---
title: "Inbound and Outbound Email Model"
canonical: "https://test.abhinandan.one/email-model"
markdown_url: "https://test.abhinandan.one/email-model.md"
publisher: "Primitive SDKs"
kind: "concept"
content_type: "reference"
category: "Core Concepts"
description: "delivered, bounced, deferred, and wait_timeout are the four terminal delivery statuses wait mode returns across every Primitive SDK."
keywords: ["ReceivedEmail", "wait mode", "delivery status", "primitive.receive", "client.reply", "deliveryStatus"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:18:13.131807+00:00"
source_files:
  - "sdk-node/README.md"
  - "sdk-python/README.md"
  - "sdk-go/README.md"
  - "README.md"
sections:
  - {anchor: "the-four-step-flow", title: "The four-step flow"}
  - {anchor: "the-normalized-receivedemail-object", title: "The normalized ReceivedEmail object"}
  - {anchor: "receiving-inbound-mail", title: "Receiving inbound mail"}
  - {anchor: "sending-replying-and-forwarding", title: "Sending, replying, and forwarding"}
  - {anchor: "wait-mode-and-delivery-status", title: "Wait mode and delivery status"}
  - {anchor: "delivery-status-values", title: "Delivery status values"}
  - {anchor: "idempotent-retries", title: "Idempotent retries"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Inbound and Outbound Email Model

The normalized email object and the receive/send/reply/forward flow that every Primitive SDK implements identically, including wait-mode delivery statuses.

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.

## The four-step flow

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

```mermaid
flowchart LR
    A[Inbound webhook POST] --> B["receive() / Receive()"]
    B --> C[ReceivedEmail]
    C --> D["client.reply() / client.forward()"]
    E[Your code] --> F["client.send()"]
    D --> G[Primitive delivers via SMTP]
    F --> G
```

1. **Receive** an inbound webhook delivery and verify its HMAC signature.
2. **Normalize** it into a `ReceivedEmail` object with a consistent field shape.
3. **Reply or forward** using fields the server derives for you (threading, subject, recipients).
4. **Send** new outbound mail directly when you're not responding to an inbound message.

This is the same model across languages:

- **Node.js SDK**: `primitive.receive(...)` → `client.reply(email, ...)`

- **Python SDK**: `primitive.receive(...)` → `client.reply(email, ...)`

- **Go SDK**: `primitive.Receive(...)` → `client.Reply(ctx, email, ...)`

## The normalized ReceivedEmail object

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

Every SDK exposes the same fields, just spelled in the language's own casing convention:

| Concept | Node.js | Python | Go |
|---|---|---|---|
| Sender address | `email.sender.address` | `email.sender.address` | `email.Sender.Address` |
| Recipient that received it | `email.receivedBy` | `email.received_by` | `email.ReceivedBy` |
| Address to reply to | `email.replyTarget.address` | `email.reply_target.address` | `email.ReplyTarget.Address` |
| Reply subject (`Re: ...`) | `email.replySubject` | `email.reply_subject` | `email.ReplySubject` |
| Forward subject (`Fwd: ...`) | `email.forwardSubject` | `email.forward_subject` | `email.ForwardSubject` |
| Subject | `email.subject` | `email.subject` | `email.Subject` |
| Body text | `email.text` | `email.text` | `email.Text` |
| Threading message-id | `email.thread.messageId` | `email.thread.message_id` | `email.Thread.MessageID` |
| Threading references | `email.thread.references` | `email.thread.references` | `email.Thread.References` |
| Raw webhook payload | `email.raw` | `email.raw` | `email.Raw` |

`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](https://test.abhinandan.one/node-sdk-email-authenticity.md).

> **Tip:** Don'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](https://test.abhinandan.one/node-sdk-email-authenticity.md) for anything security-sensitive.

## Receiving inbound mail

Call `receive()` (Node/Python) or `Receive()` (Go) with the raw body, headers, and your webhook secret; it verifies the signature and returns a `ReceivedEmail`.

**Choose one of the following:**

**Node.js**

```typescript
import primitive from "@primitivedotdev/sdk";

export const runtime = "nodejs";
export const maxDuration = 300;

const client = primitive.client({
  apiKey: process.env.PRIMITIVE_API_KEY!,
});

export async function POST(req: Request) {
  const email = await primitive.receive(req, {
    secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
  });

  await client.reply(email, "Thank you for your email.");

  return Response.json({ ok: true });
}
```

**Python**

```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}
```

**Go**

```go
email, err := primitive.Receive(primitive.HandleWebhookOptions{
    Body:    body,
    Headers: headers,
    Secret:  "whsec_...",
})
if err != nil {
    log.Printf("invalid webhook: %v", err)
    return
}

client, err := primitive.NewClient("prim_test")
if err != nil {
    log.Fatal(err)
}

_, err = client.Reply(ctx, email, primitive.ReplyParams{BodyText: "Thank you for your email."})
```

`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](https://test.abhinandan.one/webhook-events.md); Node-specific manual verification is on [Webhook Signature Verification](https://test.abhinandan.one/node-sdk-webhook-signing.md).

## Sending, replying, and forwarding

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

The differences that matter:

- **`send`**: a brand-new email. You control `from`, `to`, `subject`, and body fully.
- **`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.
- **`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.

**Choose one of the following:**

**Node.js**

```typescript
const result = await client.send({
  from: "Support <support@example.com>",
  to: "alice@example.com",
  subject: "Hello",
  bodyText: "Hi there",
  wait: true,
  waitTimeoutMs: 5000,
});

console.log(result.id, result.status, result.queueId, result.deliveryStatus);

await client.reply(email, {
  text: "Thanks for your email.",
  from: "notifications@outbound.example.com",
});

await client.forward(email, {
  to: "ops@example.com",
  bodyText: "Can you take this one?",
});
```

**Python**

```python
result = client.send(
    from_email="Support <support@example.com>",
    to="alice@example.com",
    subject="Hello",
    body_text="Hi there",
    idempotency_key="customer-key-abc123",
    wait=True,
    wait_timeout_ms=5000,
)

print(result.id, result.status, result.queue_id, result.delivery_status)

client.reply(
    email,
    "Thanks for your email.",
    from_email="notifications@outbound.example.com",
)

client.forward(
    email,
    to="ops@example.com",
    body_text="Can you take this one?",
)
```

**Go**

```go
wait := true
result, err := client.Send(ctx, primitive.SendParams{
    From:           "Support <support@example.com>",
    To:             "alice@example.com",
    Subject:        "Hello",
    BodyText:       "Hi there",
    IdempotencyKey: "customer-key-abc123",
    Wait:           &wait,
    WaitTimeoutMs:  5000,
})

_, err = client.Reply(ctx, email, primitive.ReplyParams{
    BodyText: "Thanks for your email.",
    From:     "notifications@outbound.example.com",
})

_, err = client.Forward(ctx, email, primitive.ForwardParams{
    To:       "ops@example.com",
    BodyText: "Can you take this one?",
})
```

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

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

For full attachment handling (inline vs. Primitive Payloads by reference), see [Sending, Replying, and Forwarding Email](https://test.abhinandan.one/node-sdk-sending-email.md).

## Wait mode and delivery status

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

Set wait mode when you need to know the outcome before responding:

| Language | Flag | Timeout field | Default timeout |
|---|---|---|---|
| Node.js | `wait: true` | `waitTimeoutMs` | 30000 |
| Python | `wait=True` | `wait_timeout_ms` | 30000 |
| Go | `Wait: &wait` | `WaitTimeoutMs` | 30000 |

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

### Delivery status values

**Delivery status** is the terminal outcome reported when wait mode is used. There are exactly four values, identical across every SDK:

| Status | Meaning |
|---|---|
| `delivered` | Accepted by the receiving MTA. |
| `bounced` | Rejected by the receiving MTA (the HTTP response is still `200 OK`). |
| `deferred` | Temporary failure (receiver returned 4xx); Primitive retries the delivery later. |
| `wait_timeout` | No outcome was observed before the timeout. Treat this as "outcome unknown", the send may still complete after your response returns. |

Read it off the result object:

**Choose one of the following:**

**Node.js**

```typescript
console.log(result.deliveryStatus); // "delivered" | "bounced" | "deferred" | "wait_timeout" | undefined
```

**Python**

```python
print(result.delivery_status)  # "delivered" | "bounced" | "deferred" | "wait_timeout" | None
```

**Go**

```go
result.DeliveryStatus // primitiveapi.OptDeliveryStatus
```

> **Note:** `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.

## Idempotent retries

Pass 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](https://test.abhinandan.one/node-sdk-request-options.md) for the Node.js per-call mechanics.
