DEV Community

Cover image for How to E2E Test Postmark Email Workflows in Playwright
zerodrop
zerodrop

Posted on

How to E2E Test Postmark Email Workflows in Playwright

Postmark is known for fast, reliable transactional email delivery. But how do you test that your Postmark emails actually arrive, contain the right content, and work end-to-end in CI?

This guide covers the full testing progression — from local development to automated Playwright tests in GitHub Actions.


The app we're testing

A Next.js API route that sends a verification email via Postmark:

// app/api/auth/signup/route.ts
import { ServerClient } from 'postmark';

const client = new ServerClient(process.env.POSTMARK_API_TOKEN!);

export async function POST(req: Request) {
  const { email } = await req.json();

  const token = crypto.randomUUID();
  const verifyUrl = `${process.env.NEXT_PUBLIC_URL}/verify?token=${token}`;

  await client.sendEmail({
    From: 'noreply@yourapp.com',
    To: email,
    Subject: 'Verify your email',
    HtmlBody: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
    MessageStream: 'outbound',
  });

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

Stage 1 — Local development: Postmark test message stream

Postmark has a dedicated test message stream that accepts emails without delivering them. Change MessageStream from outbound to outbound with a test server token:

// Use Postmark's test API token for local development
const client = new ServerClient(
  process.env.NODE_ENV === 'development'
    ? 'POSTMARK_API_TEST' // Postmark's built-in test token
    : process.env.POSTMARK_API_TOKEN!
);
Enter fullscreen mode Exit fullscreen mode

Postmark's POSTMARK_API_TEST token accepts all emails and returns a success response without delivering anything. You can inspect sent emails in your Postmark dashboard under the test server.

What it solves: Does my app call Postmark correctly? Is my email template valid?

What it doesn't solve: Automated testing. You can't read emails from Postmark's test server programmatically in a Playwright test.


Stage 2 — Staging: Postmark live token to a real inbox

Switch to your live Postmark server token for staging:

const client = new ServerClient(process.env.POSTMARK_API_TOKEN!);
Enter fullscreen mode Exit fullscreen mode

Emails now go through Postmark's real delivery infrastructure. You can manually verify they arrive, links work, and the content is correct. Catches real issues like missing DKIM records or template rendering bugs.

What it solves: Does the email actually reach a real inbox end-to-end?

What it doesn't solve: Automation. You can't run this in CI without a real inbox your test can read.


Stage 3 — CI: Postmark live token + ZeroDrop

For automated Playwright tests in GitHub Actions:

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

test('user can sign up and verify email', async ({ page }) => {
  // 1. Generate a disposable inbox
  const inbox = mail.generateInbox();
  // → "swift-x7k2m@zerodrop-sandbox.online"

  // 2. Sign up — Postmark sends a real verification email to this inbox
  await page.goto('/signup');
  await page.fill('[data-testid="email"]', inbox);
  await page.click('[data-testid="submit"]');

  await expect(page).toHaveURL('/check-email');

  // 3. ZeroDrop catches the email — magic link auto-extracted
  const email = await mail.waitForLatest(inbox, { timeout: 30000 });

  expect(email.subject).toContain('Verify your email');
  expect(email.magicLink).not.toBeNull();

  // 4. Click the verification link
  await page.goto(email.magicLink!);

  // 5. Assert verified
  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

OTP flows

If your app sends a numeric OTP via Postmark:

await client.sendEmail({
  From: 'noreply@yourapp.com',
  To: email,
  Subject: 'Your verification code',
  HtmlBody: `<p>Your code is: <strong>${otp}</strong></p>`,
  MessageStream: 'outbound',
});
Enter fullscreen mode Exit fullscreen mode
const email = await mail.waitForLatest(inbox, { timeout: 30000 });

// OTP auto-extracted at the edge — no regex needed
expect(email.otp).not.toBeNull();
await page.fill('[data-testid="otp"]', email.otp!);
await page.click('[data-testid="verify"]');
Enter fullscreen mode Exit fullscreen mode

Using Postmark Templates

If you use Postmark's template system:

await client.sendEmailWithTemplate({
  From: 'noreply@yourapp.com',
  To: email,
  TemplateAlias: 'verify-email',
  TemplateModel: {
    verify_url: verifyUrl,
    product_name: 'YourApp',
  },
  MessageStream: 'outbound',
});
Enter fullscreen mode Exit fullscreen mode
// ZeroDrop catches the fully rendered template output
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
Enter fullscreen mode Exit fullscreen mode

This tests that your Postmark template renders correctly with real data — something the test token can't verify.


GitHub Actions workflow

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - run: npx playwright install --with-deps chromium

      - name: Generate test inbox
        id: inbox
        uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0

      - name: Run E2E tests
        run: npx playwright test
        env:
          TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
          POSTMARK_API_TOKEN: ${{ secrets.POSTMARK_API_TOKEN }}
          NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}
Enter fullscreen mode Exit fullscreen mode
// Use CI inbox or generate locally
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
Enter fullscreen mode Exit fullscreen mode

The full picture

Test token (local) Live token (staging) Live token + ZeroDrop (CI)
Validates API call
No real emails sent
Tests template rendering
Automated in CI
Parallel test runs
OTP auto-extraction
Tests real delivery

Use the test token during development, the live token for manual staging verification, and the live token + ZeroDrop for automated CI.


ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
zerodrop.dev · docs · npm

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

The important test is not only "did Postmark accept the request?"

For auth flows, I like the end-to-end assertion to follow the user path: email received, link extracted, token accepted once, token rejected after use or expiry. That catches a whole class of bugs that a mocked send call will never see.