An accepted email is queued (or scheduled). From there a worker builds the message once and hands it to Amazon SES.

From queued to sent

  1. The send worker picks up the email and checks the project's kill switch and the recipients' suppressions and consent again: a contact who unsubscribed from marketing mail, or opted out of the email's topic, since it was accepted is left out, and when nobody is left the email is suppressed. Then it builds the MIME message: your HTML and text (a text part is generated from HTML-only mail), attachments, inline images, tracking and the platform's headers.
  2. It writes an attempt to its ledger, calls SES in the domain's primary region, and records the answer.
  3. When SES accepts the message, the email becomes sent, with provider: { "name": "ses", "region": "us-east-1" }. SES's own Send event follows within seconds as email.sent.

sent means SES accepted the message for delivery, not that the recipient's server took it: that is delivered. A bounced or delivery_delayed event can still follow a sent.

Scheduled mail fires at scheduled_at. A lost fire is fired again, so a scheduled email goes out at most about ten minutes late.

Failover

A domain created with failover: true has an identity in us-east-1 and one in us-west-2, each with its own DKIM keys and MAIL FROM host (One identity per region). The worker moves to us-west-2 only when us-east-1 answered with a failure that proves nothing was sent:

us-east-1 answer Fails over
Sending paused, account suspended yes
503 service unavailable yes
Daily sending quota spent yes
Throttling (rate_limit_exceeded) no, retried later in us-east-1
Rejected content, an invalid address, an unverified identity no, the email fails
A timeout, a reset connection, a 500, no answer no, never: SES may have accepted it

Failover never runs after an uncertain answer, since sending again could deliver the email twice. It needs the us-west-2 identity verified; until then mail stays in us-east-1. Each region keeps its own reputation, so a domain that only ever fails over has a cold one in us-west-2.

Never sent twice

Every provider call is surrounded by a ledger row, written before the call and completed after it. Queue messages can arrive more than once and workers can crash mid-call, so the worker reads the ledger first:

  • An attempt that was accepted: nothing is sent; the email is brought up to date.
  • An attempt still in flight, because a worker crashed or SES's answer was lost: nothing is sent. The worker looks for proof that SES accepted it (its Send event) on each redelivery.
  • No proof after 15 minutes: the email fails with outcome_unknown and is not sent again.

outcome_unknown is rare and means exactly what it says: the email may or may not have gone out. Look at the recipient's side before sending it again, with a new idempotency key, since the old one answers with the original result. Two workers holding the same job race on the attempt number in the ledger, and only one reaches SES.

Retries and failure reasons

A throttled send is retried after a short, growing backoff: 1, 2, 4, 8, 16 and 30 seconds, each with some random spread. If SES still throttles it, and for other temporary failures, it is retried through the queue about every six minutes. After five of those tries, or at once for anything a retry cannot fix, the email becomes failed and an email.failed event carries the reason:

failed.reason Means What to do
rate_limit_exceeded SES kept throttling the account through every retry, about half an hour Spread sends out
daily_quota_exceeded The SES daily quota is spent in every region tried Send later
sending_paused SES paused the account in every region tried Check alerts in the console; send again once cleared
domain_not_verified The domain was removed, sending was turned off for it, or SES no longer has its identity Fix the domain in the console
content_blocked SES rejected the content (for example a virus) Change the content
validation_error SES refused an address or a header Fix the request
payload_too_large The message is above 40 MB after encoding Send large files as links
invalid_attachment A stored attachment's content is gone Send the email again with the file
invalid_state The stored content expired (30 days) Send the email again
outcome_unknown See above See above

Mail queued while sending is paused is held, not failed (When sending pauses). When every recipient of a queued email was suppressed after it was accepted (a scheduled email can wait 30 days), nothing is sent and the email becomes suppressed (Suppressions).

Open and click tracking

Tracking is off unless the domain turns it on (tracking.opens, tracking.clicks) or the email asks for it with tracking:

TypeScript
await minket.emails.send({
  from: "Acme <news@news.acme.example>",
  to: "jane@example.com",
  subject: "October at Acme",
  html: '<p>Read <a href="https://acme.example/blog/october">the October update</a>.</p><p><a href="https://acme.example/unsubscribe">Unsubscribe</a></p>',
  stream: "marketing",
  tracking: { opens: true, clicks: true },
});
  • Clicks. Every http and https link in the HTML body becomes https://links.minket.com/click/<token>. The token names a stored link, never a URL, so the redirect always goes to the link that was in the email, whatever the request adds. A click records email.clicked once per email, link and day, and redirects. The first 500 links of an email are tracked; later ones stay as written.
  • Opens. A 1×1 image at the end of the body records email.opened once per email and UTC day.
  • Never rewritten: mailto: and tel: links, relative links, the platform's own unsubscribe and preference links, and anything outside <body>, in comments, scripts or styles. The plain-text part keeps the real addresses.
  • Apple Mail Privacy Protection loads every image when the message arrives, so opens from Apple Mail users are recorded whether or not anyone read the email. Treat open rates as an upper bound and prefer clicks for decisions.
  • Tracked links are stored with the email. Turn click tracking off (tracking: { clicks: false }) for mail with sign-in or password-reset links, so those links are neither stored nor routed through the tracking host.

Marketing mail and any mail to a contact also gets the one-click List-Unsubscribe headers (One-click unsubscribe).

The Message-ID

The message is built with Message-ID: <email id@sending domain>, but SES replaces it with its own. message_id on the email is the one SES wrote, filled in from SES's Send event and null until then; every email event carries it in data.message_id from then on. The platform's id travels in two headers SES keeps:

  • X-Minket-Email-Id: <email id>
  • References: <email id@sending domain>, after any References you sent, so replies thread with the original.

The platform's own SES message tags route SES's events back to the email; your own tags never reach SES, and come back in events.

Updated