How to Send Email via API in JavaScript (Node.js Tutorial)
Skip the SMTP boilerplate. This guide shows you how to send email in Node.js using the Unipile unified email API - with copy-paste code for Gmail, Outlook, and SMTP in under 10 lines of JavaScript.
import { UnipileClient } from 'unipile-node-sdk';
const client = new UnipileClient(
process.env.UNIPILE_DSN,
process.env.UNIPILE_TOKEN
);
await client.email.send({
account_id: 'YOUR_ACCOUNT_ID',
to: [{ display_name: 'Alice',
identifier: 'alice@example.com' }],
subject: 'Hello from Node.js',
body: 'Sent via Unipile!
'
});5-Line Node.js Example
If you already know what a Send Email API is and just want to send email API JavaScript code that actually runs, here it is. The full tutorial follows below.
npm install unipile-node-sdkUNIPILE_DSN and UNIPILE_TOKEN to your .env file.client.email.send()account_id, to, subject, and body. Done.import { UnipileClient } from 'unipile-node-sdk';
const client = new UnipileClient(
process.env.UNIPILE_DSN,
process.env.UNIPILE_TOKEN
);
const result = await client.email.send({
account_id: 'acc_xxxxxxxxxxxxxxxx',
to: [{
display_name: 'Alice Martin',
identifier: 'alice@acme.com'
}],
subject: 'Hello from Node.js',
body: 'Sent via Unipile API!
'
});
console.log(result); // { tracking_id: 'msg_...' }Prerequisites & Setup
Before you can use the send email API JavaScript workflow in production, you need four things: a supported Node.js version, the Unipile SDK, an API key, and a DSN endpoint.
fetch and top-level await. Node 20 LTS is recommended for production. Check your version with node -v.api4.unipile.com:13444). Both are required to initialise the client..mjs files or set "type":"module" in package.json. For CommonJS, dynamic import() works too - examples shown below.npm install unipile-node-sdkyarn add unipile-node-sdkpnpm add unipile-node-sdk# Unipile credentials (never commit this file)
UNIPILE_DSN=https://api4.unipile.com:13444
UNIPILE_TOKEN=your_access_token_here
# The account ID of the linked email account
EMAIL_ACCOUNT_ID=acc_xxxxxxxxxxxxxxxxAdd .env to your .gitignore. Use dotenv or the native Node 20.6+ --env-file flag to load it: node --env-file=.env send.mjs.
Connecting Your First Email Account
The unified email API uses a single account_id to abstract provider differences. Link an account once, then call client.email.send() identically across all three providers.
Pick your provider below to see the exact Node.js snippet. The account_id returned is what you store and reuse for every subsequent send.
import { UnipileClient } from 'unipile-node-sdk';
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
// Step 1: generate a hosted OAuth link for Gmail
const { url } = await client.account.createHostedAuthLink({
type: 'GOOGLE',
success_redirect_url: process.env.OAUTH_CALLBACK_URL,
failure_redirect_url: process.env.OAUTH_CALLBACK_URL + '?error=1',
});
// Step 2: redirect your user to `url`
console.log('Redirect user to:', url);
// Step 3: Unipile POSTs the account_id to your callback
// Store it: process.env.EMAIL_ACCOUNT_ID = result.account_idimport { UnipileClient } from 'unipile-node-sdk';
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
// Works for personal Outlook AND Microsoft 365 / Exchange Online
const { url } = await client.account.createHostedAuthLink({
type: 'MICROSOFT',
success_redirect_url: process.env.OAUTH_CALLBACK_URL,
failure_redirect_url: process.env.OAUTH_CALLBACK_URL + '?error=1',
});
// Redirect user -> they complete Microsoft OAuth flow
console.log('Redirect user to:', url);
// See: /syncing-emails-with-microsoft-graph-api-a-developers-guide/import { UnipileClient } from 'unipile-node-sdk';
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
// SMTP/IMAP: pass credentials directly (no OAuth redirect)
const account = await client.account.create({
type: 'IMAP',
imap: {
username: process.env.IMAP_USER,
password: process.env.IMAP_PASS,
host: process.env.IMAP_HOST,
port: 993,
},
smtp: {
username: process.env.IMAP_USER,
password: process.env.IMAP_PASS,
host: process.env.SMTP_HOST,
port: 587,
},
});
console.log(account.account_id); // store thisSending Your First Email from Node.js
Three production-ready patterns for the send email API JavaScript workflow: plain text, HTML with CC/BCC, and reading the response object. All use the same client.email.send() call.
import { UnipileClient } from 'unipile-node-sdk';
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
const result = await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Bob', identifier: 'bob@example.com' }],
subject: 'Welcome to the platform',
body: 'Hi Bob, your account is ready.',
});
console.log(result.tracking_id); // msg_xxxxxxxxxxxxxxxxconst result = await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Alice', identifier: 'alice@acme.com' }],
cc: [{ display_name: 'Manager', identifier: 'boss@acme.com' }],
bcc: [{ display_name: 'Audit', identifier: 'audit@internal.io' }],
subject: 'Your invoice #1042',
// HTML body - provider renders it natively
body: `
Invoice #1042
Amount due: $299
Pay now
`,
});
console.log('Sent:', result.tracking_id);| Field | Type | Required | Description |
|---|---|---|---|
account_id | string | Required | ID of the linked email account to send from |
to | Recipient[] | Required | Array of {display_name, identifier} objects |
subject | string | Required | Email subject line |
body | string | Required | Plain text or HTML string |
cc | Recipient[] | Optional | Carbon-copy recipients |
bcc | Recipient[] | Optional | Blind carbon-copy recipients |
from | Recipient | Optional | Override sender display name |
reply_to | string | Optional | Provider message ID to reply to (threads) |
attachments | Array | Optional | Array of [filename, Buffer] tuples |
custom_headers | object[] | Optional | Custom X- headers array |
tracking_options | object | Optional | {opens, links, label} - enable open/click tracking |
Get your API key, link a Gmail or Outlook account in minutes, and run the Node.js examples from this guide against real mailboxes.
Sending Attachments in Node.js
The attachments field accepts an array of [filename, Buffer] tuples. Read the file with Node's fs.readFileSync or stream it with fs.promises.readFile.
fs.promises.readFile(path) to get a Buffer, then pass ['filename.pdf', buffer]. Works for PDF, DOCX, images, any binary.cid: scheme. Reference the same filename used in the attachments tuple: 
.pdfkit), collect it into a Buffer, and attach without writing to disk. Production-safe for serverless.import { UnipileClient } from 'unipile-node-sdk';
import { promises as fs } from 'node:fs';
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
// Read file into a Buffer
const pdfBuffer = await fs.readFile('./invoice.pdf');
await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Client', identifier: 'client@example.com' }],
subject: 'Your invoice is attached',
body: 'Please find your invoice attached.
',
attachments: [
['invoice.pdf', pdfBuffer], // [filename, Buffer]
],
});const [logo, report] = await Promise.all([
fs.readFile('./logo.png'),
fs.readFile('./report.xlsx'),
]);
await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Team', identifier: 'team@acme.com' }],
subject: 'Q1 Report',
body: `
Q1 Report
See the attached spreadsheet for details.
`,
attachments: [
['logo.png', logo], // inline via cid:logo.png in body
['report.xlsx', report], // regular attachment
],
});Replies, Threads & Tracking
Go beyond basic sends: thread replies, custom headers for idempotency, open/click tracking via webhooks, and sending email on behalf of a user.
1
Replying to a thread
Pass the provider message ID (returned in the original send response or from listing emails) as reply_to. Unipile injects the correct In-Reply-To and References headers so the reply threads correctly in Gmail, Outlook, and IMAP clients.
await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Alice', identifier: 'alice@acme.com' }],
subject: 'Re: Your question',
body: 'Thanks for reaching out! Here is the answer...
',
// Provider message ID from the original email
reply_to: 'msg_xxxxxxxxxxxxxxxx',
});
2
Custom headers & idempotency
Use custom_headers to add any X- header. A common pattern is X-Idempotency-Key to prevent duplicate sends when retrying after a network timeout.
import { randomUUID } from 'node:crypto';
await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Bob', identifier: 'bob@example.com' }],
subject: 'Order confirmation #9981',
body: 'Your order is confirmed.
',
custom_headers: [
{ name: 'X-Idempotency-Key', value: randomUUID() },
{ name: 'X-Order-ID', value: '9981' },
],
});
3
Open & click tracking via webhooks
Enable tracking in tracking_options. Unipile fires a webhook event (email.opened / email.link_clicked) to your registered webhook URL with the label you set here so you can correlate events to your internal IDs.
await client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'Lead', identifier: 'lead@prospect.com' }],
subject: 'Following up on your trial',
body: 'Hi, wanted to check in...
',
tracking_options: {
opens: true, // fires email.opened webhook
links: true, // fires email.link_clicked webhook
label: 'crm_lead_12345', // your internal correlation ID
},
});
4
Sending on behalf of another user
When building multi-tenant SaaS apps, each end-user links their own email account. Store the account_id per user in your database and pass it at send time. See the full guide on how to send email on behalf of a user.
// Each user has their own linked account_id stored in your DB
async function sendAsUser(userId, to, subject, body) {
const user = await db.getUser(userId);
return client.email.send({
account_id: user.unipile_account_id, // per-user
to, subject, body,
});
}
// Email is sent from alice's Gmail, not your server address
await sendAsUser('user_alice', recipients, subject, body);Working in Python instead? See our Python implementation guide.
Error Handling & Retries
Network failures and rate limits are inevitable at scale. Here is a production-grade async/await pattern for your send email API JavaScript implementation - with exponential backoff and structured logging using Winston or Pino.
tracking_id is populated. No retry needed.Retry-After header. Use exponential backoff.UNIPILE_TOKEN env var. Do not retry - fix credentials.account_id does not exist or was revoked. Re-link the account.import { UnipileClient } from 'unipile-node-sdk';
import logger from './logger.mjs'; // Winston or Pino instance
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
async function sendWithRetry(payload, maxAttempts = 4) {
let attempt = 0;
while (attempt < maxAttempts) {
try {
const result = await client.email.send(payload);
logger.info({ tracking_id: result.tracking_id }, 'Email sent');
return result;
} catch (err) {
const status = err?.status ?? err?.statusCode;
// Do not retry on client errors (4xx except 429)
if (status >= 400 && status !== 429 && status < 500) {
logger.error({ status, err }, 'Non-retryable error');
throw err;
}
attempt++;
if (attempt >= maxAttempts) throw err;
// Exponential backoff: 1s, 2s, 4s, 8s...
const delay = 1000 * 2 ** (attempt - 1);
logger.warn({ attempt, delay, status }, 'Retrying...');
await new Promise(r => setTimeout(r, delay));
}
}
}
// Usage
await sendWithRetry({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [{ display_name: 'User', identifier: 'user@example.com' }],
subject: 'Your report is ready',
body: 'Click to download.
',
});Security Best Practices in Node.js
Your UNIPILE_TOKEN grants full API access. Never expose it client-side. See the full email API security guide for advanced topics including DKIM, SPF, and OAuth token rotation.
UNIPILE_TOKEN must stay on the server. Any browser-side JavaScript - including Next.js client components, React frontend, or Vite - must NOT import the token directly./api/send-email). In Express, a POST endpoint. Authenticate your own users first, then call Unipile server-side with the token from env.account_id remains valid. You do not need to handle access token expiry yourself.// client.js - NEVER do this
const client = new UnipileClient(
'https://api4.unipile.com:13444',
'sk_live_xxxxxxxxxx' // exposed!
);
// app/api/send-email/route.js
export async function POST(req) {
// auth check first
const session = await getServerSession();
if (!session) return new Response(null,{status:401});
const client = new UnipileClient(
process.env.UNIPILE_DSN, // server-only
process.env.UNIPILE_TOKEN // server-only
);
await client.email.send({...});
}
Common Pitfalls (Node.js-specific)
These are the most frequent mistakes developers make when integrating a send email API in JavaScript for the first time. Each one is easy to avoid once you know the pattern - and all apply equally to the Node.js send email API context.
await only works in ES modules (.mjs or "type":"module"). In CommonJS, wrap your send call in an async IIFE or use .then(). The SDK works in both - just pick the right module format.// CommonJS: wrap in async IIFE
(async () => {
const { UnipileClient } = await import('unipile-node-sdk');
const client = new UnipileClient(process.env.UNIPILE_DSN, process.env.UNIPILE_TOKEN);
await client.email.send({ /* ... */ });
})();Promise.all sends simultaneously will hit rate limits and cause 429 errors. Use a concurrency limiter like p-limit to cap parallel requests.import pLimit from 'p-limit';
const limit = pLimit(5); // max 5 concurrent sends
const results = await Promise.all(
recipients.map(r => limit(() => client.email.send({
account_id: process.env.EMAIL_ACCOUNT_ID,
to: [r], subject, body,
})))
);Buffer object - not a base64 string, not a UTF-8 string. If you have base64 content (e.g. from a webhook or API response), convert it first: Buffer.from(base64str, 'base64').// WRONG: passing a string
attachments: [['file.pdf', 'JVBERi0xLjQ...']] // base64 string - breaks!
// CORRECT: Buffer object
attachments: [['file.pdf', Buffer.from(base64str, 'base64')]]
// or from disk:
attachments: [['file.pdf', await fs.readFile('./file.pdf')]]send() call will crash the process in Node 15+. Always await the result or attach a .catch() handler.// WRONG: fire-and-forget - crashes on error in Node 15+
client.email.send(payload); // no await, no .catch()
// CORRECT: always handle the promise
await client.email.send(payload) // option 1: await
.catch(err => logger.error(err)); // option 2: .catch()UNIPILE_TOKEN are for server-side Node.js only. Browser fetch to the API directly would expose your token. Use a backend route (Next.js API route, Express endpoint, Netlify/Vercel function) as a proxy.account_id from a previously linked Gmail, Outlook, or IMAP account. The API returns 404 if the account_id is missing or was unlinked.Frequently Asked Questions
Common questions about sending email in JavaScript and Node.js with the Unipile unified email API.
unipile-node-sdk, initialise UnipileClient with your DSN and token, link a Gmail or Outlook account via OAuth, then call client.email.send(). No SMTP server, no port 587, no TLS configuration needed on your end - Unipile handles the transport layer.No - and you should not. Calling the Unipile API from browser JavaScript would expose your UNIPILE_TOKEN to any user who opens DevTools. Always call the API from a server-side Node.js context: an Express route, a Next.js API route (app/api/), a Vercel Edge Function, or a Netlify Function.
Your frontend sends a request to your own backend endpoint, which authenticates the user session and then calls Unipile server-side.
Nodemailer connects directly to an SMTP server from your Node.js process. It requires you to manage SMTP credentials, handle TLS, configure DKIM yourself, and deal with each provider's quirks separately.
The Unipile email API is a cloud abstraction layer: you link accounts via OAuth (no SMTP credentials needed for Gmail/Outlook), get a single consistent SDK for all providers, and Unipile handles transport, retries, and token refresh. The trade-off is that your sends go through Unipile's infrastructure rather than a direct SMTP connection.
See our full email API providers comparison.Yes. The unipile-node-sdk package ships with TypeScript type definitions. You get full autocomplete and type safety for the send() payload, including Recipient, tracking_options, and the response type.
import { UnipileClient } from 'unipile-node-sdk';
// Full TS types - autocomplete works in VSCode
const client: UnipileClient = new UnipileClient(dsn, token);
const result = await client.email.send({ /* typed! */ });For high-volume sends, use a concurrency limiter (e.g. p-limit with 5-10 concurrent calls), add exponential backoff on 429 responses, and spread sends across multiple linked accounts if possible. Each linked email account has its own sending limits set by the provider (Gmail: ~500/day for regular accounts, higher for Workspace).
For true bulk/marketing email at scale (millions of recipients), consider a dedicated ESP (Mailgun, SendGrid) alongside Unipile for transactional and OAuth-based sends. We cover this in our free Gmail, Outlook and IMAP API breakdown.
Next.js: use API routes (app/api/send-email/route.js) or Server Actions. Keep the SDK instantiation server-side only.
Nuxt: use server routes (server/api/send-email.post.ts). The SDK is Node.js-only so it cannot go in a composable or plugin that runs on the client.
NestJS: create an EmailModule with a service that wraps UnipileClient. Inject it wherever you need to trigger sends - in controllers, CRON jobs, or event handlers.
Still have questions? Our team is here to help.