---
title: "Sending, Replying, and Forwarding Email"
canonical: "https://test.abhinandan.one/node-sdk-sending-email"
markdown_url: "https://test.abhinandan.one/node-sdk-sending-email.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Node.js SDK"
description: "client.send, client.reply, and client.forward deliver outbound mail from @primitivedotdev/sdk with wait-mode delivery status and attachment control."
keywords: ["client.send", "client.reply", "client.forward", "wait mode", "SendAttachment", "idempotencyKey"]
last_modified: "2026-08-11T18:55:05.717545+00:00"
published_at: "2026-08-11T18:55:05.568764+00:00"
source_files:
  - "sdk-node/README.md"
  - "sdk-node/src/api/index.ts"
sections:
  - {anchor: "send-a-new-email", title: "Send a new email"}
  - {anchor: "reply-to-an-inbound-email", title: "Reply to an inbound email"}
  - {anchor: "forward-an-inbound-email", title: "Forward an inbound email"}
  - {anchor: "control-delivery-confirmation-with-wait-mode", title: "Control delivery confirmation with wait mode"}
  - {anchor: "step-decide-whether-you-need-a-delivery-outcome", title: "Decide whether you need a delivery outcome"}
  - {anchor: "step-set-wait-and-a-generous-timeout", title: "Set wait and a generous timeout"}
  - {anchor: "step-configure-your-runtimes-own-request-timeout", title: "Configure your runtime's own request timeout"}
  - {anchor: "step-read-resultdeliverystatus", title: "Read result.deliveryStatus"}
  - {anchor: "attach-files", title: "Attach files"}
  - {anchor: "thread-a-new-send-under-an-existing-conversation", title: "Thread a new send under an existing conversation"}
  - {anchor: "retry-safely-with-idempotency-keys", title: "Retry safely with idempotency keys"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Sending, Replying, and Forwarding Email

Use client.send, client.reply, and client.forward to deliver outbound mail from the Node.js SDK, with control over threading, wait-mode delivery status, and inline or referenced attachments.

Use `client.send`, `client.reply`, and `client.forward` to deliver outbound mail from `@primitivedotdev/sdk`. Reach for this page when you already have a `client` from `primitive.client({ apiKey })` and need to send a new message, answer an inbound one, or hand it off to someone else.

All three methods return as soon as Primitive accepts the message for delivery, unless you opt into [wait mode](https://test.abhinandan.one/email-model.md) for a delivery status. For the inbound side of the flow (turning a webhook into a [ReceivedEmail](https://test.abhinandan.one/node-sdk-receiving-email.md)), see [Receiving Inbound Email](https://test.abhinandan.one/node-sdk-receiving-email.md).

## Send a new email

`client.send` delivers a brand-new message. It requires `from`, `to`, `subject`, and at least one of `bodyText` or `bodyHtml`.

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

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

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);
```

`send` validates inputs before making a request:

- `from` and `to` must be non-empty (`from` up to 998 chars, `to` up to 320 chars) and `to` must parse as a valid address or `Name <addr>` form.
- `subject` must be non-empty.
- One of `bodyText` or `bodyHtml` is required.
- `waitTimeoutMs`, when set, must be an integer between 1000 and 30000.

## Reply to an inbound email

`client.reply` answers a [ReceivedEmail](https://test.abhinandan.one/node-sdk-receiving-email.md) you already normalized with `primitive.receive(...)`. Recipients, the `Re:` subject, and threading headers (`In-Reply-To`, `References`) are all derived server-side from the inbound row, you never set them.

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

The bare-string form is shorthand for `{ text: "..." }`. Pass an object for more control:

```ts
await client.reply(email, {
  text: "Thanks for your email.",
  html: "<p>Thanks for your email.</p>",
  attachments: [
    {
      filename: "report.txt",
      content_base64: Buffer.from("hello").toString("base64"),
    },
  ],
  wait: true,
});
```

> **Tip:** `reply()` defaults the From address to the inbound recipient (the address that received the email). Pass `from` explicitly when your verified outbound domain differs from your inbound domain:
>
> ```ts
> await client.reply(email, {
>   text: "Thanks for your email.",
>   from: "notifications@outbound.example.com",
> });
> ```

> **Warning:** `reply()` does not accept a `subject` field. Gmail's Conversation View needs both a `References` match and a normalized-subject match to thread correctly, so a custom subject silently breaks threading for a chunk of recipients. Use `client.send(...)` if you need full subject control.

If the inbound row isn't in a state Primitive can reply to (it was rejected at ingestion, its content was discarded, or it has no recipient recorded), the API returns `inbound_not_repliable` (HTTP 422) and the SDK throws. Check [Node.js SDK Errors](https://test.abhinandan.one/node-sdk-errors.md) for how to handle this.

## Forward an inbound email

`client.forward` re-sends an inbound message to a new recipient.

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

`forward` has no `wait` option: it always returns as soon as Primitive accepts the message.

## Control delivery confirmation with wait mode

Pass `wait: true` on `send` or `reply` to hold the HTTP response open until the first downstream SMTP delivery outcome, or until `waitTimeoutMs` elapses (default 30000 ms, and it must be an integer between 1000 and 30000 when you set it). By default all three methods return the moment Primitive accepts the message, with no signal about what happened downstream. This is [wait mode](https://test.abhinandan.one/email-model.md); see that page for the full [delivery status](https://test.abhinandan.one/email-model.md) reference (`delivered`, `bounced`, `deferred`, `wait_timeout`).

### 1. Decide whether you need a delivery outcome

Use the default (no `wait`) for fast response times when your caller doesn't need SMTP confirmation. Use `wait: true` only when the caller must know whether the message actually reached the recipient's MTA before you respond.

### 2. Set wait and a generous timeout

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

### 3. Configure your runtime's own request timeout

When you use `wait: true`, configure your transport or serverless runtime with a request timeout long enough for SMTP delivery, or your own infrastructure will cut the connection before Primitive responds.

### 4. Read result.deliveryStatus

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

`wait_timeout` means no outcome was observed in time, treat it as "outcome unknown," not "failed." The send may still complete after the response returns.

## Attach files

Pass `attachments` with base64 `content_base64` for anything at or below the inline size cap; the SDK's default inline threshold is 25 MiB, the server's inline/offload threshold:

```ts
await client.send({
  from: "Support <support@example.com>",
  to: "alice@example.com",
  subject: "Report",
  bodyText: "See attached.",
  attachments: [
    {
      filename: "report.txt",
      content_type: "text/plain",
      content_base64: Buffer.from("hello").toString("base64"),
    },
  ],
});
```

For larger files, upload the object with [Primitive Payloads](https://test.abhinandan.one/node-sdk-payloads.md) first, then deliver it by reference with `payloadAttachments` instead of inlining the bytes. Each entry takes the finalized object's 64-char lowercase-hex Merkle `root` (`PushResult.merkleRoot`), a `filename`, an optional `contentType`, and the hex-encoded `cek` (`PushResult.cek`) the recipient needs to decrypt:

```ts
await client.send({
  from: "Support <support@example.com>",
  to: "alice@example.com",
  subject: "Archive",
  bodyText: "See the attached archive.",
  payloadAttachments: [
    {
      root: pushed.merkleRoot,
      filename: "large-file.zip",
      cek: pushed.cek,
    },
  ],
});
```

> **Tip:** If you don't want to choose inline vs. reference yourself, use `client.sendAttachment(...)` (the `SendAttachmentInput` shape) with a single `attachment.content` or `attachment.path`. It picks inline or upload-and-reference for you based on size, defaulting the threshold to 25 MiB (`inlineThreshold`).

At most one `payloadAttachments` entry is supported per send in v1; there's no upper size ceiling on the referenced object beyond what [Primitive Payloads](https://test.abhinandan.one/node-sdk-payloads.md) supports.

## Thread a new send under an existing conversation

`send` (not `reply`) accepts explicit threading headers when you need to continue a thread outside the reply-to-inbound flow:

```ts
await client.send({
  from: "Support <support@example.com>",
  to: "alice@example.com",
  subject: "Re: Hello",
  bodyText: "Following up on this.",
  thread: {
    inReplyTo: "<parent-message-id@example.com>",
    references: ["<root@example.com>", "<parent-message-id@example.com>"],
  },
});
```

`thread.references` accepts at most 100 values, and the joined header must stay under 8 KiB.

## Retry safely with idempotency keys

Pass `idempotencyKey` as a per-call [request option](https://test.abhinandan.one/node-sdk-request-options.md) on `send` to make retries safe. Reusing the same key returns the original response instead of sending a duplicate:

```ts
await client.send(
  { from: "support@example.com", to: "alice@example.com", subject: "Hello", bodyText: "Hi there" },
  { idempotencyKey: "customer-key-abc123" },
);
```

Check `result.idempotentReplay` to tell whether a response was replayed from a prior send (`true`) or is fresh (`false`).
