Skip to content

StbanMc/m365-graph-mail

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

m365-graph-mail

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.

npm version Node.js License: MIT

🇪🇸 Léelo en español


Why this exists

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.

When to use this

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

When not to use this

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

Install

npm install m365-graph-mail

Requires Node.js 18+ (uses URLSearchParams, modern https, etc.).


Setup (5 minutes in Azure)

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.


Quick start

// 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>',
});

API

All methods are async unless noted.

Messages

// 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'

Folders

// 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']);

Search (full-text)

// 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' });

Conversations / threading

// 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`);
}

Drafts and replies

// 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);

Sending in one shot (no intermediate draft)

await mail.sendMail({
  subject: 'System alert',
  bodyHtml: '<p>Disk usage at 92%.</p>',
  to: ['ops@example.com'],
  importance: 'high',
});

Inline images (CID embedding)

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],
});

Attachments

// 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`);

Batch operations

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 } },
]);

Pagination (large mailboxes)

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;
  }
}

Low-level escape hatches

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.


Configuration reference

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

Errors

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

Retries

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`);
    },
  },
});

Idempotency

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.

Healthcheck

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.


Examples

See examples/ for runnable scripts:

  • 01-list-unread.js — find unread mail in the last 24h
  • 02-send-mail-with-signature.js — compose & send a new mail with HTML signature
  • 03-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 history
  • 05-bulk-mark-and-move.js — search + auto-create folder + batch mark-read + batch move
  • 06-send-with-inline-image.js — send HTML email with embedded inline (CID) image

Security

  • Never commit .env. The library's .gitignore excludes 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.

License

MIT © Esteban Esquivel — see LICENSE.

About

Lightweight, zero-dependency Microsoft Graph mail toolkit for Node.js. App-only auth (client_credentials), automatic retry, folders, drafts, search, batch, pagination.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors