Microsoft Graph
Send through Microsoft Graph's sendMail API with OAuth 2.0 client credentials or custom token providers for managed identity and certificate authentication.
Capabilities
| Repeated headers | Idempotency | Scheduling | Personalized |
|---|---|---|---|
| Yes | none | No | expanded |
These values come from the adapter's exported capabilities declaration. The field support matrix covers normalized message fields.
The Graph adapter calls Microsoft Graph's sendMail API with plain fetch and supports both OAuth 2.0 client credentials flow and custom getAccessToken functions for managed identity, certificate-based authentication, or external token providers. It authenticates with app permissions (Mail.Send) and sends from a specific user's mailbox by user ID or UPN.
Configure
Create an app registration in Microsoft Entra ID (Azure AD), grant it the Mail.Send application permission, get admin consent, and verify the sender identity matches a real mailbox in your tenant.
import { createEmailClient } from "@opencoredev/email-sdk";
import { graph } from "@opencoredev/email-sdk/graph";
export const email = createEmailClient({
adapters: [
graph({
tenantId: process.env.MS_GRAPH_TENANT_ID!,
clientId: process.env.MS_GRAPH_CLIENT_ID!,
clientSecret: process.env.MS_GRAPH_CLIENT_SECRET!,
user: process.env.MS_GRAPH_USER!,
}),
],
});Prop
Type
Send
Graph maps cc, bcc, one replyTo, custom headers (x- prefix only), and base64 attachments. There are no tags, metadata, or scheduled delivery.
const result = await email.send({
from: "Acme Alerts <alerts@acme.com>",
to: "ops@example.com",
subject: "Build failed on main",
html: "<p>Commit abc123 broke the build.</p>",
headers: [{ name: "X-Build-ID", value: "build_98412" }],
});
console.log(result.adapter); // "graph"Graph returns a 202 Accepted status with an empty body, so there is no message ID. If you need to correlate delivery events later, keep saveToSentItems enabled and retrieve the internetMessageId from the Sent Items copy.
Only x- prefixed custom headers
Graph accepts custom headers only if the name starts with x- (case insensitive). A header like
List-Unsubscribe throws an EmailValidationError before any request is made. Graph caps custom
headers at 5 per message.
No tags, metadata, or scheduling
Graph's sendMail API has no tags, metadata, or sendAt fields. Pass any of these and the
adapter throws an EmailValidationError before making a request.
Managed identity and certificate authentication
Pass a custom getAccessToken function to skip the built-in client credentials flow. The adapter calls your function before every send and caches nothing, so you control token lifetime and refresh logic.
import { DefaultAzureCredential } from "@azure/identity";
import { graph } from "@opencoredev/email-sdk/graph";
const credential = new DefaultAzureCredential();
export const email = createEmailClient({
adapters: [
graph({
user: "alerts@acme.com",
async getAccessToken() {
const token = await credential.getToken("https://graph.microsoft.com/.default");
return token.token;
},
}),
],
});When getAccessToken is supplied, tenantId, clientId, and clientSecret must not be set.
Verify from the CLI
Export MS_GRAPH_CLIENT_SECRET in your shell or inject it through your secret manager.
The CLI reads it from the environment; do not pass it as a command-line argument.
MS_GRAPH_TENANT_ID="..." MS_GRAPH_CLIENT_ID="..." MS_GRAPH_CLIENT_SECRET="..." MS_GRAPH_USER="..." \
npx --package @opencoredev/email-sdk email-sdk doctor --adapter graphnpx --package @opencoredev/email-sdk email-sdk send \
--adapter graph \
--tenant-id "$MS_GRAPH_TENANT_ID" \
--client-id "$MS_GRAPH_CLIENT_ID" \
--user "$MS_GRAPH_USER" \
--from "Acme <hello@acme.com>" \
--to user@example.com \
--subject "Graph smoke test" \
--text "It works" \
--dry-runDrop --dry-run for one real send. Only Microsoft can prove the app registration, consent, and mailbox are set up correctly.
National clouds and Convex
Add --live to doctor --adapter graph for a non-sending client-credentials check.
It verifies authentication, not mailbox access, Mail.Send consent, or delivery.
The CLI accepts --token-url / MS_GRAPH_TOKEN_URL and --scope / MS_GRAPH_SCOPE
for national-cloud authentication, alongside --base-url / MS_GRAPH_BASE_URL for sending.
For a national-cloud SDK client, configure baseUrl, tokenUrl, and scope together.
For example, US Government L4 uses https://graph.microsoft.us/v1.0,
https://login.microsoftonline.us/<tenant-id>/oauth2/v2.0/token, and
https://graph.microsoft.us/.default. See Microsoft's national-cloud guidance.
In the Convex component, endpoint overrides and scope are server-controlled through
MS_GRAPH_BASE_URL, MS_GRAPH_TOKEN_URL, and MS_GRAPH_SCOPE. Public send requests
cannot provide literal endpoint URLs, so callers cannot redirect server credentials.
MS_GRAPH_CLIENT_SECRET is restricted to Graph's clientSecret field; other adapter
or field environment overrides cannot read it.
Graph's environment mappings are fixed to its declared variables. Map custom secret
names in the trusted app.use(convexEmail, { env }) configuration, not in send requests.
Frequently asked questions
Does Email SDK support Microsoft Graph?
Yes. Email SDK ships a Microsoft Graph adapter imported from @opencoredev/email-sdk/graph. You keep your Microsoft Graph account and credentials; the SDK adds message validation, typed errors, and no-network test adapters around the same send() call used for every other provider.
Which message fields does the Microsoft Graph adapter support?
Microsoft Graph supports CC recipients, BCC recipients, Reply-To address, Custom headers, and Attachments. It does not support Tags, Metadata, and Scheduled sending (sendAt); Email SDK rejects a message that uses those fields before any request is made.
Can Microsoft Graph schedule email for later with Email SDK?
No. Microsoft Graph has no provider-side scheduling, so a message with sendAt fails validation. Store the job in your own queue and send when it is due.
Does the Microsoft Graph adapter support idempotent sends?
No. Microsoft Graph has no provider-side idempotency, so a retried send can be delivered twice. Deduplicate in your own queue before retrying.