A signup flow is tested only once the confirmation message has been delivered and its link works. A mock of the mail sender checks whether the application attempted to send a message, not whether the message arrived. Public disposable mail services are of limited use as well. Signup forms may reject their domains, the services may limit the request rate, and the test data leaves the test environment.

Approach

I built a service that provides a mail domain with a REST API. A test creates a mailbox with a POST request and receives a randomly generated, unique address such as 3f9a1c2e@example.com. The test uses this address to complete the signup and then retrieves the message through the API. The mailbox expires after 24 hours.

Architecture

SenTdeesrtSMTPPoRsEtSfTixLMTPaFiSaoQssLtmiAttPpeId

I configured Postfix to accept messages only for the service’s domain and to forward them to the application over LMTP, and I disabled outbound delivery.

relay_domains     = example.com
relay_transport   = lmtp:inet:app:10024
default_transport = error:outbound disabled

LMTP is the protocol a mail server uses to hand mail to a mailbox system (RFC 2033 ). It largely corresponds to SMTP but assumes that the receiving side manages no queue. Postfix therefore keeps queuing, TLS and retries, and the application only decides whether to accept and store a message.

The LMTP receiver uses aiosmtpd and runs in the same process as the FastAPI application. Both access the same SQLite database.

Acceptance and rejection

Postfix has no knowledge of the mailboxes, so the application decides at RCPT TO, before the content is transferred. It rejects unknown and expired addresses with 550 No such user here (RFC 5321 ).

The cleanup may delete a mailbox between RCPT TO and DATA. Storing the message then fails on the foreign key, and the application responds with 451 Try again later:

except aiosqlite.IntegrityError:
    return REPLY_TRY_LATER

Postfix retries the delivery, and on the second attempt the application rejects the address at RCPT TO permanently. The reply codes of the protocol thus carry the error handling, and the application needs no retry logic of its own.

Mailbox lifetime

An expired mailbox is immediately unavailable to the API and the receiver, even while its row remains in the database. A periodic cleanup removes expired mailboxes, and ON DELETE CASCADE deletes their messages with them.

DNS configuration

The domain has the following records.

RecordPurpose
MXdelivery target for the domain
SPF -allno server is authorised to send on behalf of the domain
DMARC p=rejectmessages with a forged sender are rejected
MTA-STS, TLS-RPTdelivery over TLS, reports on failures

The records follow the specifications for SPF (RFC 7208 ), DMARC (RFC 9989 ), MTA-STS (RFC 8461 ) and TLS-RPT (RFC 8460 ). Because the server sends no messages, SPF and DMARC protect the domain against misuse. Any message with this sender domain is forged.

Use in a test

A test requires three calls. It creates the mailbox, polls it until a message is present, and then retrieves the message.

API=https://tempmail.example.com/v1

curl -X POST $API/emails \
  -H "X-API-Key: $API_KEY"

curl $API/emails/3f9a1c2e@example.com/messages \
  -H "X-API-Key: $API_KEY"

curl $API/messages/f47ac10b-58cc-4372-a567-0e02b2c3d479 \
  -H "X-API-Key: $API_KEY"

The last response contains the text and HTML parts separately, from which the test extracts the link or code.

Limitations

I designed the service for testing only. It only receives mail and keeps no message beyond the expiry of its mailbox. The API offers no call that waits for a message, so the test polls at intervals. For confirmation messages that arrive within a few seconds, this approach is sufficient.

Conclusion

The service closes the gap between a mocked send and an actual delivery. Tests can therefore check the message a user would receive.