aaron@mccarthy:~/posts/microsoft-graph-email-smtp-2026.md
← cd ../blog.md

Sending Email the Right Way in 2026: Microsoft Graph vs SMTP (Lessons Learned)

Every internal app I’ve built eventually needs to send email. Approval notifications, status updates, password resets—email is still the connective tissue of business communication. And for years, the answer was always the same: point Nodemailer at an SMTP server and move on.

That worked fine until it didn’t. Delayed messages. DNS errors nobody could explain. Emails landing in Junk for no obvious reason. Credentials that mysteriously stop working after a security policy change you weren’t told about.

I’ve hit all of these. Eventually I stopped fighting SMTP and switched to Microsoft Graph. Here’s why, what changed, and what bit me along the way.

## The Problem With SMTP in 2026

SMTP isn’t broken. It’s just showing its age in ways that matter when you’re building apps people rely on.

In production, I’ve seen DNS lookup failures silently delay delivery for hours. I’ve watched properly configured messages land in Junk because the sending server’s reputation score dipped. I’ve debugged authentication failures caused by Microsoft tightening security policies on basic auth—without any warning in the admin portal.

A typical error looks like this:

"450 4.4.312 DNS query failed"

That’s not something your application can fix. It’s a dependency problem two layers removed from your code, and it becomes your problem anyway when users start asking why they didn’t get the notification.

SMTP also stacks a lot of assumptions: username/password auth that’s increasingly deprecated, external mail server configuration you may not control, and SPF, DKIM, and DMARC records that all need to be perfectly aligned. Even when everything is set up right, deliverability can still be inconsistent.

The classic app-side setup looks simple on paper, but it hides a lot of fragility:

// nodemailer-style (illustrative) — many failure modes hide here
import nodemailer from "nodemailer";

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  secure: false,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

await transporter.sendMail({
  from: '"No Reply" <noreply@contoso.com>',
  to: "user@contoso.com",
  subject: "Your report is ready",
  text: "…",
});

## Why Microsoft Graph Changes the Game

Microsoft Graph flips the model. Instead of pushing messages through an SMTP relay and hoping for the best, you’re calling an API that talks directly to Microsoft 365. Your email goes through the same infrastructure that delivers Outlook messages. You’re not hoping the message arrives—you’re working within the same system that delivers it.

That means OAuth 2.0 instead of stored passwords, tighter integration with your tenant’s security policies, fewer moving parts between your code and the inbox, and clear API responses when something goes wrong instead of silence.

## Authentication: The Biggest Upgrade

This is the single biggest reason to switch. SMTP breaks constantly because of authentication changes—Microsoft keeps tightening security, basic auth is deprecated or heavily restricted, and your app stops sending email at 2 AM on a Tuesday with no warning.

With Graph, you register an app in Azure, grant it the Mail.Send permission, and authenticate using secure tokens instead of passwords. No stored credentials in config files. No password rotation surprises. No sudden authentication failures because someone changed a policy you didn’t know about.

It’s a one-time setup that pays off every day your app runs without email issues.

Here’s a minimal client credentials flow to obtain a token and call sendMail. In production you’d wrap this in a mail service, cache the token until expiry, and handle retries—but this is the shape of the thing:

// illustrative — app registration + Mail.Send (application) + admin consent
const tenantId = process.env.AZURE_TENANT_ID!;
const clientId = process.env.AZURE_CLIENT_ID!;
const clientSecret = process.env.AZURE_CLIENT_SECRET!;

async function getGraphAccessToken(): Promise<string> {
  const tokenUrl =
    `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;

  const body = new URLSearchParams({
    client_id: clientId,
    client_secret: clientSecret,
    scope: "https://graph.microsoft.com/.default",
    grant_type: "client_credentials",
  });

  const res = await fetch(tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });

  if (!res.ok) throw new Error(`token failed: ${res.status}`);
  const json = (await res.json()) as { access_token: string };
  return json.access_token;
}
async function sendMailViaGraph(options: {
  fromUserId: string;
  to: string;
  subject: string;
  html: string;
}) {
  const token = await getGraphAccessToken();

  const res = await fetch(
    `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(options.fromUserId)}/sendMail`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message: {
          subject: options.subject,
          body: { contentType: "HTML", content: options.html },
          toRecipients: [
            { emailAddress: { address: options.to } },
          ],
        },
        saveToSentItems: false,
      }),
    }
  );

  if (!res.ok) {
    const detail = await res.text();
    throw new Error(`sendMail ${res.status}: ${detail}`);
  }
}

// Note: fromUserId is often the mailbox UPN or object ID, depending on how you configure send-as. Shared mailboxes and permission models are where people get tripped up (more on that below).

## Deliverability: Why Your Emails Stop Going to Junk

This was the improvement I noticed first. With SMTP, even properly configured systems can land in Junk because of sending server reputation, misaligned headers, or subtle SPF/DKIM issues that are nearly impossible to debug from the application side.

With Graph, emails are sent as part of your Microsoft 365 environment. They inherit your domain’s trust, your tenant’s configuration, and correctly formatted headers by default. That alone eliminated about 90% of the “check your Junk folder” conversations I used to have with users.

## Error Handling and Visibility

SMTP errors are vague when they exist at all. Half the time your message disappears into a queue and you never find out what happened. With Graph, you get an immediate API response with a clear error message you can actually act on. Your app can retry intelligently, log meaningful errors, and alert you when something genuinely fails—instead of silently dropping messages.

Graph errors come back as JSON with a code you can branch on—throttling, permission denied, bad recipient—instead of a bounce message that shows up in someone’s inbox three hours later:

// example response body you might log as structured data
{
  "error": {
    "code": "ErrorSendAsDenied",
    "message": "The user account which was used to submit ..."
  }
}

## Real-World Gotchas (What Bit Me)

Switching to Graph isn’t completely frictionless. Here’s what caught me off guard:

1. Permissions matter more than you think

Application vs. delegated permissions will trip you up if you’re not paying attention. If you’re sending from a shared mailbox like no-reply@yourcompany.com, you’ll need application permissions, admin consent, and the mailbox access configured correctly. Miss any one of those and you get a cryptic 403 that tells you almost nothing useful.

2. “Send As” vs. “Send on Behalf”

These are not the same, and if you get it wrong, every email your app sends will show “on behalf of” in the from line. That’s confusing for users and makes your system-generated emails look like they came from someone’s personal account. Get the send-as permissions right from the start.

3. New sending patterns can still trigger filtering

Even with Graph, if your app suddenly starts sending a new type of email, it can land in Junk until the pattern establishes itself. Consistent sending patterns, clear subject lines, and avoiding spam-like content help. This sorts itself out quickly, but it’s worth knowing about before your first deployment.

4. Rate limits exist

Graph isn’t unlimited. If your app sends high volumes—hundreds of notifications in a burst—you’ll need to batch requests, queue emails, and handle throttling responses gracefully. For most internal apps this isn’t an issue, but it’s worth building the retry logic early rather than discovering the limits in production.

## When SMTP Still Makes Sense

To be fair, SMTP isn’t dead. It still works fine for simple scripts, one-off tools, or environments that aren’t running Microsoft 365. If your app lives outside the Microsoft ecosystem and you just need to send a few emails, SMTP is probably fine.

But for business-critical applications that are already in the Microsoft ecosystem—and that describes most of the internal tools I build—it’s hard to justify staying on SMTP when Graph is available.

## My Current Approach

For every internal app I build now, I default to Microsoft Graph for email. I wrap it in a thin service layer with logging and retry logic so the rest of the application doesn’t know or care how email gets sent. Predictable, good deliverability, and I almost never get support tickets about missing notifications anymore.

// thin service boundary (shape only)
export async function notifyUserReady(userEmail: string, reportName: string) {
  await sendMailViaGraph({
    fromUserId: process.env.MAILBOX_SENDER_ID!,
    to: userEmail,
    subject: `Your report "${reportName}" is ready`,
    html: `<p>Open the app to download it.</p>`,
  });
}

## Final Thought

Email seems simple until it isn’t. When your app depends on it—approvals, notifications, status updates—reliability isn’t optional. SMTP got us here, and it served its purpose. But in 2026, if you’re already in the Microsoft ecosystem, Graph is the better path. It’s not just a technical upgrade. It’s a stability upgrade. And after years of debugging SMTP issues at midnight, I’ll take stable and boring every time.