DEV Community

jasonmills94
jasonmills94

Posted on

Dockerized Mail Capture for AWS CI Pipelines

Email verification tests become unreliable when every CI job reads from the same inbox. A message from a previous run can look valid, a retry can consume the wrong token, and a failed pipeline can leave sensitive URLs in artifacts.

A better boundary is to give each run its own disposable address and capture service. Package the capture layer in Docker, make the fixture lifecycle explicit, and pass only a small receipt back to the AWS pipeline. The result is easier to debug without turning email content into permanent test data.

Why email capture belongs inside the test boundary

An email test has more moving parts than a normal API assertion:

  • the application creates a verification token;
  • a mail provider or local capture service accepts the message;
  • the test finds the expected message;
  • the browser or API consumes the token;
  • cleanup removes the fixture after the run.

If those parts share a mailbox, the test has no strong answer to a basic question: did this message belong to this attempt? Subject matching is not enough when two AWS jobs run in parallel. A unique recipient, run identifier, and expiration time are much stronger correlation fields.

The same ownership idea is useful for one-owner email fixtures. One test attempt should create, read, and release its own fixture. Other jobs should not be able to discover it by accident.

Give every CI run its own fixture

The fixture contract can stay small. At creation time, record something like this:

{
  "run_id": "build-1842-attempt-1",
  "recipient": "build-1842-attempt-1@example.test",
  "fixture_id": "mail-7f31",
  "expires_at": "2026-10-13T03:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

The address does not need to be memorable. It needs to be unique, searchable, and scoped to one attempt. Store the fixture identifier in the test environment and use it when querying messages. Do not use the newest message in an inbox as an implicit selector.

A useful rule is that a retry creates a new fixture and a new token. Reusing the original one seems efficient, but the failure history become ambiguous. It also make parallel debugging harder because the same message can be claimed twice. Fresh identifiers cost very little compared with investigating a false-positive test.

A small Docker capture service

The capture container can expose a private HTTP API to the test runner while accepting SMTP from the application container. It should support only the operations the tests need:

POST /fixtures       create a run-scoped fixture
POST /messages/query find messages for a fixture
POST /messages/claim claim one matching message
DELETE /fixtures/id  release a fixture
Enter fullscreen mode Exit fullscreen mode

Keep the service on an isolated Docker network. The application can reach the SMTP endpoint, while the public edge of the test environment cannot. A minimal compose fragment might look like this:

services:
  mail-capture:
    image: internal/mail-capture:latest
    networks: [test-net]
    environment:
      FIXTURE_TTL_SECONDS: "900"
  app:
    environment:
      SMTP_HOST: mail-capture
      SMTP_PORT: "2525"
    networks: [test-net]

networks:
  test-net:
    internal: true
Enter fullscreen mode Exit fullscreen mode

The image should be pinned by digest in the real pipeline, not left on a floating latest tag. The capture service also needs a bounded body size and a clear policy for attachments. Most verification tests need a link and a few headers, not a permanent archive of every MIME part.

Keep AWS pipeline logs useful but safe

AWS CI logs are valuable when they show correlation, timing, and the final assertion. They are risky when they show a complete verification URL. A receipt should contain identifiers and outcomes, for example:

{
  "fixture_id": "mail-7f31",
  "message_id": "msg-91ab",
  "received_at": "2026-10-13T02:35:12Z",
  "token_status": "accepted",
  "cleanup_status": "released"
}
Enter fullscreen mode Exit fullscreen mode

Keep the raw token in memory for the shortest practical time. Redact query strings before the logger sees them, rather than trying to hide them later in a dashboard. If a debugging artifact must include the message, encrypt it, shorten its retention, and make access explicit.

This receipt pattern also fits delivery receipts in deployment gates. A pipeline should be able to prove that delivery and consumption happened for the intended run, without copying the whole email into the release record.

Legacy suites sometimes contain search values such as dummy e mail or fake e mail com. Keep those as plain test data when compatibility requires it. They should never become backlink anchors, credentials, or production selectors.

Cleanup, retries, and failure receipts

Cleanup must work on both success and failure. Ask the test runner to release the fixture in a finally block, then let a janitor remove abandoned fixtures after their TTL. The janitor is the last line of defense for cancelled jobs and broken agents.

For an AWS pipeline, publish the small receipt as a short-lived artifact and emit metrics for:

  • time from fixture creation to message arrival;
  • messages rejected because their run id did not match;
  • expired fixtures found by the janitor;
  • token claims that were already consumed;
  • cleanup failures.

Do not make the cleanup step depend on the browser test passing. The pipeline become much easier to operate when resource release has its own result and alert path. A failed cleanup should be visible, but it should not cause the test to retry and send another message without a decision.

Operational checklist

Before adopting this pattern, check that:

  1. Every attempt gets a unique disposable address and fixture id.
  2. The capture image is pinned and the Docker network is private.
  3. Queries require fixture correlation, not only a subject line.
  4. Verification URLs and message bodies are excluded from normal logs.
  5. Retries create fresh fixtures and tokens.
  6. TTL cleanup handles cancelled and retryed jobs.
  7. Receipts contain enough timing data to diagnose delivery delays.

The contract pay off when a failure happens at 2 AM: the pipeline can show what was created, what arrived, what was claimed, and what was released without exposing the secret that made the test pass.

Questions teams usually ask

Should the capture service run in every job?

For parallel or security-sensitive suites, yes. A per-job container gives the cleanest ownership boundary. A shared service can work for lower-risk tests if fixture ids, permissions, and cleanup are enforced, but it adds another coordination layer.

Do we need a real mail provider?

Not for every verification test. Local Docker capture is usually enough to prove that the application generated the right message and that the consumer handled the token. Keep a smaller provider-backed suite for delivery behavior that cannot be simulated locally.

What belongs in a failure artifact?

Keep the fixture id, message id, timestamps, status codes, and redacted headers. A full message body is prefered only when a narrowly scoped incident investigation requires it, with short retention and controlled access.

Top comments (0)