---
title: "Sending Email"
canonical: "https://test.abhinandan.one/python-send-email"
markdown_url: "https://test.abhinandan.one/python-send-email.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Python SDK"
description: "client.send() in the Primitive Python SDK sends outbound mail, returning as soon as Primitive accepts the message unless you pass wait=True."
keywords: ["client.send python", "primitive.client(api_key)", "idempotency_key python", "wait_timeout_ms", "delivery_status", "SendResult"]
last_modified: "2026-08-11T18:55:00.67136+00:00"
published_at: "2026-08-11T18:55:00.50459+00:00"
source_files:
  - "sdk-python/src/primitive/client.py"
  - "sdk-python/README.md"
  - "sdk-python/tests/test_client.py"
sections:
  - {anchor: "send-a-message", title: "Send a message"}
  - {anchor: "step-create-a-client", title: "Create a client"}
  - {anchor: "step-call-clientsend-with-required-fields", title: "Call client.send with required fields"}
  - {anchor: "step-read-the-result", title: "Read the result"}
  - {anchor: "validation-before-the-request-fires", title: "Validation before the request fires"}
  - {anchor: "idempotent-retries", title: "Idempotent retries"}
  - {anchor: "wait-mode-and-delivery-status", title: "Wait mode and delivery status"}
  - {anchor: "threading-a-send-manually", title: "Threading a send manually"}
  - {anchor: "error-handling", title: "Error handling"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Sending Email

Send outbound email synchronously with client.send, control idempotent retries and wait-mode delivery confirmation, and read the resulting SendResult.

Use `client.send` to send a new outbound email from the Python SDK (`primitivedotdev` on PyPI, imported as `primitive`). Reach for it whenever you're composing a fresh message rather than continuing a thread. For replies and forwards, see [Replying and Forwarding](https://test.abhinandan.one/python-reply-forward.md).

By default `send` returns as soon as Primitive accepts the message for delivery. Pass `wait=True` when you need to know the first downstream SMTP outcome before your code moves on.

## Send a message

Call `client.send` with `from_email`, `to`, `subject`, and one of `body_text` or `body_html`; it returns a `SendResult` once Primitive accepts the message.

### 1. Create a client

```python
import primitive

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

`primitive.client(...)` builds a `PrimitiveClient`. Configuring the API key, timeouts, and dual-host base URLs is covered in [Install and Configure the Python SDK](https://test.abhinandan.one/python-sdk-quickstart.md) and [Client and Request Options](https://test.abhinandan.one/python-client-options.md).

### 2. Call client.send with required fields

```python
result = client.send(
    from_email="Support <support@example.com>",
    to="alice@example.com",
    subject="Hello",
    body_text="Hi there",
)

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

`from_email`, `to`, and `subject` are required. You must supply at least one of `body_text` or `body_html`; passing neither raises `ValueError("one of body_text or body_html is required")` before any request is sent.

### 3. Read the result

`client.send` returns a `SendResult` dataclass:

| Field | Type | Meaning |
|---|---|---|
| `id` | `str` | The sent-email id |
| `status` | `str` | Server-side send status (e.g. `submitted_to_agent`) |
| `accepted` | `list[str]` | Recipients accepted for delivery |
| `rejected` | `list[str]` | Recipients rejected |
| `client_idempotency_key` | `str` | The idempotency key recorded for this send |
| `request_id` | `str` | Server request id, useful for support |
| `content_hash` | `str` | Hash of the canonical send payload |
| `queue_id` | `str \| None` | Queue id for the outbound message, if any |
| `idempotent_replay` | `bool` | `True` when this response replays an earlier send with the same idempotency key |
| `delivery_status` | `str \| None` | Set only in [wait mode](#wait-mode-and-delivery-status) |
| `smtp_response_code` | `int \| None` | Set only in wait mode |
| `smtp_response_text` | `str \| None` | Set only in wait mode |

## Validation before the request fires

`send` validates its arguments locally before making a network call, so malformed input raises `ValueError` immediately instead of burning a request:

- `from_email` and `to` must be non-empty header-safe strings (3–998 chars for `from`, 3–320 for `to`).
- `to` must be a valid email address or a `"Display Name <addr@example.com>"` form. An invalid address raises `ValueError("to must be a valid email address")`.
- `subject` must be non-empty.
- One of `body_text` / `body_html` is required.
- `wait_timeout_ms`, if given, must be between `1000` and `30000`.

## Idempotent retries

Pass `idempotency_key` to `send` to make retries safe; it is sent as the `Idempotency-Key` request header. Reusing the same key returns the original response instead of sending a duplicate message:

```python
client.send(
    from_email="support@example.com",
    to="alice@example.com",
    subject="Hello",
    body_text="Hi there",
    idempotency_key="customer-key-abc123",
)
```

Use one key per logical send, for example derived from your own outbox row id, not a value that changes on every retry. Check `result.idempotent_replay` to tell a fresh send from a replayed one.

> **Tip:** For per-call `timeout` and `extra_headers` in addition to `idempotency_key`, see [Client and Request Options](https://test.abhinandan.one/python-client-options.md). `with_options(...)` lets you set client-wide defaults that these per-call kwargs still override.

## Wait mode and delivery status

Pass `wait=True` to hold the HTTP response open until the first downstream SMTP delivery outcome, or until `wait_timeout_ms` (default 30000 ms) elapses. The wait-mode delivery model (statuses `delivered`, `bounced`, `deferred`, `wait_timeout`) is identical across every SDK; see [Inbound and Outbound Email Model](https://test.abhinandan.one/email-model.md) for the full contract. In the Python SDK, opt in with `wait=True`:

```python
result = client.send(
    from_email="support@example.com",
    to="alice@example.com",
    subject="Hello",
    body_text="Hi there",
    wait=True,
    wait_timeout_ms=5000,
)

print(result.delivery_status)       # "delivered", "bounced", "deferred", or "wait_timeout"
print(result.smtp_response_code)    # e.g. 250
print(result.smtp_response_text)    # e.g. "250 OK"
```

`wait_timeout_ms` must be between `1000` and `30000` and defaults to `30000` when omitted. When you set `wait=True`, configure the client with a request timeout long enough for SMTP delivery, typically 30-60 seconds:

```python
client = primitive.client(api_key="prim_test", timeout=60.0)
```

> **Warning:** `wait_timeout` means "no outcome observed in time," not "the send failed." The message may still be delivered after your call returns. Do not treat `wait_timeout` as a permanent failure.

## Threading a send manually

`send` accepts an optional `thread` argument (a `SendThread` with `in_reply_to` and `references`) when you need to place a new message into an existing thread yourself. Most reply flows should use `client.reply` instead; see [Replying and Forwarding](https://test.abhinandan.one/python-reply-forward.md).

```python
from primitive.client import SendThread

client.send(
    from_email="support@example.com",
    to="alice@example.com",
    subject="Re: Hello",
    body_text="Following up",
    thread=SendThread(
        in_reply_to="<parent@example.com>",
        references=["<root@example.com>", "<parent@example.com>"],
    ),
)
```

## Error handling

A non-2xx response raises `primitive.client.PrimitiveAPIError`, the SDK's API-error exception, which carries `status_code`, `code`, `gates`, `request_id`, `retry_after`, and `details`:

```python
from primitive.client import PrimitiveAPIError

try:
    client.send(
        from_email="support@example.com",
        to="alice@example.com",
        subject="Hello",
        body_text="Hi there",
    )
except PrimitiveAPIError as err:
    print(err.status_code, err.code, err.request_id)
```

For the full error catalog and what triggers each code, see [Python SDK Error Reference](https://test.abhinandan.one/python-errors-reference.md).
