Application email QA
Test welcome email delivery in Next.js: assertions and cleanup
Assert welcome email generation, SMTP handoff, and inbound delivery in Next.js with a receive-only mailbox—and clean up test identities afterward.

To test welcome email delivery in a Next.js app you control, assert four layers separately: template generation, provider handoff, inbound receipt, and rendered UX—then delete the test user. A receive-only mailbox such as Quick Inbox is enough for manual or scripted receipt checks. Mailby’s Developer API and console are live at /developers and /account/developer when you need OTP wait APIs and webhooks for owned systems—do not treat public Quick Inbox as an open CI dump for third-party accounts.
Next.js context and boundaries
You own the application. You can create users, trigger /api/auth/register (or equivalent), and read logs. You are not trying to intercept mail for a service you do not operate.
Stack assumptions (adjust names to your repo):
- Next.js App Router or Pages Router
- Auth library or custom credentials flow that sends a welcome message after signup
- Email via Resend, Postmark, SES, Nodemailer SMTP, or similar
Boundaries:
- Mailby does not send or forward mail
- Do not claim Quick Inbox automates CI by itself—wire APIs intentionally via /developers
- Never point production welcome mail at shared disposable inboxes used by strangers
Separate the layers
Welcome email bugs hide when tests only click “sign up” and glance at a UI toast.
| Layer | Question | Typical evidence |
|---|---|---|
| Application generation | Was the job enqueued with the right template + recipient? | Server log, queue payload, unit test on template render |
| SMTP / API handoff | Did the ESP accept the message? | Provider 200, message ID |
| Inbound delivery | Did some MX accept it? | Message visible in test inbox |
| User experience | Are subject, CTA, and branding correct? | HTML preview / link extract |
Failing to separate layers produces flaky “email tests” that flake on DNS, rate limits, or preview sanitization—not on your React code.
Field notes (editorial pattern, 2026-09-24)
Working path: Local Next.js signup → Resend test mode → message ID logged → mail arrives in a dedicated receive-only address → assert subject contains Welcome → delete user via admin API → discard inbox session.
Failure path: Test asserted only that fetch('/api/register') returned 200. ESP was misconfigured in CI; no mail left the provider. The test stayed green for weeks. Fix: assert provider response and inbound receipt (or at least provider message ID in non-prod).
Test case table
| Test case | Expected message | Observable evidence | Negative case |
|---|---|---|---|
| Happy-path signup | Welcome, correct locale | Inbox subject + body CTA URL | Wrong template ID |
| Duplicate email signup | No second welcome (or explicit resend policy) | Single message / error JSON | Spammed duplicates |
| Invalid recipient rejected | No send attempt | Validation error before ESP | ESP 400 after bad address |
| Provider outage | User created or rolled back per design | Logged failure; retry/backoff | Silent drop |
| Link integrity | CTA host allowlisted | Extracted URL matches app origin | Open redirect in template |
Minimal authorized Next.js pattern
1. Make recipient injectable in non-production
// lib/email/welcome.ts — conceptual
export async function sendWelcomeEmail(user: { email: string; name: string }) {
if (process.env.EMAIL_SINK) {
user = { ...user, email: process.env.EMAIL_SINK };
}
return emailProvider.send({
to: user.email,
subject: `Welcome, ${user.name}`,
html: renderWelcomeHtml(user),
});
}
Point EMAIL_SINK at a mailbox you control: Quick Inbox address for manual runs, or a Developer inbox for automation.
2. Assert generation without the network
Unit-test renderWelcomeHtml for required strings and link shape. This catches copy and localization bugs without SMTP.
3. Assert handoff
Integration-test the provider adapter with recorded fixtures or a sandbox API key. Assert status + message id.
4. Assert inbound delivery
Manual: open /inbox, paste address into EMAIL_SINK, run signup, watch preview.
Automated (owned systems): use the live Developer API documented at /developers to wait for a message matching subject/from filters, then assert extracts. Keep secrets in CI vaults—never hardcode.
5. Cleanup
- Delete the auth user and sessions
- Revoke test API keys if scoped
- End disposable inbox sessions so addresses tombstone cleanly (/data-retention)
- Clear
EMAIL_SINKoverrides so staging cannot leak to a personal temp address overnight
Worked example: 15-minute manual gate before merge
- Spin local Next.js against staging ESP sandbox.
- Create Quick Inbox; set
EMAIL_SINK. - Sign up
ci-user-$TIMESTAMP@example.invalidlocally but sink mail to Quick Inbox. - Confirm welcome subject, CTA host, and that OTP/action links extract cleanly in safe preview.
- Click CTA on a browser profile that is not your daily driver.
- Delete user; close inbox.
If step 4 fails while step 3 returns 200, you found a handoff/delivery bug—not a form bug.
Failure cases worth encoding
Preview sanitization differences. Mailby safe HTML preview may strip scripts; your production clients may differ. Assert on text + links, not on script execution.
Rate limits. Burst signup tests can trip ESP limits; backoff and unique recipients.
Clock skew on “joined at” copy. Avoid asserting exact timestamps in HTML.
Environment bleed. A shared preview deployment with a forgotten EMAIL_SINK can send real user welcomes to a disposable address—guard with environment checks.
Alternatives to disposable receiving
| Approach | Pros | Cons |
|---|---|---|
| ESP sandbox + message API | Fast, no MX wait | Misses real MX issues |
| Mailpit/Mailhog in Docker | Great local DX | Not production-like DNS |
| Quick Inbox manual | Real public MX path | Human-in-the-loop |
| Mailby Developer API | Wait/webhook automation | Requires API plan—see /pricing |
| Permanent QA mailbox | Simple | Pollution, collisions |
For browser-level identity isolation patterns, see isolating test identities in Cypress. For spam placement when you use Gmail as a sink, see verification mail in Gmail spam.
How this differs from a test-email-workflows hub
The hub surveys QA strategies across stacks. This page is a Next.js welcome-email field guide: injectable sink, four-layer assertions, cleanup, and an explicit green-test failure story when only HTTP 200 was checked.
Short answers
What causes welcome delivery failures in Next.js? Misconfigured ESP env vars, bad templates, silent queue failures, or asserting UI success without provider/inbox evidence.
What should I do first? Split generation vs handoff vs inbound; add one inbound assertion in non-prod.
When is a permanent address safer? Never use a personal long-term mailbox as a shared CI sink; use dedicated QA identities.
What evidence changes the recommendation? If you need parallel CI waits and webhooks, move from manual Quick Inbox to /developers.
Sources, test date, limitations
- Pattern review: 2026-09-24
- Next.js docs: Server Actions / Route Handlers for where send calls usually live
- RFC 5321 SMTP interaction model
- Mailby: /developers, /inbox, /how-it-works
Limitations: Provider SDKs differ. We do not publish copy-paste production secrets. Safe preview ≠ every desktop client’s renderer—see inline images in desktop clients when you assert HTML fidelity.
Local vs preview vs production parity
Next.js developers often send welcome mail from three environments:
next devwith a sandbox API key- Vercel/preview deployments with shared secrets
- Production with live ESP keys
Parity bugs include: preview using production keys, production missing a template ID that exists in sandbox, and locale defaulting differently per region. Encode environment name in the email subject during non-prod ([staging] Welcome) so inbound assertions can reject cross-environment leakage.
Guardrails:
if (process.env.NODE_ENV === 'production' && process.env.EMAIL_SINK) {
throw new Error('EMAIL_SINK must not be set in production');
}
Template regression checklist
Each welcome template change should verify:
- Subject length under common mobile truncation limits
- CTA button URL host allowlist
- Plaintext multipart alternative present for accessibility
- Unsubscribe or manage-preferences link only when legally required—and never breaking transactional meaning
- Images follow CID or carefully hosted remote patterns (inline images guide)
Automate what you can in unit tests; keep one human glance on a real client per release.
Observability hooks
Log structured fields: templateId, toHash (not raw email in shared logs if avoidable), providerMessageId, env. When a user says “no welcome email,” support can ask for approximate signup time and correlate without fishing through personal content. Pair with retention awareness—logs are not forever (/data-retention for Mailby; your own log TTLs for your app).
Team workflow
- Developer runs manual Quick Inbox check on the PR that touches mail.
- CI runs generation + provider sandbox tests.
- Nightly job (owned systems) waits on Developer API inbox for a canary signup.
- On failure, page the mail owner—not the random frontend on-call—using a defined ownership label.
That sequence keeps temporary inboxes in their proper place: fast human verification, not a substitute for CI contracts.
Example assertion sketch (integration)
test('welcome email arrives for new user', async () => {
const email = mintSinkAddress();
const user = await signup({ email: 'user@example.test', sink: email });
const msg = await waitForMessage({ to: email, timeoutMs: 90_000 });
expect(msg.subject).toMatch(/welcome/i);
expect(extractLinks(msg.html)).toContainEqual(
expect.stringContaining(process.env.APP_ORIGIN!),
);
await deleteUser(user.id);
});
Adapt mintSinkAddress / waitForMessage to Mailpit, ESP APIs, or Mailby Developer waits. The shape matters more than the vendor: unique sink, capped wait, link allowlist, cleanup.
Handling multipart content in assertions
Welcome mail should include multipart/alternative with text and HTML. Assert that text part contains the CTA URL even if HTML fails to render in a given client. This protects users who prefer plaintext and protects your tests from HTML-minifier churn.
Internationalization and RTL welcome mail
If you ship multiple locales, add a test matrix row per locale code. Assert the correct language string and that RTL layouts still contain a valid CTA URL. Sinks and waits stay the same; only fixtures change. Missing locale rows are a common production-only bug.
Queue semantics and “fire and forget” anti-patterns
Many Next.js codebases call await sendWelcome() inside the request path. Others push to a queue and return 200 immediately. Both can be correct—but tests must match. If you return 200 before enqueue, asserting inbound mail in the same HTTP test without waiting for the worker will flake. Prefer:
- API test asserts enqueue success (job row / queue ACK).
- Worker test asserts provider handoff with a fixture job.
- End-to-end test waits on inbound with a long timeout.
Conflating these layers recreates the green-test failure from the field notes. Document the architecture in docs/email.md so new hires do not invent a fourth path.
Preview deployments and shared sinks
On preview URLs, bind EMAIL_SINK to a per-preview address derived from the git SHA. Shared sinks across previews recreate Cypress-style cross-talk. Delete preview users when the deployment is garbage-collected.
Reader takeaway box
Match the tool to the downside. Temporary receive-only mail excels at short verification and throwaway curiosity. Durable addresses and aliases excel at recovery, money, travel, and reputation. Product pages: /inbox, /pricing, /data-retention, /security, /developers, /how-it-works.
Conclusion
Welcome email tests are trustworthy only when they observe the message leaving your app and arriving somewhere you control—then cleaning up. Start with Quick Inbox for manual receipt; graduate to /developers when your team needs automated waits on systems you own.
Try it on Mailby
Open a receive-only disposable inbox when a short-lived address fits the job — session-bound, with timed purge.
