npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

m365-graph-mail

v0.1.1

Published

Lightweight, zero-dependency Microsoft Graph mail toolkit for Node.js. App-only auth (client_credentials), automatic retry with backoff and jitter, real-world helpers for folders, drafts (inline images), attachments, full-text search, conversation threadi

Readme

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. [email protected]).

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=...
//    [email protected]

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: '[email protected]',
  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: '[email protected]',    // 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, ['[email protected]'], '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: ['[email protected]'],
  cc: ['[email protected]'],
});

// 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: ['[email protected]'] });

// 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: ['[email protected]'],
  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: ['[email protected]'],
  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: '[email protected]', 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.