{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/python-receive-email","markdown_url":"https://test.abhinandan.one/python-receive-email.md","article":{"id":"d9447c04-268f-4687-a166-789e9ca4ddda","article_slug":"python-receive-email","parent_article_slug":null,"parent_article_title":null,"kind":"guide","published_at":"2026-08-11T18:55:01.779153+00:00","keywords":["primitive.receive","normalize_received_email","ReceivedEmail","reply_target","handle_webhook","ReceivedEmailThread"],"meta_description":"Convert a raw inbound webhook body into a typed ReceivedEmail dataclass with primitive.receive(), then pass it straight into client.reply().","og_image_url":null,"source_file_paths":["sdk-python/src/primitive/received_email.py","sdk-python/README.md","sdk-python/tests/test_client.py"],"recording_id":null,"replayable":false,"task_name":"Receiving and Parsing Inbound Email","category":"Python SDK","summary":null,"description":"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.","content_kind":"repo_page","content_markdown":"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(...)`.\n\nUse 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](python-reply-forward). If you need to verify the webhook signature yourself instead of letting `receive()` do it, see [Verifying Webhook Signatures](python-webhook-verification).\n\n## Normalize an inbound webhook in one call\n\n<Steps>\n\n<Step title=\"Install the SDK and set your API key\">\n\n```bash\npip install primitivedotdev\nexport PRIMITIVE_API_KEY=prim_test\n```\n\nSee [Install and Configure the Python SDK](python-sdk-quickstart) if you haven't set up a client yet.\n\n</Step>\n\n<Step title=\"Call primitive.receive with the raw body, headers, and webhook secret\">\n\n```python\nimport primitive\n\nclient = primitive.client(api_key=\"prim_test\")\n\n\ndef webhook_handler(body: bytes, headers: dict[str, str]) -> dict[str, object]:\n    email = primitive.receive(\n        body=body,\n        headers=headers,\n        secret=\"whsec_...\",\n    )\n\n    client.reply(email, \"Thank you for your email.\")\n    return {\"ok\": True}\n```\n\n`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.\n\n</Step>\n\n<Step title=\"Read the normalized fields off the returned ReceivedEmail\">\n\n```python\nemail.sender.address       # \"alice@example.com\"\nemail.sender.name          # \"Alice\" or None\n\nemail.received_by          # the address that received this email\nemail.received_by_all      # list of every address it was sent to\n\nemail.reply_target.address # who a reply should go to (may differ from sender)\nemail.reply_subject        # \"Re: <original subject>\", idempotently prefixed\nemail.forward_subject      # \"Fwd: <original subject>\", idempotently prefixed\n\nemail.subject\nemail.text\n\nemail.thread.message_id\nemail.thread.references\n\nemail.raw                  # the full validated EmailReceivedEvent, if you need it\n```\n\n</Step>\n\n</Steps>\n\n**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`.\n\n## What `receive()` does under the hood\n\n`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:\n\n```python\nfrom primitive import handle_webhook\nfrom primitive.received_email import normalize_received_email\n\nraw_body: bytes = b\"...\"  # the exact request bytes\nheaders: dict[str, str] = {}  # the request headers\n\nevent = handle_webhook(body=raw_body, headers=headers, secret=\"whsec_...\")\nemail = normalize_received_email(event)\n```\n\nWebhook signature verification itself (the `Primitive-Signature` HMAC scheme, tolerance window, and error codes) is documented on [Verifying Webhook Signatures](python-webhook-verification).\n\n## How each field is derived\n\nEvery `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.\n\nHeader 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.\n\n| Field | Derived from |\n| --- | --- |\n| `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. |\n| `reply_target` | The first `email.parsed.reply_to` entry, when its address passes the same addr-spec check. Otherwise falls back to `sender`. |\n| `received_by` | The first address in `email.smtp.rcpt_to`. |\n| `received_by_all` | The full `email.smtp.rcpt_to` list. |\n| `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. |\n| `forward_subject` | Same idempotent-prefix logic, with `Fwd: `. |\n| `thread.message_id` | `email.headers.message_id`, unmodified. |\n| `thread.in_reply_to` / `thread.references` | `email.parsed.in_reply_to` / `email.parsed.references`, defaulting to empty lists. |\n| `raw` | The full input `EmailReceivedEvent`, untouched, use it whenever you need a field this table doesn't cover. |\n\n<Warning>\n\n`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.\n\n</Warning>\n\n<Tip>\n\n`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](python-sender-trust) to decide whether an inbound email can be trusted before acting on it.\n\n</Tip>\n\n## Address shape: `ReceivedEmailAddress`\n\n`sender` and `reply_target` are both `ReceivedEmailAddress`, a frozen dataclass with a lowercased `address` and an optional display `name`.\n\n```python\n@dataclass(frozen=True)\nclass ReceivedEmailAddress:\n    address: str          # lowercased\n    name: str | None = None\n```\n\n`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.\n\n## Thread shape: `ReceivedEmailThread`\n\n`email.thread` is a `ReceivedEmailThread` dataclass carrying the parent `Message-Id` plus the `In-Reply-To` and `References` header lists.\n\n```python\n@dataclass(frozen=True)\nclass ReceivedEmailThread:\n    message_id: str | None\n    in_reply_to: list[str]\n    references: list[str]\n```\n\nYou 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.\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Replying and Forwarding\" href=\"python-reply-forward\">\n\nPass a ReceivedEmail straight into client.reply and client.forward with correct threading.\n\n</Card>\n\n<Card title=\"Authenticating Senders with SPF, DKIM, and DMARC\" href=\"python-sender-trust\">\n\nDecide whether to trust sender before acting on the normalized email.\n\n</Card>\n\n<Card title=\"Verifying Webhook Signatures\" href=\"python-webhook-verification\">\n\nVerify the Primitive-Signature HMAC or Standard Webhooks header yourself.\n\n</Card>\n\n<Card title=\"Handling Webhook Events\" href=\"python-webhook-events\">\n\nBranch on payment.* and interaction.x402.* events from the same endpoint.\n\n</Card>\n\n</CardGroup>","canonical_base_url":"https://test.abhinandan.one","seo_indexing_enabled":true,"last_modified":"2026-08-21T18:22:43.359885+00:00","video_url":null,"voiceover_url":null,"tools_used":[],"demonstrated_by":[],"steps":[],"related_links":[],"intro":null,"prerequisites":[],"verification":[],"troubleshooting":[],"suggest_edit_url":"https://github.com/abhi-browzer/primitive-sdks/edit/main/sdk-python/src/primitive/received_email.py","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+Receiving+and+Parsing+Inbound+Email&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Fpython-receive-email","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}