Lightweight, zero-dependency Microsoft Graph mail toolkit for Node.js. App-only OAuth (
client_credentials), automatic retry with exponential backoff, and real-world helpers for folders, drafts, signatures and attachments.
The official @microsoft/microsoft-graph-client is great if you're building
a full Graph integration, but it pulls in @azure/msal-node and a handful
of transitive dependencies, the API is verbose builder pattern, and getting
a working app-only mail script takes a non-trivial amount of setup.
This library is the opposite trade-off: just mail, just app-only auth,
just Node built-ins. No node_modules to vet, no MSAL boilerplate, no
multi-step builders. Drop it into a cron, a script, a serverless function.
- You run server-side automations on a single mailbox (or a small set of mailboxes).
- You authenticate with
client_credentials(app-only, no user flow). - You want zero dependencies in
node_modules. - You hit retry-worthy errors (
429,503) often enough that retries-as-default matter to you.
- You need delegated user auth, MSAL token cache, popups, refresh tokens, or
multi-tenant onboarding flows. Use
@azure/msal-node+@microsoft/microsoft-graph-client. - You need calendars, OneDrive, Teams, SharePoint. Use the official SDK.
npm install m365-graph-mailRequires Node.js 18+ (uses URLSearchParams, modern https, etc.).
You need an app registration in Azure AD with these application permissions (admin-consented):
| Permission | Purpose |
|---|---|
Mail.ReadWrite |
List, read, mark, move, draft, delete messages |
Mail.Send |
Send drafts |
Then grab from the app's overview:
- Tenant ID (Directory ID, GUID)
- Client ID (Application ID, GUID)
- Client secret value (from Certificates & secrets → New client secret)
And pick which mailbox the app operates on (e.g. automation@yourdomain.com).
Security note: application permissions grant the app access to the entire tenant's mailboxes by default. To scope it to a single mailbox, use Application Access Policy via PowerShell. Highly recommended.
// 1. Provide credentials via .env (gitignored)
// TENANT_ID=...
// CLIENT_ID=...
// CLIENT_SECRET=...
// MAILBOX=automation@yourdomain.com
require('m365-graph-mail').loadEnvFile('.env');
const { createClient } = require('m365-graph-mail');
const mail = createClient();
// 2. List the 10 most recent unread messages
const unread = await mail.listMessages({ unreadOnly: true, top: 10 });
console.log(`${unread.length} unread`);
// 3. Mark the first as read
if (unread.length) await mail.markRead(unread[0].id, true);Or pass credentials directly (skipping .env):
const mail = createClient({
tenantId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
clientId: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy',
clientSecret: process.env.MY_SECRET,
mailbox: 'automation@yourdomain.com',
signatureHtml: '<p>--<br>Automated mail bot</p>',
});All methods are async unless noted.
// List
const msgs = await mail.listMessages({
folder: 'Inbox', // 'Inbox' | 'SentItems' | 'Drafts' | folder ID
top: 20,
unreadOnly: false,
fromAddress: 'a@b.com', // post-filter
subjectContains: 'invoice', // post-filter
sinceDate: '2026-01-01T00:00:00Z',
});
// Get full message
const m = await mail.getMessage(msgId);
// Mark read / unread
await mail.markRead(msgId, true);
await mail.markRead(msgId, false);
// Move
await mail.moveMessage(msgId, destinationFolderId);
// Forward
await mail.forwardMessage(msgId, ['someone@example.com'], 'FYI');
// Copy
await mail.copyMessage(msgId, destinationFolderId);
// Delete (sends to Recoverable Items, NOT Deleted Items folder)
await mail.deleteMessage(msgId);
// Categories, importance, follow-up flag
await mail.setCategories(msgId, ['Invoice', 'Q1']);
await mail.addCategories(msgId, ['Reviewed']); // preserves existing
await mail.setImportance(msgId, 'high');
await mail.setFlag(msgId, 'flagged'); // 'notFlagged' | 'flagged' | 'complete'// Walk a path of names, case-insensitive
const id = await mail.findFolderByPath(['Projects', 'Acme'], 'Inbox');
// Recursive listing (tree-style)
const tree = await mail.listFoldersRecursive();
// Flat map with full paths — handy as a cached lookup
const map = await mail.buildFolderMap();
// [
// { id: '...', displayName: 'Projects', fullPath: 'Projects', depth: 0, ... },
// { id: '...', displayName: 'Acme', fullPath: 'Projects/Acme', depth: 1, ... },
// ]
// Substring match against displayName + fullPath
const matches = mail.findFolderFuzzy(map, 'acme');
// CRUD
const newFolder = await mail.createFolder('Archive 2026');
await mail.renameFolder(folderId, 'Archive 2026 (renamed)');
await mail.moveFolder(folderId, parentId);
await mail.deleteFolder(folderId);
// Auto-create intermediates ('mkdir -p' semantics)
const leafId = await mail.ensureFolderByPath(['Projects', '2026', 'Q1']);// KQL-style search across the mailbox
const hits = await mail.searchMessages({ query: 'subject:invoice' });
// Restrict to a folder
const inbox = await mail.searchMessages({ query: 'budget 2026', folder: 'Inbox' });// All messages in a thread, oldest first
const thread = await mail.listConversation(msg.conversationId);
// Group an existing message list by thread
const grouped = mail.groupByConversation(messages);
for (const [conversationId, msgs] of grouped) {
console.log(`${conversationId}: ${msgs.length} messages`);
}// Brand-new draft
const draft = await mail.createDraft({
subject: 'Hello',
bodyHtml: '<p>Hi there.</p>',
to: ['recipient@example.com'],
cc: ['cc@example.com'],
});
// Reply-all preserving quoted history + auto-appended signature
const { draftId } = await mail.createReplyAllDraft({
messageId: incomingMsgId,
newBodyHtml: '<p>Acknowledged.</p>',
});
// Update body or recipients later
await mail.updateDraftBody(draftId, '<p>Updated text.</p>');
await mail.updateDraftRecipients(draftId, { to: ['new@example.com'] });
// Send
await mail.sendDraft(draftId);await mail.sendMail({
subject: 'System alert',
bodyHtml: '<p>Disk usage at 92%.</p>',
to: ['ops@example.com'],
importance: 'high',
});const fs = require('fs');
const logo = fs.readFileSync('./logo.png');
const { contentId, attachment } = mail.buildInlineImage({
name: 'logo.png',
contentType: 'image/png',
data: logo,
});
await mail.sendMail({
subject: 'Newsletter',
bodyHtml: `<p>Hi,</p><img src="cid:${contentId}" alt="logo"/><p>...</p>`,
to: ['recipient@example.com'],
attachments: [attachment],
});// List attachments on a message
const atts = await mail.listAttachments(msgId);
// Copy file attachments from one message to a draft
const copied = await mail.copyAttachments(sourceMsgId, draftId);
console.log(`${copied} attachments copied`);Up to 20 sub-requests per HTTP round trip via Graph $batch. The library
auto-chunks if you pass more.
// Bulk mark-as-read
const result = await mail.markManyRead([id1, id2, id3], true);
console.log(`${result.ok} ok, ${result.failed} failed`);
// Bulk move
await mail.moveMany([id1, id2, id3], destinationFolderId);
// Raw batch (full control)
const responses = await mail.batch([
{ method: 'PATCH', url: `/me/messages/${id1}`, body: { isRead: true } },
{ method: 'POST', url: `/me/messages/${id2}/move`, body: { destinationId: folderId } },
]);Graph caps responses at a few hundred items per page. Use these helpers to
follow @odata.nextLink automatically.
// Eager: collect EVERY page (be careful with huge mailboxes)
const all = await mail.collectAll(
() => mail.listMessages({ folder: 'Inbox', top: 100, includeNextLink: true }),
{ maxPages: 50 },
);
// Lazy: async iterator — short-circuit when you find what you need
for await (const msg of mail.iterate(
() => mail.listMessages({ folder: 'Inbox', top: 100, includeNextLink: true }),
)) {
if (msg.subject.includes('urgent')) {
console.log('found:', msg.id);
break;
}
}For Graph endpoints not wrapped explicitly:
const calendars = await mail.graphGet('/calendars');
const newEvent = await mail.graphPost('/events', { subject, body, start, end });These prefix the URL with the user mailbox path automatically.
Environment variable overrides are read by default. You can override any of
them by passing them to createClient().
| Variable | Required | Description |
|---|---|---|
TENANT_ID |
yes | Azure AD tenant (directory) ID |
CLIENT_ID |
yes | App registration client ID |
CLIENT_SECRET |
yes | App registration client secret value |
MAILBOX |
yes | UPN of the mailbox to operate on |
Optional client constructor flags:
| Option | Default | Description |
|---|---|---|
signatureHtml |
'' |
HTML signature appended to drafts |
All HTTP failures throw a real Error subclass — never a plain object — so
stack traces and instanceof work as expected.
const { createClient, GraphHttpError, GraphAuthError, GraphConfigError } = require('m365-graph-mail');
try {
await mail.sendDraft(id);
} catch (err) {
if (err instanceof GraphHttpError) {
// err.statusCode → 401, 404, 429, 5xx, ...
// err.category → 'auth' | 'rate_limit' | 'server' | 'client'
// err.transient → true if a retry might succeed
// err.body → parsed Graph error body
// err.requestId → Graph's request-id (use this when opening a support case)
} else if (err instanceof GraphAuthError) {
// Token acquisition failed. err.aadError / err.aadErrorDescription have the AAD detail.
} else if (err instanceof GraphConfigError) {
// You passed something invalid (bad email, bad sinceDate, etc.) — fix and re-call.
}
}By default the HTTP layer retries on 408, 429, 502, 503, 504 up to
3 times with exponential backoff plus ±25% jitter to spread retries
across a fleet. Retry-After is honored for every retried status (clamped
to a sane maximum to prevent malicious-server stalls).
Override per-client:
const mail = createClient({
http: {
maxRetries: 5,
baseDelayMs: 2000,
socketTimeoutMs: 60000,
onRetry({ attempt, statusCode, delayMs, method, url }) {
console.warn(`[retry ${attempt}] ${method} ${url} → ${statusCode}, waiting ${delayMs}ms`);
},
},
});| Operation | Safe to retry? | Notes |
|---|---|---|
markRead, setCategories, setImportance, setFlag |
✅ | PATCH semantics |
getMessage, listMessages, searchMessages |
✅ | GET only |
moveMessage, copyMessage, forwardMessage |
Retry may produce duplicates; check before reissuing | |
sendMail, sendDraft |
❌ | Not idempotent. A network blip after Graph accepts the request will cause a duplicate send if you retry blindly. |
createDraft, createReply*Draft |
Creates a new draft each call | |
deleteMessage, deleteFolder |
✅ | Idempotent (subsequent deletes 404) |
For non-idempotent calls, use httpOptions.maxRetries: 0 per-call if you
want to disable auto-retry, or implement application-level deduplication.
await mail.healthCheck();
// → { ok: true, mailbox: 'automation@yourdomain.com', inboxId: '...' }Use it from a startup probe to validate that the token endpoint, mailbox permissions, and network are all wired correctly.
See examples/ for runnable scripts:
01-list-unread.js— find unread mail in the last 24h02-send-mail-with-signature.js— compose & send a new mail with HTML signature03-move-to-folder-by-path.js— move a message into a nested folder (exact + fuzzy)04-reply-all-preserve-history.js— reply-all with auto-signature, preserving quoted history05-bulk-mark-and-move.js— search + auto-create folder + batch mark-read + batch move06-send-with-inline-image.js— send HTML email with embedded inline (CID) image
- Never commit
.env. The library's.gitignoreexcludes it; double-check yours does too. - Rotate client secrets regularly. Azure AD app secrets default to 6-24 months.
- Scope the application access policy to the specific mailbox(es) you operate on, not the entire tenant.
- Read tokens are not persisted to disk by this library. They live in process memory and are renewed automatically.
MIT © Esteban Esquivel — see LICENSE.