{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreigqeqij5ik7cpy4e7juuryxk2aebvfsz56v5lkvc3xgvl4ivuhppy",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mols63otofj2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreifvnfxcycbf3a63vjxuhxotnkzlc7iwaccfesno5k5houe6ywd2d4"
},
"mimeType": "image/webp",
"size": 39788
},
"path": "/zerodrop/how-to-e2e-test-postmark-email-workflows-in-playwright-b51",
"publishedAt": "2026-06-18T20:43:37.000Z",
"site": "https://dev.to",
"tags": [
"playwright",
"testing",
"javascript",
"webdev",
"zerodrop.dev",
"docs",
"npm",
"@playwright"
],
"textContent": "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?\n\nThis guide covers the full testing progression — from local development to automated Playwright tests in GitHub Actions.\n\n## The app we're testing\n\nA Next.js API route that sends a verification email via Postmark:\n\n\n\n // app/api/auth/signup/route.ts\n import { ServerClient } from 'postmark';\n\n const client = new ServerClient(process.env.POSTMARK_API_TOKEN!);\n\n export async function POST(req: Request) {\n const { email } = await req.json();\n\n const token = crypto.randomUUID();\n const verifyUrl = `${process.env.NEXT_PUBLIC_URL}/verify?token=${token}`;\n\n await client.sendEmail({\n From: 'noreply@yourapp.com',\n To: email,\n Subject: 'Verify your email',\n HtmlBody: `<p>Click <a href=\"${verifyUrl}\">here</a> to verify your email.</p>`,\n MessageStream: 'outbound',\n });\n\n return Response.json({ success: true });\n }\n\n\n## Stage 1 — Local development: Postmark test message stream\n\nPostmark has a dedicated test message stream that accepts emails without delivering them. Change `MessageStream` from `outbound` to `outbound` with a test server token:\n\n\n\n // Use Postmark's test API token for local development\n const client = new ServerClient(\n process.env.NODE_ENV === 'development'\n ? 'POSTMARK_API_TEST' // Postmark's built-in test token\n : process.env.POSTMARK_API_TOKEN!\n );\n\n\nPostmark'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.\n\n**What it solves:** Does my app call Postmark correctly? Is my email template valid?\n\n**What it doesn't solve:** Automated testing. You can't read emails from Postmark's test server programmatically in a Playwright test.\n\n## Stage 2 — Staging: Postmark live token to a real inbox\n\nSwitch to your live Postmark server token for staging:\n\n\n\n const client = new ServerClient(process.env.POSTMARK_API_TOKEN!);\n\n\nEmails 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.\n\n**What it solves:** Does the email actually reach a real inbox end-to-end?\n\n**What it doesn't solve:** Automation. You can't run this in CI without a real inbox your test can read.\n\n## Stage 3 — CI: Postmark live token + ZeroDrop\n\nFor automated Playwright tests in GitHub Actions:\n\n\n\n npm install zerodrop-client\n\n\n\n import { test, expect } from '@playwright/test';\n import { ZeroDrop } from 'zerodrop-client';\n\n const mail = new ZeroDrop();\n\n test('user can sign up and verify email', async ({ page }) => {\n // 1. Generate a disposable inbox\n const inbox = mail.generateInbox();\n // → \"swift-x7k2m@zerodrop-sandbox.online\"\n\n // 2. Sign up — Postmark sends a real verification email to this inbox\n await page.goto('/signup');\n await page.fill('[data-testid=\"email\"]', inbox);\n await page.click('[data-testid=\"submit\"]');\n\n await expect(page).toHaveURL('/check-email');\n\n // 3. ZeroDrop catches the email — magic link auto-extracted\n const email = await mail.waitForLatest(inbox, { timeout: 30000 });\n\n expect(email.subject).toContain('Verify your email');\n expect(email.magicLink).not.toBeNull();\n\n // 4. Click the verification link\n await page.goto(email.magicLink!);\n\n // 5. Assert verified\n await expect(page).toHaveURL('/dashboard');\n });\n\n\n## OTP flows\n\nIf your app sends a numeric OTP via Postmark:\n\n\n\n await client.sendEmail({\n From: 'noreply@yourapp.com',\n To: email,\n Subject: 'Your verification code',\n HtmlBody: `<p>Your code is: <strong>${otp}</strong></p>`,\n MessageStream: 'outbound',\n });\n\n\n\n const email = await mail.waitForLatest(inbox, { timeout: 30000 });\n\n // OTP auto-extracted at the edge — no regex needed\n expect(email.otp).not.toBeNull();\n await page.fill('[data-testid=\"otp\"]', email.otp!);\n await page.click('[data-testid=\"verify\"]');\n\n\n## Using Postmark Templates\n\nIf you use Postmark's template system:\n\n\n\n await client.sendEmailWithTemplate({\n From: 'noreply@yourapp.com',\n To: email,\n TemplateAlias: 'verify-email',\n TemplateModel: {\n verify_url: verifyUrl,\n product_name: 'YourApp',\n },\n MessageStream: 'outbound',\n });\n\n\n\n // ZeroDrop catches the fully rendered template output\n const email = await mail.waitForLatest(inbox, { timeout: 30000 });\n expect(email.magicLink).not.toBeNull();\n\n\nThis tests that your Postmark template renders correctly with real data — something the test token can't verify.\n\n## GitHub Actions workflow\n\n\n name: E2E Tests\n\n on: [push, pull_request]\n\n jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n\n - run: npm ci\n\n - run: npx playwright install --with-deps chromium\n\n - name: Generate test inbox\n id: inbox\n uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0\n\n - name: Run E2E tests\n run: npx playwright test\n env:\n TEST_INBOX: ${{ steps.inbox.outputs.inbox }}\n POSTMARK_API_TOKEN: ${{ secrets.POSTMARK_API_TOKEN }}\n NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}\n\n\n\n // Use CI inbox or generate locally\n const inbox = process.env.TEST_INBOX ?? mail.generateInbox();\n\n\n## The full picture\n\n| Test token (local) | Live token (staging) | Live token + ZeroDrop (CI)\n---|---|---|---\nValidates API call | ✅ | ✅ | ✅\nNo real emails sent | ✅ | ❌ | ❌\nTests template rendering | ❌ | ✅ | ✅\nAutomated in CI | ❌ | ❌ | ✅\nParallel test runs | ❌ | ❌ | ✅\nOTP auto-extraction | ❌ | ❌ | ✅\nTests real delivery | ❌ | ✅ | ✅\n\nUse the test token during development, the live token for manual staging verification, and the live token + ZeroDrop for automated CI.\n\n**ZeroDrop** — disposable email inboxes for CI pipelines. Free, no signup, no Docker.\n→ zerodrop.dev · docs · npm",
"title": "How to E2E Test Postmark Email Workflows in Playwright"
}