Blog

Every email to your domain is saved before we touch it

How imado receives mail: SES, S3 and SNS on AWS, a receipt before any delivery, idempotent retries, an hourly reconciliation, and storage our own code cannot delete from.

On this page6 sections

imado gives a domain you already own a working email address: you connect the domain, create an address like hello@yourdomain.com, and read and reply in your browser. If you have not tried it, start here.

This post is for the developer deciding whether to point a domain's MX record at us. It follows one message from the sender's mail server to a mailbox, names the mechanism at each step, shows what recovers each kind of failure, and explains what protects the stored copy.

Receiving and storage run on AWS (Amazon Web Services), in the us-east-1 region. AWS SES (Simple Email Service) accepts mail for your domain, AWS S3 stores it, and SNS, AWS's notification service, tells our code that a message has arrived. Everything after that is imado's own code, which runs on Vercel and keeps its data in Postgres.

What you can count on

  • Stored before processed. SES writes the raw message to S3 before it notifies us, so none of our code runs on a message that is not already stored.
  • Recorded before delivered. The first thing our code writes is a receipt holding every recipient, and the receipt stays open until every delivery is done.
  • Idempotent. Every step can run again safely. A second delivery to the same mailbox is refused by a unique key, so a message never lands twice.
  • Reconciled every hour. A cron job, a task that runs on a schedule, finishes any open receipt and any stored message that has no receipt.
  • Not deletable by us. Our application's credentials cannot delete an archived message, or any earlier version of one.

The rest of this post shows how each of these holds.

The path of one message

Who does each step5 steps
  1. 1SenderDelivers over SMTPTo the host your MX names
  2. 2Receiving, AWS SESAccepts and scansSpam, virus, SPF, DKIM, DMARC
  3. 3Storage, AWS S3Stores the raw MIMEBefore SNS notifies us
  4. 4imado, our codeOpens a receiptKeyed on SES's message id
  5. 5imado, our codeDelivers per mailboxOne transaction eachThen: Archived original (Storage, AWS S3)

1. SMTP to SES. When you connect a domain, one of the records you add is an MX record, which tells other mail servers where to deliver mail for the domain. Ours points at SES. The sender's mail server looks the record up and delivers the message over SMTP, encrypted with TLS whenever the sender supports it.

2. Accepted and scanned. SES accepts the message and records verdicts: spam, virus, and the SPF, DKIM and DMARC checks that say whether the sender is who the From line claims. We store the verdicts with the message. A new conversation that SES flagged as spam or a virus starts in your Spam folder, and any other starts in your Inbox.

3. Written to S3. SES writes the message, byte for byte as received (the raw MIME), to an S3 bucket that blocks all public access and encrypts every object with AES-256. Only after that write succeeds does SES publish a notification to SNS. The notification names the stored object and carries the envelope recipients, which are the addresses the message was actually sent to. That list includes blind copies (BCC), which appear nowhere in the message's headers.

4. A receipt, first. SNS posts the notification to our HTTPS endpoint. We verify its signature with AWS's signing certificate and check that it came from our own topic. A notification from any other topic is refused with a 403. Then, before reading the message, we insert a receipt row keyed on SES's message id: where the object is, when it arrived, the verdicts, and every envelope recipient. The insert does nothing if the row already exists, so a repeated notification is harmless.

5. Parsed once, delivered per mailbox. We fetch the object from S3 and parse it once: its text, its HTML, its attachments with a SHA-256 fingerprint of each, and the In-Reply-To and References headers that place it in a conversation. The parsed body is written back to S3 as a cache. Each recipient address is matched to a mailbox, and each mailbox gets the message in one Postgres transaction: the conversation, the message and its row in your list commit together or not at all. A unique key on the mailbox and SES's message id turns a second delivery into a no-op.

Last, the raw object is moved from the incoming prefix to an archive prefix (S3 has no move, so this is a copy followed by a delete), and the receipt is closed with each recipient's outcome. The order matters: the receipt closes only after the object is at the key the message rows point to.

If a step fails

What recovers each step5 steps
1SMTPThe sender's server looks up your MX record and delivers the message to SES.
2ScannedSES accepts it and records spam, virus, SPF, DKIM and DMARC verdicts.
3StoredSES writes the raw MIME to S3, then publishes a notification to SNS.
4ReceiptWe verify the SNS signature and insert a receipt keyed on SES's message id.
5DeliveredOne transaction per mailbox, then the original moves to the archive and the receipt closes.
Not accepted? The sender's server queues the message and retries for days (RFC 5321).
Notification lost? SNS retries for about an hour, and the hourly cron lists S3 for objects with no receipt.
Crashed partway? The receipt stays open and the cron runs it again. A unique key skips mailboxes already done.

