---
title: "Receiving and Parsing Inbound Email"
canonical: "https://test.abhinandan.one/python-receive-email"
markdown_url: "https://test.abhinandan.one/python-receive-email.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Python SDK"
description: "Convert a raw inbound webhook body into a typed ReceivedEmail dataclass with primitive.receive(), then pass it straight into client.reply()."
keywords: ["primitive.receive", "normalize_received_email", "ReceivedEmail", "reply_target", "handle_webhook", "ReceivedEmailThread"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:55:01.779153+00:00"
source_files:
  - "sdk-python/src/primitive/received_email.py"
  - "sdk-python/README.md"
  - "sdk-python/tests/test_client.py"
sections:
  - {anchor: "normalize-an-inbound-webhook-in-one-call", title: "Normalize an inbound webhook in one call"}
  - {anchor: "step-install-the-sdk-and-set-your-api-key", title: "Install the SDK and set your API key"}
  - {anchor: "step-call-primitivereceive-with-the-raw-body-headers-and-webhook-secret", title: "Call primitive.receive with the raw body, headers, and webhook secret"}
  - {anchor: "step-read-the-normalized-fields-off-the-returned-receivedemail", title: "Read the normalized fields off the returned ReceivedEmail"}
  - {anchor: "what-receive-does-under-the-hood", title: "What `receive()` does under the hood"}
  - {anchor: "how-each-field-is-derived", title: "How each field is derived"}
  - {anchor: "address-shape-receivedemailaddress", title: "Address shape: `ReceivedEmailAddress`"}
  - {anchor: "thread-shape-receivedemailthread", title: "Thread shape: `ReceivedEmailThread`"}
  - {anchor: "next-steps", title: "Next steps"}
---

> Documentation index: https://test.abhinandan.one/llms.txt

# Receiving and Parsing Inbound Email

Turn a raw inbound webhook payload into a normalized ReceivedEmail object with sender, reply-target, and thread fields, using primitive.receive or normalize_received_email.

Every inbound `email.received` webhook arrives as a big, schema-validated JSON payload. You rarely want to work with that shape directly: you want the sender's address, the thread headers, and the body text. `primitive.receive(...)` gives you that normalized object, a `ReceivedEmail` dataclass, in one call, ready to hand to `client.reply(...)` or `client.forward(...)`.

Use this page when you're writing the webhook handler itself. If you already have a `ReceivedEmail` and want to send a reply, see [Replying and Forwarding](https://test.abhinandan.one/python-reply-forward.md). If you need to verify the webhook signature yourself instead of letting `receive()` do it, see [Verifying Webhook Signatures](https://test.abhinandan.one/python-webhook-verification.md).

## Normalize an inbound webhook in one call

### 1. Install the SDK and set your API key

```bash
pip install primitivedotdev
export PRIMITIVE_API_KEY=prim_test
```

See [Install and Configure the Python SDK](https://test.abhinandan.one/python-sdk-quickstart.md) if you haven't set up a client yet.

### 2. Call primitive.receive with the raw body, headers, and webhook secret

```python
import primitive

client = primitive.client(api_key="prim_test")

def webhook_handler(body: bytes, headers: dict[str, str]) -> dict[str, object]:
    email = primitive.receive(
        body=body,
        headers=headers,
        secret="whsec_...",
    )

    client.reply(email, "Thank you for your email.")
    return {"ok": True}
```

`body` must be the exact raw bytes of the request, before any JSON parsing, re-serializing the body (even just re-encoding identical JSON) can change whitespace and break signature verification. `secret` is your account's webhook signing secret (`whsec_...`), the same one used to verify the `Primitive-Signature` header.

### 3. Read the normalized fields off the returned ReceivedEmail

```python
email.sender.address       # "alice@example.com"
email.sender.name          # "Alice" or None

email.received_by          # the address that received this email
email.received_by_all      # list of every address it was sent to

email.reply_target.address # who a reply should go to (may differ from sender)
email.reply_subject        # "Re: <original subject>", idempotently prefixed
email.forward_subject      # "Fwd: <original subject>", idempotently prefixed

email.subject
email.text

email.thread.message_id
email.thread.references

email.raw                  # the full validated EmailReceivedEvent, if you need it
```

**Expected result**: `webhook_handler` returns `{"ok": True}` after sending a reply, and your reply thread appears under the original message in the recipient's client, `client.reply(email, ...)` derives the `Re:` subject and threading headers server-side from `email.id`.

## What `receive()` does under the hood

`primitive.receive(...)` calls `handle_webhook(...)` (verify signature, then validate against the JSON schema) and pipes the result through `normalize_received_email(...)`. If you already have a validated `EmailReceivedEvent`, skip straight to normalization:

```python
from primitive import handle_webhook
from primitive.received_email import normalize_received_email

raw_body: bytes = b"..."  # the exact request bytes
headers: dict[str, str] = {}  # the request headers

event = handle_webhook(body=raw_body, headers=headers, secret="whsec_...")
email = normalize_received_email(event)
```

Webhook signature verification itself (the `Primitive-Signature` HMAC scheme, tolerance window, and error codes) is documented on [Verifying Webhook Signatures](https://test.abhinandan.one/python-webhook-verification.md).

## How each field is derived

Every `ReceivedEmail` field is either read straight off the `EmailReceivedEvent` or derived with an explicit fallback rule, listed below. Knowing the rules helps you predict edge cases.

Header address parsing rejects any `From`/`Reply-To` value longer than 998 UTF-8 bytes (the RFC 5322 line limit) and falls back rather than guessing.

| Field | Derived from |
| --- | --- |
| `sender` | Strict-parsed `From` header. Falls back to the SMTP envelope sender (`email.smtp.mail_from`) if the header doesn't strict-parse to a single valid address. |
| `reply_target` | The first `email.parsed.reply_to` entry, when its address passes the same addr-spec check. Otherwise falls back to `sender`. |
| `received_by` | The first address in `email.smtp.rcpt_to`. |
| `received_by_all` | The full `email.smtp.rcpt_to` list. |
| `reply_subject` | `subject` with an idempotent `Re: ` prefix (won't double-prefix a subject that already starts with `Re:`, case-insensitive). Bare `Re:` when the subject is empty. |
| `forward_subject` | Same idempotent-prefix logic, with `Fwd: `. |
| `thread.message_id` | `email.headers.message_id`, unmodified. |
| `thread.in_reply_to` / `thread.references` | `email.parsed.in_reply_to` / `email.parsed.references`, defaulting to empty lists. |
| `raw` | The full input `EmailReceivedEvent`, untouched, use it whenever you need a field this table doesn't cover. |

> **Warning:** `normalize_received_email` raises `ValueError` if `email.smtp.rcpt_to` is empty, every valid `email.received` event has at least one recipient, so an empty list means the payload is malformed. This is a hard invariant, not a soft warning: it means something upstream constructed a bad event.

> **Tip:** `sender` is parsed leniently for display purposes and falls back to the SMTP envelope sender when the header doesn't validate. That makes it convenient for showing "From:..." in a UI, but it is **not** a safe authorization anchor, a spoofed `From` header can still populate `sender`. Use [Authenticating Senders with SPF, DKIM, and DMARC](https://test.abhinandan.one/python-sender-trust.md) to decide whether an inbound email can be trusted before acting on it.

## Address shape: `ReceivedEmailAddress`

`sender` and `reply_target` are both `ReceivedEmailAddress`, a frozen dataclass with a lowercased `address` and an optional display `name`.

```python
@dataclass(frozen=True)
class ReceivedEmailAddress:
    address: str          # lowercased
    name: str | None = None
```

`address` is always lowercased. `format_address(...)` renders it back to a header-ready string (`"Alice <alice@example.com>"` or bare `"alice@example.com"` when there's no display name), which is what the SDK uses internally when building forwarded-message text.

## Thread shape: `ReceivedEmailThread`

`email.thread` is a `ReceivedEmailThread` dataclass carrying the parent `Message-Id` plus the `In-Reply-To` and `References` header lists.

```python
@dataclass(frozen=True)
class ReceivedEmailThread:
    message_id: str | None
    in_reply_to: list[str]
    references: list[str]
```

You rarely need to build threading headers yourself, `client.reply(...)` and `client.forward(...)` derive them server-side from the email's id. `thread` is here mainly for logging, deduplication keys, or building your own indexing scheme across a conversation.
