> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Inbound email parse pipeline

> How a received email crosses from the inbound funnel into Orbit: the parse stages that turn raw MIME into structured fields, the recipient-route match, the attachment hand-off to the threat scan, and the conversation or ticket the message resolves to — the email sibling of the SMS MO chain.

# Inbound email parse pipeline

Every email that lands on your inbound funnel crosses the same chain on its way into your workspace: the provider receives it at the funnel, Orbit parses the raw MIME into structured fields, the recipient is matched against a route you registered, the attachments are handed to the threat scan, and the message resolves to a conversation thread or a new ticket. This page explains that chain end to end — the email sibling of the [SMS MO chain](/concepts/dlr-and-mo-pipeline), read alongside it so the two channels share one shape.

## Scope

This page covers the **inbound** email parse pipeline only — how a received email becomes a conversation or ticket in your workspace. Two related pages cover the other directions and are deferred to here:

* **Outbound email** — the send path, status machine, bounces, and the retry queue — is [Email delivery lifecycle](/concepts/email-delivery-lifecycle). That page answers "what happened to a message I sent"; this one answers "what happened to a message I received."
* **Attachment threat scanning** — the byte-level scan that decides whether a part is archived or quarantined — is [Inbound attachment threat scan](/concepts/inbound-attachment-threat-scan). This page hands attachments to that scan and resumes once the verdict is in.

## The inbound funnel

Orbit receives your inbound email at a **dedicated funnel subdomain** — `send.<domain>`, provisioned per tenant — not at your apex domain. The funnel is the host your MX records point at Orbit's ingress, and it is the address the provider delivers to. The [transport security model](/concepts/email-transport-security-model) covers the MX gate that grades that record; this page starts one step later, at the moment a message arrives on the funnel.

Why a subdomain and not the apex? On a platform that both sends and receives email on the same domain, the apex carries your SPF, DKIM, and DMARC records for outbound — and the return-path pair the provider issues at `send.<domain>` for bounces and complaints. Receiving inbound mail at the same host as the return-path would conflate two directions that have different deliverability requirements. The funnel isolates the receive side so the MX gate grades exactly the record your tenant provisioned, and so a sender-only domain that never receives is not asked for an MX it never needed.

## The parse pipeline stages

