July 7, 2026

Testing outbound email flows with Playwright

Outbound email often gets tested indirectly. The app creates a row, a job gets queued, a mocked provider returns success, and the test passes. But the things users care about happen after that: the message reaches the right recipient, its content survives delivery, attachments remain intact, and replies return to the original sender.

Mailisk can test that delivery path without sending CI traffic to personal inboxes or a production email provider. A test can send from a Mailisk namespace, deliver back into the same namespace, and inspect the received message through the API.

This post uses Playwright as an API test runner, so the first example does not open a browser. If your application sends through Mailisk, replace the direct sendEmail call with the browser action or application API request that triggers the message. The inbox assertions stay the same.

For a complete list of SDK methods and options, see the Mailisk Node.js guide. Here we'll focus on making the tests isolated and reliable.

Setup

Install the Mailisk Node SDK:

npm install --save-dev mailisk

Add your API key and namespace to .env or your CI secret store:

MAILISK_API_KEY=<your-api-key>
MAILISK_NAMESPACE=<your-namespace>

Load those values in your Playwright config or test setup:

import dotenv from "dotenv";

dotenv.config();

Test same-namespace delivery

Same-namespace delivery is a useful default for CI. Mailisk namespaces accept arbitrary address prefixes, so every run can use a unique recipient and avoid matching an older message.

import { expect, test } from "@playwright/test";
import { MailiskClient } from "mailisk";

const mailisk = new MailiskClient({ apiKey: process.env.MAILISK_API_KEY });
const namespace = process.env.MAILISK_NAMESPACE!;

test("delivers an outbound email", async () => {
  const runId = Date.now();
  const to = `outbound-${runId}@${namespace}.mailisk.net`;
  const subject = `Outbound test ${runId}`;
  const fromTimestamp = Math.floor(Date.now() / 1000);

  const outboundEmail = await mailisk.sendEmail(namespace, {
    from: {
      email: `support@${namespace}.mailisk.net`,
      name: "Support",
    },
    to: [to],
    subject,
    text: "This is the plain text body.",
    html: "<p>This is the HTML body.</p>",
  });

  expect(outboundEmail.status).toBe("queued");
  expect(outboundEmail.recipient_count).toBe(1);

  const { data: emails } = await mailisk.searchInbox(
    namespace,
    {
      to_addr_prefix: to,
      subject_includes: subject,
      from_timestamp: fromTimestamp,
    },
    { timeout: 60_000 },
  );

  expect(emails).toHaveLength(1);

  const [email] = emails;
  expect(email.subject).toBe(subject);
  expect(email.to.map((recipient) => recipient.address)).toContain(to);
  expect(email.text).toContain("plain text body");
  expect(email.html).toContain("HTML body");
});

searchInbox long-polls until a matching email arrives. The Node SDK waits for up to five minutes by default; this example uses a one-minute timeout so a broken send fails faster. Capturing from_timestamp immediately before the trigger prevents a previous run from matching, while the unique recipient and subject isolate parallel tests.

The inbox assertion is the important part of this test. It proves that Mailisk accepted the outbound request, processed it, delivered it into the namespace, and stored the resulting message for inspection.

Inspect the delivery snapshot

sendEmail also returns an outbound email ID. Use it to inspect the current message status, recipients, delivery summary, and provider events:

const detail = await mailisk.getOutboundEmail(outboundEmail.id);

expect(["queued", "sending", "sent"]).toContain(detail.status);
expect(detail.recipients).toHaveLength(1);
expect(detail.recipients[0].email).toBe(to);

const summarizedRecipients = Object.values(detail.delivery_summary).reduce(
  (total, count) => total + count,
  0,
);
expect(summarizedRecipients).toBe(outboundEmail.recipient_count);

This endpoint returns a snapshot rather than waiting for a particular state. A recipient might be pending, queued, sent, or delivered by the time the request completes, so avoid asserting one intermediate delivery status unless your test polls for it.

For same-namespace tests, searchInbox should normally be the main delivery assertion. Use getOutboundEmail when you also need to inspect provider state or diagnose a failure.

