{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/email-model","markdown_url":"https://test.abhinandan.one/email-model.md","article":{"id":"56b494fb-ecca-4481-9bbb-9a9889fa52d9","article_slug":"email-model","parent_article_slug":null,"parent_article_title":null,"kind":"concept","published_at":"2026-08-11T18:18:13.131807+00:00","keywords":["ReceivedEmail","wait mode","delivery status","primitive.receive","client.reply","deliveryStatus"],"meta_description":"delivered, bounced, deferred, and wait_timeout are the four terminal delivery statuses wait mode returns across every Primitive SDK.","og_image_url":null,"source_file_paths":["sdk-node/README.md","sdk-python/README.md","sdk-go/README.md","README.md"],"recording_id":null,"replayable":false,"task_name":"Inbound and Outbound Email Model","category":"Core Concepts","summary":null,"description":"The normalized email object and the receive/send/reply/forward flow that every Primitive SDK implements identically, including wait-mode delivery statuses.","content_kind":"repo_page","content_markdown":"Primitive is an inbound and outbound email platform. Every SDK (Node.js, Python, Go) implements the same small model: verify and normalize an inbound webhook into a **ReceivedEmail**, then send, reply to, or forward mail through a client that optionally waits for delivery confirmation. This page defines that model once; each SDK's own docs show its exact syntax.\n\n## The four-step flow\n\nEvery integration follows the same four steps: receive an inbound webhook, normalize it, reply or forward, and send new mail when you're not responding to anything.\n\n```mermaid\nflowchart LR\n    A[Inbound webhook POST] --> B[\"receive() / Receive()\"]\n    B --> C[ReceivedEmail]\n    C --> D[\"client.reply() / client.forward()\"]\n    E[Your code] --> F[\"client.send()\"]\n    D --> G[Primitive delivers via SMTP]\n    F --> G\n```\n\n1. **Receive** an inbound webhook delivery and verify its HMAC signature.\n2. **Normalize** it into a `ReceivedEmail` object with a consistent field shape.\n3. **Reply or forward** using fields the server derives for you (threading, subject, recipients).\n4. **Send** new outbound mail directly when you're not responding to an inbound message.\n\nThis is the same model across languages:\n\n<CardGroup cols={2}>\n\n<Card title=\"Node.js SDK\">\n\n`primitive.receive(...)` → `client.reply(email, ...)`\n\n</Card>\n\n<Card title=\"Python SDK\">\n\n`primitive.receive(...)` → `client.reply(email, ...)`\n\n</Card>\n\n<Card title=\"Go SDK\">\n\n`primitive.Receive(...)` → `client.Reply(ctx, email, ...)`\n\n</Card>\n\n</CardGroup>\n\n## The normalized ReceivedEmail object\n\n**ReceivedEmail** is the SDK-normalized representation of an inbound email. It is what `receive()` / `Receive()` returns after verifying the webhook signature and parsing the raw payload. It keeps the common case clean while preserving the full raw payload for advanced use.\n\nEvery SDK exposes the same fields, just spelled in the language's own casing convention:\n\n| Concept | Node.js | Python | Go |\n|---|---|---|---|\n| Sender address | `email.sender.address` | `email.sender.address` | `email.Sender.Address` |\n| Recipient that received it | `email.receivedBy` | `email.received_by` | `email.ReceivedBy` |\n| Address to reply to | `email.replyTarget.address` | `email.reply_target.address` | `email.ReplyTarget.Address` |\n| Reply subject (`Re: ...`) | `email.replySubject` | `email.reply_subject` | `email.ReplySubject` |\n| Forward subject (`Fwd: ...`) | `email.forwardSubject` | `email.forward_subject` | `email.ForwardSubject` |\n| Subject | `email.subject` | `email.subject` | `email.Subject` |\n| Body text | `email.text` | `email.text` | `email.Text` |\n| Threading message-id | `email.thread.messageId` | `email.thread.message_id` | `email.Thread.MessageID` |\n| Threading references | `email.thread.references` | `email.thread.references` | `email.Thread.References` |\n| Raw webhook payload | `email.raw` | `email.raw` | `email.Raw` |\n\n`email.raw` (Node/Python) or `email.Raw` (Go) is the original, schema-validated **email.received event** (`EmailReceivedEvent`), the raw payload distinct from the normalized object above. Fall back to it when you need a field `ReceivedEmail` doesn't surface, such as SPF/DKIM/DMARC results for [email authenticity checks](node-sdk-email-authenticity).\n\n<Tip>\n\nDon't authorize actions based on `email.replyTarget` / `email.reply_target` / `email.ReplyTarget`, or on the raw SMTP envelope sender. Both are sender-controlled. Use [domain-anchored sender trust](node-sdk-email-authenticity) for anything security-sensitive.\n\n</Tip>\n\n## Receiving inbound mail\n\nCall `receive()` (Node/Python) or `Receive()` (Go) with the raw body, headers, and your webhook secret; it verifies the signature and returns a `ReceivedEmail`.\n\n<Tabs>\n\n<Tab title=\"Node.js\">\n\n```typescript\nimport primitive from \"@primitivedotdev/sdk\";\n\nexport const runtime = \"nodejs\";\nexport const maxDuration = 300;\n\nconst client = primitive.client({\n  apiKey: process.env.PRIMITIVE_API_KEY!,\n});\n\nexport async function POST(req: Request) {\n  const email = await primitive.receive(req, {\n    secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,\n  });\n\n  await client.reply(email, \"Thank you for your email.\");\n\n  return Response.json({ ok: true });\n}\n```\n\n</Tab>\n\n<Tab title=\"Python\">\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</Tab>\n\n<Tab title=\"Go\">\n\n```go\nemail, err := primitive.Receive(primitive.HandleWebhookOptions{\n    Body:    body,\n    Headers: headers,\n    Secret:  \"whsec_...\",\n})\nif err != nil {\n    log.Printf(\"invalid webhook: %v\", err)\n    return\n}\n\nclient, err := primitive.NewClient(\"prim_test\")\nif err != nil {\n    log.Fatal(err)\n}\n\n_, err = client.Reply(ctx, email, primitive.ReplyParams{BodyText: \"Thank you for your email.\"})\n```\n\n</Tab>\n\n</Tabs>\n\n`receive()` / `Receive()` reads the body, verifies the `Primitive-Signature` HMAC header, rejects expired or tampered deliveries, and returns the normalized `ReceivedEmail`. Signature verification mechanics live on [Webhook Events Overview](webhook-events); Node-specific manual verification is on [Webhook Signature Verification](node-sdk-webhook-signing).\n\n## Sending, replying, and forwarding\n\nThree operations cover all outbound mail: `send` for a new message, `reply` to continue a thread from a `ReceivedEmail`, and `forward` to pass an inbound message to a new recipient.\n\nThe differences that matter:\n\n- **`send`**: a brand-new email. You control `from`, `to`, `subject`, and body fully.\n- **`reply`**: continues a thread from a `ReceivedEmail`. Recipients, the `Re:` subject, and threading headers (`In-Reply-To`, `References`) are derived server-side from the inbound row. You cannot override `subject` on reply, Gmail's Conversation View needs both a References match and a normalized-subject match to thread, so a custom subject silently breaks threading for part of the recipient population. Call `send` if you need full subject control.\n- **`forward`**: sends the inbound message content to a new recipient. Forward has no wait option; it always returns as soon as Primitive accepts the message.\n\n<Tabs>\n\n<Tab title=\"Node.js\">\n\n```typescript\nconst result = await client.send({\n  from: \"Support <support@example.com>\",\n  to: \"alice@example.com\",\n  subject: \"Hello\",\n  bodyText: \"Hi there\",\n  wait: true,\n  waitTimeoutMs: 5000,\n});\n\nconsole.log(result.id, result.status, result.queueId, result.deliveryStatus);\n\nawait client.reply(email, {\n  text: \"Thanks for your email.\",\n  from: \"notifications@outbound.example.com\",\n});\n\nawait client.forward(email, {\n  to: \"ops@example.com\",\n  bodyText: \"Can you take this one?\",\n});\n```\n\n</Tab>\n\n<Tab title=\"Python\">\n\n```python\nresult = client.send(\n    from_email=\"Support <support@example.com>\",\n    to=\"alice@example.com\",\n    subject=\"Hello\",\n    body_text=\"Hi there\",\n    idempotency_key=\"customer-key-abc123\",\n    wait=True,\n    wait_timeout_ms=5000,\n)\n\nprint(result.id, result.status, result.queue_id, result.delivery_status)\n\nclient.reply(\n    email,\n    \"Thanks for your email.\",\n    from_email=\"notifications@outbound.example.com\",\n)\n\nclient.forward(\n    email,\n    to=\"ops@example.com\",\n    body_text=\"Can you take this one?\",\n)\n```\n\n</Tab>\n\n<Tab title=\"Go\">\n\n```go\nwait := true\nresult, err := client.Send(ctx, primitive.SendParams{\n    From:           \"Support <support@example.com>\",\n    To:             \"alice@example.com\",\n    Subject:        \"Hello\",\n    BodyText:       \"Hi there\",\n    IdempotencyKey: \"customer-key-abc123\",\n    Wait:           &wait,\n    WaitTimeoutMs:  5000,\n})\n\n_, err = client.Reply(ctx, email, primitive.ReplyParams{\n    BodyText: \"Thanks for your email.\",\n    From:     \"notifications@outbound.example.com\",\n})\n\n_, err = client.Forward(ctx, email, primitive.ForwardParams{\n    To:       \"ops@example.com\",\n    BodyText: \"Can you take this one?\",\n})\n```\n\n</Tab>\n\n</Tabs>\n\nEvery reply defaults the From address to the inbound recipient (the address that received the email). Pass `from` (Node/Go) or `from_email` (Python) explicitly when your verified outbound domain differs from your inbound domain.\n\nIf the inbound row isn't in a state that can be replied to (rejected at ingestion, content discarded, or no recipient recorded), the API returns `inbound_not_repliable` (HTTP 422) and the SDK raises or returns an error. A missing `Message-Id` doesn't block the reply; it only omits the threading headers.\n\nFor full attachment handling (inline vs. Primitive Payloads by reference), see [Sending, Replying, and Forwarding Email](node-sdk-sending-email).\n\n## Wait mode and delivery status\n\n**Wait mode** is the behavior triggered by passing `wait: true` (Node/Python) or `Wait` pointer `true` (Go) to `send`/`reply`. By default, `send`, `reply`, and `forward` return as soon as Primitive **accepts** the message for delivery, fast, but you don't yet know whether the receiving mail server actually took it.\n\nSet wait mode when you need to know the outcome before responding:\n\n| Language | Flag | Timeout field | Default timeout |\n|---|---|---|---|\n| Node.js | `wait: true` | `waitTimeoutMs` | 30000 |\n| Python | `wait=True` | `wait_timeout_ms` | 30000 |\n| Go | `Wait: &wait` | `WaitTimeoutMs` | 30000 |\n\nIn wait mode, the call holds the HTTP response open until the first downstream SMTP delivery outcome, or until the timeout elapses. The wait timeout must be between 1000 and 30000 milliseconds; the SDKs reject anything outside that range before sending the request. Configure your runtime or HTTP client timeout to be longer than the wait timeout: the SDK READMEs recommend a request timeout \"long enough for SMTP delivery, typically 30 to 60 seconds,\" or your own request times out before Primitive's response arrives.\n\n### Delivery status values\n\n**Delivery status** is the terminal outcome reported when wait mode is used. There are exactly four values, identical across every SDK:\n\n| Status | Meaning |\n|---|---|\n| `delivered` | Accepted by the receiving MTA. |\n| `bounced` | Rejected by the receiving MTA (the HTTP response is still `200 OK`). |\n| `deferred` | Temporary failure (receiver returned 4xx); Primitive retries the delivery later. |\n| `wait_timeout` | No outcome was observed before the timeout. Treat this as \"outcome unknown\", the send may still complete after your response returns. |\n\nRead it off the result object:\n\n<Tabs>\n\n<Tab title=\"Node.js\">\n\n```typescript\nconsole.log(result.deliveryStatus); // \"delivered\" | \"bounced\" | \"deferred\" | \"wait_timeout\" | undefined\n```\n\n</Tab>\n\n<Tab title=\"Python\">\n\n```python\nprint(result.delivery_status)  # \"delivered\" | \"bounced\" | \"deferred\" | \"wait_timeout\" | None\n```\n\n</Tab>\n\n<Tab title=\"Go\">\n\n```go\nresult.DeliveryStatus // primitiveapi.OptDeliveryStatus\n```\n\n</Tab>\n\n</Tabs>\n\n<Note>\n\n`wait_timeout` is not a failure, it means the SMTP transaction hadn't resolved before your wait window closed. Don't retry the send on `wait_timeout`; the original attempt may still complete.\n\n</Note>\n\n## Idempotent retries\n\nPass an idempotency key on `send` (and on Go's `Forward`, which calls `Send` internally) to make retries safe: reusing the same key returns the original response from the first send instead of creating a duplicate. See [Request Options and Idempotency](node-sdk-request-options) for the Node.js per-call mechanics.\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Sending, Replying, and Forwarding Email\" href=\"node-sdk-sending-email\">\n\nFull attachment handling, threading control, and per-call request options in the Node.js SDK.\n\n</Card>\n\n<Card title=\"Receiving Inbound Email\" href=\"node-sdk-receiving-email\">\n\nEvery field on the ReceivedEmail shape, explained in depth for Node.js.\n\n</Card>\n\n<Card title=\"Webhook Events Overview\" href=\"webhook-events\">\n\nThe signature verification contract and event catalog shared by every SDK.\n\n</Card>\n\n<Card title=\"Verifying Inbound Email Authenticity\" href=\"node-sdk-email-authenticity\">\n\nAnchor trust decisions to a sending domain using SPF/DKIM/DMARC results.\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-node/README.md","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+Inbound+and+Outbound+Email+Model&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Femail-model","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}