Receive email on your domain
Receive mail on your domains. Routing rules, envelope recipients, verdicts from SES, attachments, forwarding, limits and the receiving API.
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. (the primary region) |
Brings the domain's mail to SES. |
TXT at <selector>. |
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 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"
}'await minket.inbound.routes.create({
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. 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",. 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.
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
passand not as a virus; - it went through fewer than 3 platform forwards (counted from its
X-Minket-Forwardedheaders, not fromReceived), 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:
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).