A received email moves through five stages from the provider to a resolved conversation. The chain is pure ingress — no outbound path is touched, so the [outbound termination rule](/concepts/dlr-and-mo-pipeline#provider-scope-receive-only-inbound-providers) that governs SMS exit is not implicated.

1. **The provider receives at the funnel.** A sender's mail server delivers to the provider's MX target for `send.<domain>`. The provider accepts the SMTP submission, flattens its own multipart envelope, and POSTs the raw MIME to your inbound-parse receiver as a signed `text/plain` body.
2. **The receiver verifies and parses.** The receiver (`POST /email/inbound-parse/:tenantSchema`) verifies the `X-Orbit-Signature` HMAC over the raw body, then parses the MIME into structured fields: `from`, `to`, `subject`, the decoded `text` and `html` bodies, and an `attachments` array that carries metadata only — filenames, content types, decoded byte sizes, and inline `Content-ID` values. Attachment bytes are never inlined into the parsed JSON.
3. **Sender and thread validation.** The recipient addresses are collected in authority order: the SMTP envelope recipient (`Delivered-To`, `X-Envelope-To`, `X-Original-To`) first, then the `To` and `Cc` headers. Matching the envelope before the headers is why BCC, aliased, and forwarded mail — where your address never appears in `To` — still routes correctly.
4. **Body extraction and normalization.** The text and HTML bodies are decoded per their `Content-Transfer-Encoding` (base64, quoted-printable, or 7bit/8bit). Folded headers are unfolded. A message with no parseable body degrades to whatever parts were extractable rather than failing the whole parse.
5. **Attachment hand-off and route resolution.** The attachments are handed to the [threat scan](/concepts/inbound-attachment-threat-scan), which byte-scans each part and returns a verdict — `clean` (archived), `suspicious` (archived, flagged), or `malicious` (quarantined, dropped). In parallel, the recipient is matched against your active routes and the message resolves to a conversation thread or a new ticket.

This is the email analogue of the [MO chain](/concepts/dlr-and-mo-pipeline#the-mo-path-deliver_sm-to-inbound-message) on the SMS side: the same shape — receive, normalize, route, dispatch — applied to a MIME body instead of an SMPP PDU. Read the two sections as siblings.

## Idempotency and retries

A provider that does not receive a clean `2xx` retries the delivery — sometimes minutes later, sometimes hours. Without dedup, a replayed delivery would create a second ticket for the same message. The parse pipeline answers that the same way the MO dead-letter queue does on the SMS side.

### Replay dedup

Every inbound email carries a `Message-ID` header (RFC 5322). When one is present, the pipeline claims an idempotency key against it **before** any write — so a provider retry that lands while the first delivery is still being processed, or a duplicate that arrives hours later, collapses onto the same key and is dropped as a duplicate rather than processed twice. The provider gets its `202` acknowledgement either way, so it does not re-fire the same message indefinitely.

### Dead-letter behaviour for transient resolution failures

A Postgres failover, a connection-pool spike, or contention on a per-sender lock can raise a transient error exactly as the conversation or ticket is being written. The carrier already got its acknowledgement — the message is gone from the network's point of view — so without a second path that inbound email would be silently lost.

Orbit's answer mirrors the [MO dead-letter queue](/concepts/dlr-and-mo-pipeline#when-mo-ingest-fails-transiently): when the resolution step fails transiently, the message is enqueued to an internal retry queue carrying the tenant, sender, destination, body, and the original wire payload. A background worker re-runs the resolution on a widening schedule — one minute, five minutes, thirty minutes, then two hours — and if it still fails after the fourth attempt, the row is held as a dead letter for seven days instead of being discarded. Once a retry succeeds, the rest of the chain runs as usual.

Do not confuse this with the [webhook delivery retry queue](/concepts/webhook-delivery-semantics): that one covers re-delivering an event *to your subscriber endpoint* when your server cannot accept it. This one covers the opposite direction — the inbound resolution path itself, before your webhook was ever in play.

## Where the parsed message lands

After the route match, the message resolves to one of three destinations. The tenant rules you configure decide which one:

| Destination | When it happens | What you observe |
| - | - | - |
| **Conversation thread** | The sender's address and a normalized subject key match an existing open ticket from the same requester | A new comment is appended to the existing ticket; the ticket stays in its current state |
| **New ticket** | No matching open thread, or the sender is unknown | A fresh ticket opens in your inbox with the email as its first entry |
| **Spam drop** | The message is flagged as auto-generated bulk mail (auto-responders, vacation notices, list mail) | No ticket is created; the message is logged and dropped to prevent a mail loop |

The threading key is the sender address plus a normalized subject — prefixes like `Re:` and `Fwd:` are stripped so a reply to a reply threads onto the original. A reply without a parseable sender always opens a fresh ticket, because the threading key has nothing to match against.

Alongside the ticket, the platform fires a `message.received` webhook event to your subscribers — the same event the SMS MO path emits, with `channel: "email"` and the email-specific metadata under `metadata`. This is the email analogue of the `message.received` event on the SMS side: a tenant that listens on the canonical webhook bus receives the same signal whether the inbound message arrived over SMS or email.

## Failure modes

| Failure | What you observe | What to check |
| - | - | - |
| **Unparseable MIME** | The receiver returns `422` with a validation error; the provider may retry. A malformed but partially-extractable message degrades to whatever fields were parseable rather than failing the whole parse. | The raw MIME body and the `Content-Transfer-Encoding` of the failing part. |
| **Unknown sender** | No ticket threads; a fresh ticket opens (or the message is dropped if it matches the auto-generated filter). | The `from` header and whether the sender address is a contact your tenant recognizes. |
| **Attachment quarantined** | The ticket opens without the attachment; `metadata.email_attachments_quarantined` on the message row carries the filename, the verdict band, and the finding codes. | The [threat scan concept](/concepts/inbound-attachment-threat-scan) for the verdict vocabulary and the operator workflow to work a quarantine to closure. |
| **No matching thread** | The message opens as a new ticket rather than threading onto an existing one. | The subject normalization — a subject that differs after prefix stripping does not thread. If you expected a thread, check that the subject key matches. |
| **No matching route** | The receiver returns `202` with `matched: false` and `reason: "no-route"`; no forward, no ticket. | Your active routes (`GET /email/inbound-routes`) and the recipient pattern shapes — the envelope recipient is matched first, then `To` and `Cc`. |

## Observability

Each stage of the pipeline surfaces in two places:

* **Webhook events.** A matched inbound email fires `message.received` to your subscribers (channel `email`, with `message_id`, `from`, `to`, `body`, and `metadata` carrying the subject, route id, matched recipient, and attachment count). The [webhook delivery semantics](/concepts/webhook-delivery-semantics) page covers the retry rules for that event.
* **Route health in the dashboard.** Each route tracks `failureCount`, `lastForwardedAt`, and `lastFailureAt` — a successful forward resets the failure count; a non-2xx response or a network error increments it. Read these on **Email → Inbound routes** to spot a destination URL that has been failing. A route whose tenant schema has not yet had the inbound-routes table applied degrades to an empty list rather than surfacing a 500, so a brand-new tenant mid-provision does not show a broken state.

The 202 acknowledgement the receiver returns carries the matched state at each hop: `matched`, `routeId`, `matchedRecipient`, `forwarded`, `destinationStatus`, `ticketId`, and `ticketThreaded`. That response is the per-delivery health signal — the same shape a provider webhook consumer reads to confirm the message was accepted.

## Worked example: one inbound email from provider to conversation

A customer sends a reply to your support address. Here is the JSON state at each hop, from the provider webhook to the resolved ticket.

**1. The provider delivers to the funnel.** The provider POSTs the raw MIME to your receiver, signed with the platform HMAC:

```json theme={null}
{
  "method": "POST",
  "path": "/api/v1/email/inbound-parse/your-tenant-schema",
  "headers": {
    "content-type": "text/plain",
    "x-orbit-signature": "sha256=a1b2c3…"
  },
  "body": "From: Jane Customer <jane@example.com>\r\nTo: support@send.acme.io\r\nSubject: Re: Order #1234\r\nMessage-ID: <95b1f744e4e2@example.com>\r\n\r\nHi — what's the status of order #1234?"
}
```

**2. The receiver verifies and parses.** The signature is checked; the MIME is parsed into structured fields. No attachments on this message:

```json theme={null}
{
  "from": "Jane Customer <jane@example.com>",
  "to": "support@send.acme.io",
  "toAddress": "support@send.acme.io",
  "subject": "Re: Order #1234",
  "text": "Hi — what's the status of order #1234?",
  "html": null,
  "attachments": [],
  "headers": {
    "from": "Jane Customer <jane@example.com>",
    "to": "support@send.acme.io",
    "subject": "Re: Order #1234",
    "message-id": "<95b1f744e4e2@example.com>"
  }
}
```

**3. The route matches.** The recipient `support@send.acme.io` is collected from the envelope, matched against your active route for that pattern, and the parsed payload is forwarded to your destination URL as a signed JSON POST:

```json theme={null}
{
  "from": "Jane Customer <jane@example.com>",
  "to": "support@send.acme.io",
  "matchedRecipient": "support@send.acme.io",
  "subject": "Re: Order #1234",
  "text": "Hi — what's the status of order #1234?",
  "html": null,
  "attachments": [],
  "headers": {
    "from": "Jane Customer <jane@example.com>",
    "to": "support@send.acme.io",
    "subject": "Re: Order #1234",
    "message-id": "<95b1f744e4e2@example.com>"
  }
}
```

**4. The conversation resolves.** The sender address and the normalized subject (`order #1234`, with the `Re:` prefix stripped) match an open ticket from the same requester — so the reply threads onto that ticket as a new comment rather than opening a fresh one. The `message.received` event fires to your webhook subscribers in parallel:

```json theme={null}
{
  "type": "message.received",
  "message_id": "95b1f744e4e2@example.com",
  "channel": "email",
  "from": "jane@example.com",
  "to": "support@send.acme.io",
  "body": "Hi — what's the status of order #1234?",
  "metadata": {
    "subject": "Re: Order #1234",
    "route_id": "einr_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "matched_recipient": "support@send.acme.io",
    "attachment_count": 0
  }
}
```

**5. The receiver acknowledges.** The provider gets a `202` with the matched state at every hop — the forward succeeded, the ticket threaded, and the event was dispatched:

```json theme={null}
{
  "data": {
    "matched": true,
    "routeId": "einr_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "matchedRecipient": "support@send.acme.io",
    "forwarded": true,
    "destinationStatus": 200,
    "ticketId": "tkt_7f8e9d0c1b2a3f4e5d6c7b8a9",
    "ticketThreaded": true
  }
}
```

Had the subject not matched an open thread, `ticketThreaded` would be `false` and `ticketId` would carry a fresh ticket id — the message opens a new ticket instead of threading.

## See also

<CardGroup cols={2}>
  <Card title="Email delivery lifecycle" href="/concepts/email-delivery-lifecycle">The outbound twin — how a sent email moves from provider acceptance to a mailbox outcome.</Card>
  <Card title="DLR and MO gateway pipeline" href="/concepts/dlr-and-mo-pipeline">The SMS sibling — the carrier-to-tenant chain this page mirrors for email.</Card>
  <Card title="Inbound attachment threat scan" href="/concepts/inbound-attachment-threat-scan">The byte-level scan this pipeline hands attachments to.</Card>
  <Card title="Inbound message routing" href="/concepts/inbound-message-routing">The tenant-level rule engine that decides where a received message goes.</Card>
  <Card title="Inbound email and SMS routing" href="/guides/inbound-email-parse">The end-to-end guide for configuring inbound parse on a domain.</Card>
  <Card title="Inbound email-to-ticket recipes" href="/guides/inbound-email-to-ticket-route-recipes">Recipes for routing inbound email into tickets and queues.</Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.