# Receive email on your domain

> Receive mail on your domains. Routing rules, envelope recipients, verdicts from SES, attachments, forwarding, limits and the receiving API.

Source: https://docs.minket.com/inbound/
Updated: 2026-10-29

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:

```sh tab=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"
  }'
```

```ts tab
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.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](https://docs.minket.com/inbound/#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](https://docs.minket.com/inbound/#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`.

```ts
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](https://docs.minket.com/webhooks/#payloads)), 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](https://docs.minket.com/sending/#share-an-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](https://docs.minket.com/deliverability/#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:

```sh
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](https://docs.minket.com/dmarc/)).
