Mail to a domain with receiving on goes to SES, is scanned for spam and viruses, stored, parsed into headers, text, HTML and attachments, and matched against the routing rules. Apps read it through email.received webhooks or the receiving API.

Turn receiving on

Add the domain with receiving: true, or set it later with PATCH /v1/domains/{id} and { "receiving": true } (the console's Inbound page has a button for it). Two records join the domain's DNS records:

Record Value Why
MX 10 at the domain inbound-smtp.us-east-1.amazonaws.com (the primary region) Brings the domain's mail to SES.
TXT at <selector>._domainkey.<domain> v=DKIM1; k=rsa; p=… The public key of the organization's ARC seal on forwarded mail.

On a connected Cloudflare zone the domain page writes them; otherwise copy them. The Inbound page shows the SES receipt rule and both records with their status, and Check now looks them up again. Moving an existing inbound domain: lower the MX TTL a day ahead and switch once it has passed.

Turning receiving on creates the domain's receiving rule, with spam and virus scanning on; turning it off removes the rule and the ARC record.

Mail to any address at the domain is then accepted. Use a subdomain such as inbound.acme.example so the apex keeps its own mail.

Routing rules

A rule takes addresses of one domain and says what happens to their mail:

curl
curl https://api.minket.com/v1/inbound/routes \
  -H "Authorization: Bearer $MINKET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "match": { "type": "plus", "local_part": "support" },
    "action": { "type": "webhook", "webhook_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c" },
    "spam_handling": "quarantine"
  }'
match.type Takes
address Exactly <local_part>@<domain>, in any letter case.
plus <local_part>+<anything>@<domain>. Hand out support+ticket4471@inbound.acme.example and the full address comes back in received_for.
catchall Every address of the domain no other rule takes.
action.type Does
webhook Sends email.received to that live endpoint of the project, whatever events the endpoint subscribes to. Deleting the endpoint deletes the rule.
forward Forwards to up to five confirmed addresses (see Forwarding).
store Keeps the message for the API and the console only.

Each envelope recipient takes the most specific rules: an exact address, then a plus base, then the catch-all. Several rules for the same address all act. Mail no rule takes goes to every endpoint subscribed to email.received. Received mail belongs to the project, not to one of its environments, so that means the subscribed endpoints of every environment of the message's mode: a preview endpoint whose events lists email.received receives production's inbound mail, and a test endpoint never receives live mail. Outside production the endpoint must list email.received; one without events (every type) gets it in production only.

spam_handling decides what happens when SES's spam scan fails: flag (the default) acts and marks the message spam: true, quarantine keeps it with status quarantined and does nothing, drop keeps only its metadata. A message that fails the virus scan is always dropped: its metadata stays, its content is deleted.

GET /v1/inbound/routes lists the rules, PATCH /v1/inbound/routes/{id} changes match, action, spam_handling or enabled (only the fields you send), DELETE /v1/inbound/routes/{id} removes one. Changing rules needs a full-access live key.

The received message

GET /v1/inbound/{id} returns the parsed message:

Field Meaning
from, to, cc, bcc, reply_to Addresses from the headers; at most 50 per list.
received_for The envelope recipients: who the message was delivered for, as SES names it in its trace header, or the addresses of the receiving domain in To and Cc when SES names none (several recipients in one delivery). A Bcc or catch-all delivery shows here and not in to. Route on this field.
subject, message_id, in_reply_to, headers headers is a flat object, the first of each name, lowercased.
text, html The parsed body. Inline images are data: URIs in html (up to 2 MB in all); ?html_format=cid keeps the cid: references.
attachments id, filename, content_type, size, content_id, disposition per attachment.
verdicts SPF, DKIM, DMARC, spam and virus, see below.
spam, virus True when SES's scan failed.
status received, routed, forwarded, quarantined or dropped.
routing What happened, per rule: { "kind": "forward", "target": "owner@acme.example", "status": "done", "email_id": "…" }. A refused forward says why in error.
issues What a limit cut or refused, see Limits.
raw A download_url for the message as received, valid until expires_at: an hour at most.
size, domain_id, created_at

GET /v1/inbound lists messages newest first without headers, text, html and raw, filtered by domain_id, from, to (matches to and received_for), status and spam, with limit, after and before.

TypeScript
const page = await minket.inbound.list({ to: "support@inbound.acme.example" });
const message = await minket.inbound.get(page.data[0]?.id ?? "");

Each message also raises email.received (payload), except DMARC reports and forwarding confirmations. An app that only receives mail should hold an inbound_read key, which reads messages and attachments and nothing else.

Verdicts

