Documentation Index: Fetch llms.txt first to discover every published page. This page is also available as Markdown at /python-receive-email.md.
Verified · 8/11/2026

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. If you need to verify the webhook signature yourself instead of letting receive() do it, see Verifying Webhook Signatures.

Normalize an inbound webhook in one call#

  1. 1

    Install the SDK and set your API key#

    pip install primitivedotdev
    export PRIMITIVE_API_KEY=prim_test
    

    See Install and Configure the Python SDK if you haven't set up a client yet.

  2. 2

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

    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. 3

    Read the normalized fields off the returned ReceivedEmail#

    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:

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.

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.

FieldDerived from
senderStrict-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_targetThe first email.parsed.reply_to entry, when its address passes the same addr-spec check. Otherwise falls back to sender.
received_byThe first address in email.smtp.rcpt_to.
received_by_allThe full email.smtp.rcpt_to list.
reply_subjectsubject 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_subjectSame idempotent-prefix logic, with Fwd: .
thread.message_idemail.headers.message_id, unmodified.
thread.in_reply_to / thread.referencesemail.parsed.in_reply_to / email.parsed.references, defaulting to empty lists.
rawThe 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 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.

@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.

@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.

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer