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

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 for a delivery status. For the inbound side of the flow (turning a webhook into a ReceivedEmail), see Receiving Inbound Email.

Send a new email#

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

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

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

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

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:

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 for how to handle this.

Forward an inbound email#

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

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; see that page for the full delivery status reference (delivered, bounced, deferred, wait_timeout).

  1. 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. 2

    Set wait and a generous timeout#

    const result = await client.send({
      from: "Support <support@example.com>",
      to: "alice@example.com",
      subject: "Hello",
      bodyText: "Hi there",
      wait: true,
      waitTimeoutMs: 5000,
    });
    
  3. 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. 4

    Read result.deliveryStatus#

    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:

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 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:

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 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:

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 on send to make retries safe. Reusing the same key returns the original response instead of sending a duplicate:

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

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer