How to Send Email via API in JavaScript (Node.js Tutorial)

Table of Contents
Table of Contents 14 sections
Getting Started
Sending Emails
Production
Reference
JavaScript Tutorial

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.

send email api javascript node.js send email api Gmail / Outlook / SMTP ESM + async/await
sendEmail.mjs
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!

'
});
Email delivered - 202 Accepted
Works with: Gmail Outlook IMAP
TL;DR

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.

1
Install the SDK
npm install unipile-node-sdk
2
Set env vars
Add UNIPILE_DSN and UNIPILE_TOKEN to your .env file.
3
Link an email account
OAuth for Gmail/Outlook or SMTP credentials for any IMAP server - one API call.
4
Call client.email.send()
Pass account_id, to, subject, and body. Done.
Same code works for Gmail, Outlook, and any IMAP server - no provider-specific logic needed. Check the Email API guide for the full concept overview.
send.mjs
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_...' }
Setup

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.

Node.js 18+ (20 recommended)
The Unipile SDK uses native fetch and top-level await. Node 20 LTS is recommended for production. Check your version with node -v.
Unipile API Key & DSN
Sign up at the Unipile dashboard to get your access token and DSN (a personal HTTPS endpoint like api4.unipile.com:13444). Both are required to initialise the client.
ESM or CommonJS
The SDK supports both. For ESM, use .mjs files or set "type":"module" in package.json. For CommonJS, dynamic import() works too - examples shown below.
A linked email account
You send email through a linked account (Gmail, Outlook, or IMAP). The next section walks you through the OAuth flow to link one. You only do this once per account.
Installing the Unipile SDK
npm
yarn
pnpm
npm install unipile-node-sdk
yarn add unipile-node-sdk
pnpm add unipile-node-sdk
.env
# 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_xxxxxxxxxxxxxxxx

Add .env to your .gitignore. Use dotenv or the native Node 20.6+ --env-file flag to load it: node --env-file=.env send.mjs.

Account Linking

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.

GmailGmail OAuth
OutlookOutlook / Microsoft 365
IMAPSMTP / IMAP
connect-gmail.mjs
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_id
connect-outlook.mjs
import { 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/
connect-imap.mjs
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 this
Gmail Gmail
Uses Google OAuth 2.0. No app passwords needed. See the full Gmail API send email tutorial for scopes and consent screen setup.
Outlook Outlook / Microsoft 365
Uses Microsoft Graph OAuth. Covers personal Outlook and M365 tenants. Details in the Microsoft Graph email guide.
IMAP SMTP / IMAP
Works with any provider that exposes IMAP/SMTP (Yahoo, ProtonMail Bridge, custom mail servers). Read the IMAP API solution guide.
Core API

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

1
Plain-text email
Simplest case
plain-text.mjs
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_xxxxxxxxxxxxxxxx
202 Accepted - result.tracking_id populated
2
HTML body with CC and BCC
Multi-recipient
html-cc-bcc.mjs
const 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);
3
Full field reference
All supported params
FieldTypeRequiredDescription
account_idstringRequiredID of the linked email account to send from
toRecipient[]RequiredArray of {display_name, identifier} objects
subjectstringRequiredEmail subject line
bodystringRequiredPlain text or HTML string
ccRecipient[]OptionalCarbon-copy recipients
bccRecipient[]OptionalBlind carbon-copy recipients
fromRecipientOptionalOverride sender display name
reply_tostringOptionalProvider message ID to reply to (threads)
attachmentsArrayOptionalArray of [filename, Buffer] tuples
custom_headersobject[]OptionalCustom X- headers array
tracking_optionsobjectOptional{opens, links, label} - enable open/click tracking
Free to start
Ready to send email via API in JavaScript?

Get your API key, link a Gmail or Outlook account in minutes, and run the Node.js examples from this guide against real mailboxes.

Attachments

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.

Single file from disk
Use fs.promises.readFile(path) to get a Buffer, then pass ['filename.pdf', buffer]. Works for PDF, DOCX, images, any binary.
Multiple attachments
Pass an array of tuples. Each tuple is independent - mix file types freely. No hard cap per attachment, but keep total payload under your plan's limit.
Inline images (CID)
Embed images in the HTML body using the cid: scheme. Reference the same filename used in the attachments tuple: .
Buffer from a stream
Generate a PDF on-the-fly (e.g., with pdfkit), collect it into a Buffer, and attach without writing to disk. Production-safe for serverless.
attach-file.mjs
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] ], });
attach-multiple-inline.mjs
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: ` Logo

Q1 Report

See the attached spreadsheet for details.

`
, attachments: [ ['logo.png', logo], // inline via cid:logo.png in body ['report.xlsx', report], // regular attachment ], });
Size limit: Keep individual attachments under 25 MB (Gmail's hard limit). For large files, upload to cloud storage and include a download link in the email body instead.
Advanced

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.

reply-thread.mjs
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.

custom-headers.mjs
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.

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

on-behalf.mjs
// 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.

Production-Ready

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.

202
Accepted
Email queued successfully. The tracking_id is populated. No retry needed.
429
Rate Limited
Too many requests. Respect the Retry-After header. Use exponential backoff.
401
Unauthorized
Invalid or expired token. Check UNIPILE_TOKEN env var. Do not retry - fix credentials.
422
Validation Error
Missing required field or malformed recipient. Check the error body - it specifies which field failed.
503
Service Unavailable
Transient server error. Safe to retry with backoff. Check status.unipile.com for incidents.
404
Account Not Found
The account_id does not exist or was revoked. Re-link the account.
Exponential backoff with async/await
send-with-retry.mjs
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

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.

Never expose keys client-side
Your 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.
Use a backend proxy route
In Next.js, create an API route (/api/send-email). In Express, a POST endpoint. Authenticate your own users first, then call Unipile server-side with the token from env.
OAuth refresh tokens
Unipile manages Gmail and Outlook OAuth token refresh automatically. Your stored account_id remains valid. You do not need to handle access token expiry yourself.
DKIM and SPF (sender reputation)
Emails sent through linked Gmail/Outlook accounts inherit those providers' DKIM signatures. For IMAP/SMTP accounts, configure DKIM on your mail server before linking.
Validate recipient input
If recipients come from user input, validate email format server-side before calling the API. The API will reject malformed addresses with a 422, but validation before the call gives cleaner UX errors.
Scope API tokens by environment
Use separate Unipile projects (and tokens) for dev, staging, and production. This prevents test emails from going to real users and limits blast radius if a key is leaked.
WRONG Token in frontend code
// client.js - NEVER do this const client = new UnipileClient( 'https://api4.unipile.com:13444', 'sk_live_xxxxxxxxxx' // exposed! );
CORRECT Backend proxy route (Next.js)
// 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({...}); }
Read the complete email API security guide
Common Pitfalls

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.

1
Forgetting top-level await (CommonJS vs ESM)
Top-level 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.
cjs-workaround.cjs
// 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({ /* ... */ }); })();
2
Using Promise.all for bulk sends without rate-limit awareness
Firing 1,000 Promise.all sends simultaneously will hit rate limits and cause 429 errors. Use a concurrency limiter like p-limit to cap parallel requests.
bulk-send.mjs
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, }))) );
3
Wrong Buffer encoding for attachments
Always pass a raw 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').
buffer-encoding.mjs
// 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')]]
4
Unhandled Promise rejections crashing the process
In Node.js, an unhandled rejection in a fire-and-forget send() call will crash the process in Node 15+. Always await the result or attach a .catch() handler.
unhandled-rejection.mjs
// 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()
5
Using this API directly in browser JavaScript
The Unipile SDK and your 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.
6
Sending without a linked account (missing account_id)
You cannot send from a generic address - every send must reference a valid 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.

Use the Unipile unified email API instead of a direct SMTP connection. Install 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.

en_USEN