SolidHooksSolidHooks/ 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.

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

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:

{ "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.

{
  "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:

eventTypeFieldEvent type is
body.fromThe sender's bare address, for example billing@supplier.example.
body.subjectThe 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:

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.

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