Node.js Email SDK: Send and Receive in TypeScript

9 min read

Use the Robotomail Node.js email SDK in TypeScript: list mailboxes, set a From name, send, read replies, keep threads, and keep API keys server-side.

John Joubert

John Joubert

Founder, Robotomail

Node.js Email SDK: Send and Receive in TypeScript
Table of contents

The Robotomail Node.js email SDK gives a TypeScript agent its own mailbox: list the mailbox created at signup, send a message with a friendly From name, receive the reply, and reply back on the same thread. It runs server-side only, reads ROBOTOMAIL_API_KEY from the environment, and ships both ESM and CommonJS builds. This guide walks through the full loop and the mistakes that cost people an afternoon.

If you would rather call the HTTP API directly, we already covered that in sending email from Python and Node and in sending email from an AI agent. This post is about the typed SDK instead.

Requirements and install

You need Node.js 20.3 or newer. The SDK uses the platform fetch and AbortSignal behaviour that stabilised in that line, and we test against it.

Install from the tested v0.1.0 tag of the public robotomail/robotomail-node repository (MIT licensed):

npm install github:robotomail/robotomail-node#v0.1.0

The install specifier is a Git tag, but the package name in package.json is @robotomail/sdk, so that is what you import. Pinning the tag rather than a branch means your lockfile resolves to a commit we have actually run the test suite against.

Set the key in your environment, never in source:

export ROBOTOMAIL_API_KEY=rm_your_api_key

Two kinds of keys exist. Mailbox-scoped keys can send and read within one mailbox. Account-level operations and attachment uploads need a full-access key. The differences are spelled out in the authentication docs; if a call returns a permission error later in this guide, that is usually why.

First request: list the signup mailbox

Every account starts with one mailbox, so the fastest way to confirm your key and runtime is to list mailboxes and print their addresses.

import { Robotomail } from "@robotomail/sdk";

const mail = new Robotomail(); // reads ROBOTOMAIL_API_KEY

const { mailboxes } = await mail.listMailboxes();
console.log(mailboxes.map(box => box.fullAddress));

Output looks like:

[ 'shopping-agent@robotomail.co' ]

The constructor takes no arguments in the common case. It reads the key from process.env.ROBOTOMAIL_API_KEY and targets https://api.robotomail.com/v1. If you run multiple accounts in one process, pass the key explicitly instead of mutating the environment.

Hold on to the mailbox id from that response. Send, list and reply calls are all mailbox-scoped, and the id is what you pass to them.

ESM and CommonJS

The package exports both module formats, so the import style follows your project, not ours.

ESM, in a package with "type": "module" or in a .mts file:

import { Robotomail } from "@robotomail/sdk";

CommonJS, in a .cjs file or a package without "type": "module":

const { Robotomail } = require("@robotomail/sdk");