Verify an attachment end to end

Attachment bugs often happen in encoding or MIME handling rather than in the mail row itself. Comparing the downloaded bytes with the original content covers that boundary.

import { Buffer } from "node:buffer";

test("preserves an outbound attachment", async () => {
  const runId = Date.now();
  const content = Buffer.from("quarter,total\nQ1,100\n", "utf8");
  const to = `attachment-${runId}@${namespace}.mailisk.net`;
  const subject = `Attachment test ${runId}`;
  const fromTimestamp = Math.floor(Date.now() / 1000);

  await mailisk.sendEmail(namespace, {
    to: [to],
    subject,
    text: "The report is attached.",
    attachments: [
      {
        filename: "report.csv",
        content_type: "application/octet-stream",
        content_base64: content.toString("base64"),
      },
    ],
  });

  const { data: emails } = await mailisk.searchInbox(namespace, {
    to_addr_prefix: to,
    subject_includes: subject,
    from_timestamp: fromTimestamp,
  });

  expect(emails).toHaveLength(1);
  expect(emails[0].attachments).toHaveLength(1);

  const [attachment] = emails[0].attachments;
  expect(attachment.filename).toBe("report.csv");

  const downloaded = await mailisk.downloadAttachment(attachment.id);
  expect(downloaded.equals(content)).toBe(true);
});

Use application/octet-stream when the file should remain a downloadable attachment across mail clients and parsers.

Test a reply flow

Replies are most useful to test as a complete round trip. The following example creates a message from a unique customer address, receives it at a support address, replies to the inbound message, and verifies that the reply returns to the customer.

test("replies to the original sender", async () => {
  const runId = Date.now();
  const customer = `customer-${runId}@${namespace}.mailisk.net`;
  const support = `support-${runId}@${namespace}.mailisk.net`;
  const subject = `Billing question ${runId}`;
  const fromTimestamp = Math.floor(Date.now() / 1000);

  await mailisk.sendEmail(namespace, {
    from: { email: customer, name: "Customer" },
    to: [support],
    subject,
    text: "Can you help me with this invoice?",
  });

  const { data: received } = await mailisk.searchInbox(namespace, {
    to_addr_prefix: support,
    subject_includes: subject,
    from_timestamp: fromTimestamp,
  });
  expect(received).toHaveLength(1);

  const reply = await mailisk.replyToEmail(received[0].id, {
    text: "Thanks, we received your message.",
  });

  expect(reply.type).toBe("reply");
  expect(reply.status).toBe("queued");

  const { data: replies } = await mailisk.searchInbox(namespace, {
    to_addr_prefix: customer,
    subject_includes: `Re: ${subject}`,
    from_timestamp: fromTimestamp,
  });

  expect(replies).toHaveLength(1);
  expect(replies[0].subject).toBe(`Re: ${subject}`);
  expect(replies[0].text).toContain("we received your message");
});

Forwarding uses the same pattern: call forwardEmail with a unique same-namespace recipient, then search that recipient's inbox and assert the forwarded subject and content. See the Node.js guide for the method signature.

External recipients

Same-namespace sends are best for fast CI because Mailisk can verify both sides of the flow. External recipients must be verified before Mailisk will send to them, and provider or mailbox filtering can make those tests slower.

getOutboundEmail can show whether Mailisk accepted, sent, or received a provider event for an external message. It cannot prove that the message is visible in the destination inbox unless the test also has access to that inbox. Keep external checks small and treat them as targeted provider tests rather than your default smoke test.

Keeping tests stable

  • Give every run a unique recipient and subject.
  • Capture from_timestamp immediately before triggering the email.
  • Filter by recipient, subject, and timestamp together.
  • Use an explicit timeout that reflects the expected delivery time.
  • Prefer same-namespace delivery for routine CI checks.
  • Poll delivery status only when the state itself is part of the behavior under test.

With those boundaries in place, Playwright can test more than whether an email job was created. It can verify that the resulting message arrived with the expected recipient, content, and files, and that a reply completed the return path.

Ready to start testing emails?
Create a free account.

Get started