# Email ingestion

Source: https://solidhooks.com/docs/email-ingestion

> Give a source an email address and turn each message sent to it into a stored, routed event.

Some systems will never send a webhook. A supplier emails a notice, a bank emails an alert, an internal tool emails a nightly report. Give a source `provider: "email"` and anything sent to its address becomes an event that connections match, deliveries retry and retention expires like any other. Your destination receives JSON over HTTP and never speaks SMTP.

For a walkthrough with a worked example, see the [use-case guide](https://solidhooks.com/use-cases/ingest-webhooks-via-email).

## How it works

Email is a provider, like `github` or `generic`. There is no separate flag.

* The source view includes `emailAddress`, which is `<maskId>@sdhk.events`. It is `null` on every other provider.
* Each accepted message is stored as an event with a normalised envelope as its payload.
* The event type comes from the source's `eventTypeField`, or `email.received` if that is unset or the message does not have the field.

There is no verification step. Nothing signs an inbound email, so an email source takes no verifier and its `verifierType` is `noop`.

## Create an email source

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "Supplier notices",
    "provider": "email",
    "verifierConfig": {}
  }'
# → { "id": "src_...", "maskId": "Kb3xk29ZtQm7Rv1A",
#     "emailAddress": "Kb3xk29ZtQm7Rv1A@sdhk.events", ... }
```

Give that address to whatever sends the mail. To turn an existing source into an email source, `PATCH /v1/sources/{id}` with `{ "data": { "provider": "email" } }`.

Converting a source that already has a verifier needs `verifierType: "noop"` in the same request, because the API will not carry a stored secret onto a source that never reads one. The same request also clears the source's `idempotencyKeys`.

## One source does email or webhooks

A source ingests email or HTTP webhooks, never both. An HTTP `POST` to an email source's ingest URL returns `400 BAD_REQUEST`:

```json
{ "ok": false, "code": "BAD_REQUEST", "message": "Source ingests email and does not accept HTTP webhooks. Send email to its address instead." }
```

Mail and webhooks arrive on different paths, and only the email path produces an envelope. Keep a separate source for webhook traffic.

## The envelope

The stored payload is a normalised envelope, so your handler does not need a mail parser.

```json
{
  "from": "alice@example.com",
  "to": ["<maskId>@sdhk.events"],
  "cc": [],
  "subject": "Invoice #42",
  "messageId": "<abc123@mail.example.com>",
  "date": "Tue, 14 Jul 2026 09:00:00 +0000",
  "text": "plain-text body",
  "html": "<p>html body</p>",
  "spamStatus": "NotSpam",
  "bounce": false,
  "attachments": [
    { "name": "invoice.pdf", "contentType": "application/pdf", "size": 12345, "data": "<base64>" }
  ]
}
```

`from` is the `From:` header reduced to a bare address, so `Acme Billing <billing@supplier.example>` arrives as `billing@supplier.example`. The display name can change without the sender changing, and a per-message bounce address would never match a filter. The SMTP envelope sender is used only when a message has no `From:` header at all.

The event's raw body is the original payload from the mail server, including any fields not shown above, with `attachments[].data` set to `null`. Attachment bytes live only in the envelope, so one large file is not stored twice.

## Event type and routing

An email source's `eventTypeField` accepts only two values:

| `eventTypeField` | Event type is                                                      |
| ---------------- | ------------------------------------------------------------------ |
| `body.from`      | The sender's bare address, for example `billing@supplier.example`. |
| `body.subject`   | The subject line.                                                  |

Any other path, including `headers.*`, is rejected with `400`. These are the two single-valued fields that tell one message from another. `to` is always the source's own address, and `cc` is usually empty.

If `eventTypeField` is unset, or the message lacks that field (an email with no subject on a `body.subject` source), the type is `email.received`. In every case the type works in a connection's `filterRules.eventTypes`. Matching ignores case, so a sender that later arrives as `Billing@Supplier.example` still matches. Replaying an event routes with the type it was stored with.

To route on sender:

```bash
curl -s -X PATCH "$API/v1/sources/src_..." \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "data": { "eventTypeField": "body.from" } }'
```

A connection can then take only that supplier with `"filterRules": { "eventTypes": ["billing@supplier.example"] }`.

## Deduplication

Email sources deduplicate on the message's `Message-ID`. A repeated `Message-ID` returns `{"ok":true,"duplicate":true}` and no second event is stored. A message with no `Message-ID` has nothing to key on and is never deduplicated.

Setting `idempotencyKeys` on an email source is rejected with `400`, because the key is already decided. See [Idempotency and deduplication](https://solidhooks.com/docs/idempotency).

## Size budget

Email bodies and attachments share the source's `maxPayloadSizeKb`, which is between 1 and 4,096 and defaults to 256. Nothing is rejected for being too big. The envelope is trimmed to fit, in this order:

1. **`text`** is charged first. If it alone exceeds the budget it is sliced down to the budget.
2. **`html`** is charged next. If `text` plus `html` would exceed the budget, `html` is dropped to `null` whole. It is never partially cut.
3. **Attachments** are charged last. One that would exceed what is left keeps its `name`, `contentType` and `size` but gets `"data": null` and `"truncated": true`. Its data is omitted entirely, never partially cut.

When `text` is sliced or `html` is dropped, the envelope also carries `"bodyTruncated": true`.

## Rotate the address

`POST /v1/sources/{id}/rotate-mask` rotates the email address along with the ingest URL, because the address is derived from the `maskId`. It needs a key scoped `source: read & write`.

> **Rotating breaks the old address**
>
> The previous address stops working as soon as you rotate. Update whatever sends the mail first, or messages sent to the old address are lost.

## Next steps

* [Idempotency and deduplication](https://solidhooks.com/docs/idempotency) — how Message-ID dedupe fits the wider model
* [Connections](https://solidhooks.com/docs/connections) — route email events by sender or subject
* [Sources](https://solidhooks.com/docs/sources) — the rest of a source's settings
* [Use-case guide: ingest webhooks via email](https://solidhooks.com/use-cases/ingest-webhooks-via-email) — a worked example