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 });
}
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!
);
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!);
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
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');
});
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',
});
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"]');
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',
});
// ZeroDrop catches the fully rendered template output
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
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 }}
// Use CI inbox or generate locally
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
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)
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.