DEV Community

jasonmills94
jasonmills94

Posted on

Docker Mailbox Leases for AWS CI

Email verification tests become much easier to operate when the test mailbox is treated like any other short-lived cloud resource. I use the same mental model for a container, a temporary database, or an S3 prefix: allocate it for one job, attach an owner, set an expiry, and clean it up when the job ends.

That sounds obvious, but many CI pipelines still treat an inbox as a shared string in a secret. A parallel job can read the wrong message, a retry can consume an old token, and a failed runner can leave a mailbox around for the next build. The result is a flaky test that looks like an application problem.

This post describes a lease-based pattern I have used around Docker jobs running in AWS CI. It also fits teams searching for a reliable tempmail disposable workflow without making the email provider the center of the architecture. Email verification is a signal rather than an identity, so the test should prove ownership and timing instead of trusting the newest message blindly.

Why a mailbox should behave like a leased container resource

A CI mailbox needs boundaries. At minimum, the job should know:

  • a unique mailbox or alias
  • the build and test name that own it
  • the time the verification action started
  • a lease expiration time
  • the cleanup status

Without those fields, debugging turns into archaeology. Someone sees a message from an earlier run and assumes the product sent it correctly. Someone else types tempail or temp org mail into a search box while looking for the setup note. That kind of naming confusion is a symptom of missing ownership data, not a useful test strategy.

The lease does not need to be complicated. A job id plus a random suffix is usually enough for isolation. The important part is that the inbox name is generated at runtime and passed to the application under test, rather than copied from a shared environment variable.

The lease contract for an AWS CI job

I define the contract before writing the test helper:

  1. Allocate: create or reserve one mailbox for the job.
  2. Publish ownership: store the job id, test id, and created_at with the lease.
  3. Use: submit the address to the application and poll only for messages newer than the trigger time.
  4. Expire: reject messages received after the lease deadline or from an unexpected mailbox.
  5. Release: delete the lease in a finally path, including after a failed assertion.

The CI runner should never log the full message body or verification token. Log the mailbox identifier, message id, timestamps, and rejection reason. Those fields are enough for triage in most cases and keep the evidence smaller.

The same thinking applies to the rest of the pipeline. I keep alert boundaries explicit for cluster maintenance too; the notes on alert boundaries for EKS freeze windows are useful when deciding which failures should page and which should only annotate a build.

A Docker-based implementation

The test container can receive a lease as environment variables. The provider-specific client stays outside the browser test so the test only sees a small contract.

services:
  email-tests:
    image: my-org/email-tests:ci
    environment:
      MAILBOX_ID: ${MAILBOX_ID}
      MAILBOX_EXPIRES_AT: ${MAILBOX_EXPIRES_AT}
      CI_JOB_ID: ${CI_JOB_ID}
    command: ["npm", "run", "test:email"]
Enter fullscreen mode Exit fullscreen mode

The helper should make the time boundary visible. A simplified TypeScript shape looks like this:

type MailLease = {
  address: string;
  owner: string;
  expiresAt: Date;
};

async function waitForCode(lease: MailLease, startedAt: Date) {
  if (new Date() >= lease.expiresAt) throw new Error("mailbox lease expired");

  const messages = await mail.poll(lease.address, { since: startedAt });
  const match = messages.find((message) =>
    message.subject === "Verify your account" &&
    message.receivedAt < lease.expiresAt &&
    message.metadata?.owner === lease.owner
  );

  if (!match) throw new Error("no owned verification message found");
  return extractCode(match);
}
Enter fullscreen mode Exit fullscreen mode

In a real implementation I also record the polling attempts and the provider request id. Keep that evidence attached to the CI artifact, not printed into every test log. For more background on treating verification data as a bounded signal, see email verification as a signal rather than an identity.

Failure handling and cleanup

There are three failures worth separating:

  • Allocation failure: the job never received a lease. Fail fast and do not start browser tests.
  • Delivery failure: the lease exists, but no matching message arrives before the deadline. Save polling evidence and mark the provider path as suspect.
  • Ownership failure: a message arrives, but its timestamp, address, or owner metadata does not match. Reject it even if the code would make the test pass.

Cleanup must run independently of the assertion result. In a shell wrapper, use a trap around the test command. In application code, use try/finally. Also add a scheduled janitor for leases whose runners disappeared; relying only on the runner's exit hook is optimistic, and AWS capacity events can interrupt a job without giving your script much notice.

Do not make retries reuse the same lease automatically. A retry can reuse the test definition, but it should receive a new owner and a new mailbox lease. Otherwise the second attempt may accidentally validate an email produced by the first attempt.

Operational checklist

Before shipping this pattern, I check the following:

  • Is the mailbox unique per job and test shard?
  • Are started_at and expires_at compared in one time standard?
  • Does the matcher verify address, owner, subject, and message age?
  • Are message bodies and tokens excluded from normal logs?
  • Does cleanup run after both pass and fail?
  • Is there a janitor for abandoned leases?
  • Can a failed run show why each candidate message was rejected?

If the answer is yes, the email provider becomes an implementation detail. The Docker job owns a bounded resource, AWS CI gets deterministic failure signals, and a retry no longer hides the original cause.

Q&A

Should every CI job get a separate mailbox?

Every job that can run in parallel should get a separate lease. Sequential jobs may share a provider account, but they should still use distinct aliases or a fresh lease id so old messages cannot satisfy a new assertion.

Is a lease useful if the provider has no delete API?

Yes. Expiration and ownership checks still prevent stale messages from being accepted. A janitor can then remove local lease records even when provider-side cleanup is limited.

What should be retried?

Retry the provider poll when the lease is valid. Do not retry indefinitely, and do not accept a message merely because it is the latest one. Once the lease expires, fail with evidence and allocate a new lease for a new attempt.

Top comments (0)