Verdict Values
spf pass, fail, softfail, neutral, none, temperror, permerror
dkim pass, fail, none, temperror, permerror
dmarc pass, fail, none, temperror, permerror
spam, virus pass, fail, unknown

They are read only from the headers SES writes at the top of each message it receives: Authentication-Results with the authserv-id amazonses.com, and X-SES-Spam-Verdict and X-SES-Virus-Verdict. Anything below SES's own block, or under another authority, is ignored however it is spelled, so a sender cannot write its own dmarc=pass. Nor can the parts of SES's header that SES copies from the sender (the MAIL FROM address and the HELO name) add a result: quoted strings and comments are read whole, and a header that is not laid out as SES writes it (one SPF result with its fields, the DKIM results, one DMARC result) is not trusted at all. none means SES gave no result. Treat dmarc: "fail" from a domain that publishes p=reject as spoofed, and let message content drive automatic actions only when spam is pass.

Attachments

Content is never inlined in API responses. GET /v1/inbound/{id}/attachments lists attachments with a presigned download_url each, valid until its expires_at; GET /v1/inbound/{id}/attachments/{attachmentId} returns one. Fetch the URL as it is, without an Authorization header; it downloads with the stored content type. A URL lives an hour at most; expires_at says when it stops working, and asking again returns a fresh URL. Content (parts, attachments, the raw message) is kept for 30 days; the metadata stays.

POST /v1/inbound/{id}/share returns a link to the message for up to 48 hours, as sharing a sent email does; it shows inline images and loads nothing remote.

Forwarding

Forwarding sends a received message on through the send service, from the receiving address. It happens only when all of this holds:

  • SES scanned the message as spam pass and not as a virus;
  • it went through fewer than 3 platform forwards (counted from its X-Minket-Forwarded headers, not from Received), which stops loops between forwarding mailboxes;
  • every address it goes to is confirmed.

The forward has From set to the address it was received at ("Jane Doe via inbound.acme.example" <support@inbound.acme.example>), because SES sends only from verified domains, and Reply-To set to the sender, so replies reach them. It carries one X-Minket-Forwarded header per hop and an ARC set (ARC-Authentication-Results with SES's verdicts, ARC-Message-Signature, ARC-Seal) sealed with the organization's key, so receivers that trust the platform can see how the original authenticated. The receiving domain must be verified for sending and send transactional mail.

While the project's kill switch is on, a rule's forward is held, not failed: its routing entry stays pending with a note saying so, and the forward goes out once the switch is cleared (When sending pauses).

Confirming an address. In the console, Inbound, Forwarding addresses, Add address mails the address a request from a verified receiving domain. Its owner confirms by replying from that mailbox; the reply can be empty. The reply goes to minket-confirm-<token>@<receiving domain> and confirms the address only when it comes from that address, SES authenticated it for the address's domain (DMARC pass, or SPF or DKIM aligned: the same organizational domain, read from the Public Suffix List, so another registrant under co.il or gov.uk never counts), it is not an automatic reply, and it arrives within 72 hours. There is no code to type in: sent mail is readable in the console and the API, so a mailed code would let whoever adds an address confirm it themselves. Ask again mails a new request (once a minute at most).

POST /v1/inbound/{id}/forward forwards one stored message:

Shell
curl https://api.minket.com/v1/inbound/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a80/forward \
  -H "Authorization: Bearer $MINKET_API_KEY" \
  -H "Idempotency-Key: forward-4471" \
  -H "Content-Type: application/json" \
  -d '{ "to": ["owner@acme.example"], "passthrough": true }'

passthrough: true (the default) sends the message as it arrived; false with your own text or html sends a new message with the original attached as forwarded_message.eml. from may name another address on a verified domain of the project. The answer is a send's ({ "id", "status", … }). A rule's forward is made once per message and rule, however often the event is delivered.

Limits

Untrusted mail never stops the worker: what breaks a limit is recorded on the message in issues.

Limit Value Then
Message size 40 MB Not parsed (message_too_large); metadata and verdicts kept.
Header bytes, all parts 256 KB Not parsed (headers_too_large).
MIME parts 1000 Not parsed (too_many_parts).
Multipart nesting 32 levels Not parsed (too_deep).
Attached messages read inline 3 levels Deeper ones stay attachments (nested_messages_kept).
Attachments 100 The rest are dropped (attachments_truncated).
Addresses per list 50 The rest are dropped (addresses_truncated).
Webhook content 256 KB Cut, truncated: true.
Forwarding hops 3 Not forwarded (forward_loop).

A message that cannot be parsed (malformed too) is dropped, keeps its raw download for 30 days and raises no webhook.

Mail to dmarc@<receiving domain> is read as DMARC aggregate reports and reaches webhooks only through a rule for dmarc (DMARC reports).

Updated