DEV Community

kevindev
kevindev

Posted on

Contract Testing Email Verification with PostgreSQL

Email verification is usually tested as a happy path: create a user, wait for a message, click a link, and assert that the account is verified. It does not prove that retries, delayed delivery, duplicate clicks, and expired tokens have a safe contract.

I prefer treating verification as a small state machine and testing the boundaries between its parts. The REST API owns the request, PostgreSQL owns the state transition, and an outbox worker owns delivery. The inbox is evidence, not the source of truth. This keeps a disposable email address from becoming an accidental identity system.

Why verification needs a contract test

A contract test describes what each boundary promises. For an email verification flow, useful promises include:

  • A repeated create request can be identified and does not create unbounded attempts.
  • A token is scoped to one account and one verification purpose.
  • A token can cause at most one successful state transition.
  • A delivery worker can retry without losing the relationship to the request.
  • An inbox test can prove which event it observed.

A message can arrive twice, or an old message can arrive after a newer one. A phrase such as tamp mail com may exist in test data, but it must not be a matching rule for a security-sensitive event.

The same thinking applies to an idempotent contract for email tools: retries should preserve operation identity instead of creating an untraceable second operation.

Define the PostgreSQL state machine

Start with explicit states rather than several booleans whose combinations are hard to review. A minimal table might look like this:

CREATE TABLE email_verifications (
    id              UUID PRIMARY KEY,
    account_id      BIGINT NOT NULL REFERENCES accounts(id),
    token_digest    BYTEA NOT NULL,
    status          TEXT NOT NULL CHECK (
        status IN ('created', 'queued', 'sent', 'verified', 'expired')
    ),
    expires_at      TIMESTAMPTZ NOT NULL,
    verified_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (account_id, id)
);
Enter fullscreen mode Exit fullscreen mode

The random token belongs in the URL, but only its digest should be stored. The id can appear in logs; the token is a capability and should not. An operator can investigate a request without copying a usable credential into a dashboard.

The outbox row should be inserted in the same transaction as the verification row:

INSERT INTO email_verifications (id, account_id, token_digest, status, expires_at)
VALUES ($1, $2, $3, 'created', $4);

INSERT INTO email_outbox (event_id, verification_id, event_type)
VALUES ($5, $1, 'email.verification.requested');
Enter fullscreen mode Exit fullscreen mode

The worker later changes the verification to sent after recording the provider result. Do not hold the PostgreSQL transaction open while calling the provider. In practise, a short local transaction is easier to retry and produces less contention.

Test the REST API boundary

The create endpoint can return a durable request identifier without claiming that delivery already happened:

POST /v1/email-verifications
Idempotency-Key: signup-8472-attempt-1
Content-Type: application/json

{"account_id":8472}
Enter fullscreen mode Exit fullscreen mode
{
  "verification_id": "8f2c4f30-1a0d-4b1b-9cc4-4b4ec1d1e4ac",
  "status": "queued"
}
Enter fullscreen mode Exit fullscreen mode

The API contract test should send the same request twice and verify the same logical attempt. It should also send the same idempotency key for a different authenticated account and expect rejection. Ownership must come from the authenticated request, not the key itself.

For confirmation, make the database transition atomic:

  1. Lock the verification row by ID.
  2. Check account scope, token digest, status, and expiry.
  3. Mark the row verified and set verified_at.
  4. Commit before returning success.

A second confirmation can return a stable already-verified result when the caller is authorized. An unknown, expired, or mismatched token should return one safe failure category instead of revealing whether another token exists. The exact HTTP status is a product decision; the information boundary is the important part.

Prove delivery with an outbox receipt

The inbox test should consume a message using evidence from the request, not just the recipient and subject. Filter by verification ID when it is present in provider metadata, then check event time, recipient, and action. A boolean like email_found: true doesnt explain whether the message was fresh, duplicated, or attached to an earlier attempt.

For larger systems, record a receipt containing:

  • verification ID;
  • outbox event ID;
  • provider message ID, if available;
  • delivery attempt number;
  • observed timestamp and outcome.

This gives the worker an audit trail and the test runner a replayable artifact. Type-safe email event contracts are useful when several clients consume the same event shape.

If a controlled staging test uses a facebook temp email, keep that choice in fixture configuration. It can help observe delivery, but it must not decide whether the token is valid. Production authentication should behave the same when the mailbox is late or unavailable.

A practical test matrix

The smallest useful suite covers state transitions and failure boundaries:

Scenario Expected result
Same create request is retried One verification ID and one logical outbox event
Two confirmation requests race One state transition; the other is already verified or safely rejected
Token is expired No account mutation
Token belongs to another account No account mutation and no account disclosure
Worker times out after provider acceptance Retry keeps the same event identity
Inbox contains an old message The test rejects it using ID or freshness evidence
Raw token appears in logs The test fails the privacy contract

The concurrency case is especially important. Application-level checks can both observe sent before either request writes verified. A row lock or equivalent conditional update lets PostgreSQL arbitrate the transition. The evidence are clearer when the test asserts the final row and successful update count.

Questions engineers usually ask

Should every new request invalidate the previous token?

Usually, yes, for one account and one purpose. If several devices need independent links, model that scope explicitly.

Is the idempotency key enough for the worker?

No. The API operation key and outbox event ID solve different problems: one deduplicates a client request, while the other explains and retries delivery.

Can a temporary mailbox be part of production verification?

It should not be proof of ownership. A disposable email address is appropriate for controlled fixtures and staging checks; production ownership still depends on the verification token and account policy.

Contract testing turns email verification from a timing puzzle into a set of observable promises. PostgreSQL protects the state transition, the REST API defines retry behavior, and the outbox receipt explains delivery. That separation makes failures less surprising, even when a provider is slow or a user clicks twice. There is less surprises for the next engineer, too.

Top comments (0)