No step depends on a single attempt succeeding. Three failures show how.

Our endpoint is down. The message is already in S3. SNS retries with exponential backoff: the first retry after 5 seconds, the gap growing to 5 minutes, 18 retries over about an hour, which is about as long as SNS allows. Those retries only shorten the wait. Every hour, a cron job lists the incoming prefix for objects older than 15 minutes that have no receipt. For each one, it opens a receipt from the message's To and Cc headers, keeping only addresses on domains connected to imado, and delivers it. The message arrives late, and it still arrives.

There is one case this cannot recover: every notification attempt is lost, and the message's headers do not name its imado recipient, most commonly because that recipient was a BCC. The envelope recipients existed only in the notification, so the message stays stored in S3 but reaches no mailbox.

Our code stops partway. A deploy, a timeout, or a crash between two mailboxes leaves the receipt open. The same hourly job retries every receipt older than 15 minutes, using the recipient list saved from the notification. Mailboxes that already have the message refuse the duplicate on the unique key, and the rest are filled. If the crash came after the copy to the archive but before the close, the retry finds the incoming key empty, reads the archive key instead, and finishes.

The parser fails on a message. Some MIME is malformed in ways our parser does not handle yet. The message is still delivered to every recipient on the receipt, without a sender, subject or body, and no cached body is written. Opening a message re-parses the archived original whenever the cached body is missing, so once the parser is fixed, the message's body shows when you open it, with nothing for you to do.

How the stored copy is protected

Who can delete stored mail3 kinds of access

SES

  • Can write new objects to the incoming prefix
  • Cannot delete anything

Our code's IAM role

  • Can write to the archive
  • Can delete only in the incoming and outgoing staging areas
  • Cannot delete an archived message
  • Cannot delete a version

Any key we store, without MFA

  • Cannot delete a version
  • Cannot suspend versioning or change expiry
  • Cannot change the bucket policy or delete the bucket

Versioning is on: an overwrite or a delete keeps the previous version, so it can be restored.

  • SES can only add. The bucket policy lets SES write new objects to the incoming prefix, on behalf of our AWS account only, and do nothing else.
  • Our code cannot delete an archived message. Its IAM role, the set of AWS permissions it runs with, may write to the archive but may delete only in the incoming prefix, the second half of the move, and in a staging area for outgoing mail. It cannot delete anything in the archive. We checked each permission with AWS's IAM policy simulator.
  • Versioning is on. In S3, an overwrite or a delete creates a new version and keeps the previous one, so the change can be undone. Our role has no permission to delete a version, so nothing our code does can destroy one. When a message moves to the archive, the version left in the incoming prefix expires after 30 days, because the archive holds the message.
  • Destructive changes need MFA. A statement in the bucket policy refuses, for any request made without multi-factor authentication, deleting a version, suspending versioning, changing the expiry rules, changing or deleting the policy itself, and deleting the bucket. No access key we store can do any of these on its own.
  • No long-lived credentials. Our code gets temporary AWS credentials for each request by exchanging a token Vercel signs (OIDC) with AWS STS, and the role accepts only tokens from production. The application holds no access key that could leak.
  • Never deleted to make room. We never refuse incoming mail because an account is over a limit, and we never delete old mail to make room for new (terms, section 2).

Reading it back

Your list of conversations is read from Postgres. Opening a message reads the parsed body cached in S3. If that cache is missing or corrupt, we re-parse the archived original and write the cache back. If the original cannot be read either, that one message says so, and the rest of the conversation still shows. An attachment is cut from the archived original when you download it and checked against the SHA-256 recorded when the message arrived, so the bytes you get are the bytes that were sent.

What we are adding next

  • A copy outside this AWS account. Today the originals and their earlier versions are kept in one AWS account. A copy in a separate account is next.
  • A restore drill. A full restore from backup, rehearsed and timed, is the subject of a later post.

Receiving and storage run in one AWS region. If us-east-1 stops, SES stops accepting mail for your domain, and the sender's server queues each message and retries. The email standard (RFC 5321) asks mail servers to keep retrying for at least four to five days, so mail is delayed while the region is down.

The advice in our terms still applies: keep another copy of any email you cannot afford to lose. You can see how receiving is doing right now on the status page.

Last updated