async function main() {
  const mail = new Robotomail();
  const { mailboxes } = await mail.listMailboxes();
  console.log(mailboxes.map(box => box.fullAddress));
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

Two practical notes:

  • Top-level await is ESM only. In CommonJS wrap the work in an async function as above, or you will get a syntax error that looks like an SDK problem and is not.
  • For TypeScript, use "module": "nodenext" and "moduleResolution": "nodenext" in tsconfig.json. Older "moduleResolution": "node" settings resolve the package's type declarations inconsistently and you lose the typed request bodies, which is most of the reason to use an SDK.

Set the From name and send to your verified owner address

A send needs to, subject and bodyText. Add displayName to control the friendly From name that recipients see, so mail arrives as Shopping Agent <shopping-agent@robotomail.co> instead of a bare address.

import { Robotomail } from "@robotomail/sdk";

const mail = new Robotomail();

const { mailboxes } = await mail.listMailboxes();
const mailbox = mailboxes[0];

const sent = await mail.sendMessage(mailbox.id, {
  to: ["you@yourcompany.com"], // your verified owner address
  displayName: "Shopping Agent",
  subject: "Order #A-4521 delivery update?",
  bodyText: "Hi, checking on the status of order A-4521."
});

console.log(sent.id);

Send to your own verified owner address for this first test. It is the one recipient you know is deliverable before you have warmed anything up or attached a custom domain, and it lets you read the result in a client you already have open. Keep displayName in config rather than hardcoding it per call site, since it is part of your agent's identity and you will want to change it in one place. Exact method signatures and the full option list live in the TypeScript SDK reference.

bodyText is required. Send plain text first and add HTML once the loop works; plain text removes a whole class of rendering questions while you are debugging delivery.

Receive the reply

Reply now to that email from your own client. Two ways to pick it up.

Register a webhook once, and inbound mail arrives as JSON with an event, a timestamp and a snake_case data object:

{
  "event": "message.received",
  "data": {
    "message_id": "...",
    "mailbox_id": "...",
    "mailbox_address": "shopping-agent@robotomail.co",
    "from": "you@yourcompany.com",
    "subject": "Re: Order #A-4521 delivery update?",
    "body_text": "Shipped Tuesday.",
    "thread_id": "...",
    "received_at": "2026-04-17T10:00:00.000Z"
  }
}

A minimal Express handler:

import express from "express";

const app = express();
app.use(express.json());

app.post("/robotomail/inbound", (req, res) => {
  const { event, data } = req.body;
  if (event === "message.received") {
    queue.add("handle-reply", {
      threadId: data.thread_id,
      messageId: data.message_id,
      from: data.from,
      bodyText: data.body_text
    });
  }
  res.sendStatus(200); // acknowledge fast, process async
});

app.listen(3000);

Acknowledge with a 2xx quickly and do the agent work on a queue. Webhook registration, signature verification and retry behaviour are covered in the webhooks concept docs.

Polling

If you are writing a CLI, a cron job, or a local test harness, listing messages on the mailbox is simpler than exposing a public endpoint. There is also an events stream, but note that the SDK does not reconnect SSE for you; if you consume the stream, write your own reconnect with backoff, or use webhooks. We compared the tradeoffs in webhooks vs WebSockets.

Reply and preserve the thread

Threading is header work, not string matching on subjects. Pass the inbound message's RFC Message-ID as inReplyTo and the SDK sets the right headers so Gmail, Outlook and Apple Mail all file your reply under the existing conversation:

async function replyOnThread(mailboxId: string, inbound: {
  message_id: string;
  from: string;
  subject: string;
}) {
  return mail.sendMessage(mailboxId, {
    to: [inbound.from],
    displayName: "Shopping Agent",
    subject: `Re: ${inbound.subject.replace(/^Re:\s*/i, "")}`,
    bodyText: "Thanks, noting Tuesday as the ship date.",
    inReplyTo: inbound.message_id
  });
}

Drop inReplyTo and the recipient gets a new standalone conversation, which looks broken as soon as there are three or four turns. If you are building anything multi-turn, read how email threading works once and you will stop guessing.

Attachments follow the same pattern: upload with a full-access key, then pass the returned ids in attachments on the send.

Keep API keys out of browser bundles

This SDK is server-only. A Robotomail key can read a mailbox and send mail as your agent, so a leaked key is a leaked identity.

  1. Never prefix the variable with a client-exposed prefix. NEXT_PUBLIC_, VITE_, PUBLIC_ and friends are inlined into the bundle by design. Keep it as plain ROBOTOMAIL_API_KEY.
  2. Instantiate Robotomail only in server files: route handlers, server actions, background workers, queue consumers, cron entry points. Not in a React component that might render on the client.
  3. Have the browser call your own endpoint, which authenticates your user and then calls Robotomail. Your API is the trust boundary.
  4. Use a mailbox-scoped key where the job only touches one mailbox. If it leaks, the blast radius is one mailbox instead of the account.
  5. Grep your build output before shipping: grep -r "rm_" dist/ .next/. It takes two seconds and catches the accidental import.

More on isolating agent credentials in the agent email security guide.

Troubleshooting

Unauthorized on the first call. ROBOTOMAIL_API_KEY is not in the process environment. A .env file does nothing on its own under plain node; load it with node --env-file=.env script.js on Node 20.6+, or a loader of your choice.

Forbidden on account or attachment calls. You are using a mailbox-scoped key. Account operations and attachment uploads need a full-access key. See authentication.

ERR_REQUIRE_ESM or "Cannot use import statement outside a module". Module format mismatch in your project, not the SDK. Either add "type": "module" and use import, or use require from a CommonJS file.

Types resolve to any. Your tsconfig.json is on legacy module resolution. Switch to nodenext.

The reply started a new conversation. inReplyTo was missing or held an internal id rather than the RFC Message-ID from the inbound message.

A send timed out and you do not know if it went out. The SDK does not retry sends, deliberately, because a blind retry on an ambiguous POST is how agents send duplicates. List the mailbox's sent messages and check whether your message is there before resending.

Mail to a stranger bounced during testing. Send to your verified owner address until your sending setup and, if you use one, your custom domain are in place.

FAQ

Is the Node.js email SDK on npm?

Install it from the pinned Git tag shown above: npm install github:robotomail/robotomail-node#v0.1.0. The repository is public and MIT licensed. Check the SDK docs for current distribution before swapping in a registry command.

Does it work with TypeScript and JavaScript?

Both. Type declarations ship with the package, and the runtime builds cover ESM and CommonJS. TypeScript users should set module and moduleResolution to nodenext to get the typed request and response shapes.

Can I use the SDK in the browser or in an edge function?

Do not put the key in a browser bundle. Server runtimes are fine as long as the key stays in server-side environment variables and the SDK is only constructed in server code.

What does the SDK not do for me?

It does not retry sends and it does not reconnect an SSE stream. Both are intentional: retries risk duplicate mail, and reconnect policy belongs to your process supervisor. Use webhooks for inbound and inspect sent messages after an ambiguous send.

Ready to wire this up? Start with Robotomail, create your account and first mailbox, and keep the TypeScript SDK reference open while you build.

Give your AI agent a real email address

Create a mailbox, connect your agent and test a conversation. Send, receive and retrieve the thread through one API.

Related posts