Skip to content
Engineering

How to test transactional email without sending to real users

Build a repeatable email test suite that cannot accidentally contact customers.

THE SHORT ANSWER

Test rendering, API behavior, and event handling separately using synthetic recipients and a local provider fixture. Simulated acceptance can verify application behavior, but authentication, network delivery, and inbox placement need distinct controlled checks.

Choose the evidence you need from each test

This separation makes failures explainable. If a template fixture fails, you should not need to troubleshoot DNS. If a webhook replay fails, a successful email preview is irrelevant. Avoid using a single “email test passed” badge for several unrelated claims.

LayerTest locallyWhat it does not establish
Template renderingVariables, escaping, links, plain text, layoutRendering in every email client
API integrationRequest shape, errors, stable keys, status lookupProvider acceptance or real delivery
Event handlingSignature checks, duplicates, retries, orderingThat a real provider sent the fixture
Controlled deliveryAuthentication and observations in owned test mailboxesInbox placement for all recipients

Render realistic fixtures, including awkward values

Create fixtures with long names, apostrophes, non-Latin text, missing optional fields, and very long URLs. Escape text and attribute values in the correct context. Verify the plain-text alternative alongside HTML.

Snapshot tests can catch unintended changes, but avoid treating any snapshot as automatically correct. Assert that there are no unresolved placeholders, the main action has a useful accessible name, and links use the expected scheme and host.

Use synthetic identities and sample domains. Never copy a production customer list into a test fixture, even if you believe sending is disabled. Keeping real recipient data out of the test path removes an entire class of mistakes.

Use the downloadable template fixtures →

Run a local API that cannot send mail

Download email-mock-server.mjs and run it with Node.js 22 or later. It listens only on the loopback interface, stores messages in memory, and implements a small acceptance and lookup contract. It contains no SMTP connection or provider SDK.

The fixture deliberately stays queued: it does not pretend a receiving server accepted anything. Restarting it clears its state. It is suitable for local integration exercises, not application persistence or production delivery.

Local fixture · two terminalsshell
node email-mock-server.mjs

# In another terminal:
curl http://127.0.0.1:3027/api/v1/emails \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: local-receipt-42" \
  -d '{"to":"reader@example.test","subject":"Receipt","text":"A simulated receipt"}'

# Repeat unchanged: same message ID.
# Change the text with the same key: HTTP 409.

Test the failure path as carefully as success

  • Validation rejection: the application shows an actionable error without recording a send as accepted.
  • Lost response: the operation remains recoverable with the original key and payload.
  • Rate limiting: the worker respects a delay and does not block the user request indefinitely.
  • Storage unavailable: the system does not claim to have saved a message or callback.
  • Duplicate callback: one event is stored once, even if the sender sees two successful responses.
  • Out-of-order callback: an older observation does not erase a newer one.
  • Expired content: support views handle missing retained bodies while preserving permitted event metadata.

Run the lost-response idempotency lab →

Replay signed webhook events →

Put a hard boundary around automated tests

Inject the transport into business logic rather than constructing a live provider client everywhere. Tests can supply a fixture transport, while the production entry point supplies the authenticated provider transport.

Keep live credentials out of CI jobs that exercise email behavior. Limit outbound network access where your test environment supports it. A test-only environment variable is useful, but it should not be the only barrier between a fixture and a customer mailbox.

When running a local Stampwing workspace, keep simulated mode explicit. Test keys and Live keys have different effects. The public resources do not require a Stampwing account and the downloadable fixture never sends email.

Add a separate, deliberate delivery check

After local checks pass, an operator can use owned test mailboxes to observe a real message with a configured provider. Record the sender identity, time, provider events, authentication results, and observed folder. Make this a deliberate operation rather than a side effect of the test suite.

Keep the conclusion narrow. A successful message to one controlled mailbox is evidence about that message and time. It does not validate every recipient, guarantee future inbox placement, or turn simulated test results into live measurements.

Inspect sending-domain configuration →

Sources and further reading

Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.