Legacy modernization
Database Mail Is a Dead End
For twenty years, Database Mail was the right answer. A job fails at 2am, sp_send_dbmail tells someone. A permit gets approved, the stored procedure emails the applicant. No service to deploy, no library to reference, no infrastructure. It lived on the box and it worked.
It stopped working because of something that has nothing to do with SQL Server.
The thing that actually broke
Database Mail speaks SMTP with basic authentication. A username and a password, stored in msdb, sent to a relay.
Microsoft has retired SMTP AUTH client submission in Exchange Online. If your mail is in Exchange Online, that path is closed. If you’re still on-prem, you have time rather than a reprieve — everything is moving the same direction.
And here’s what makes this different from most migrations: there is no Database Mail fix. It has no OAuth support and it isn’t getting any. You cannot configure your way out. The component is a dead end, and the only question is what replaces it.
The tempting wrong answer
The fastest fix is an unauthenticated internal SMTP relay. Point Database Mail at it, let it forward to Microsoft, done in an afternoon.
I understand the appeal, and it’s worth being clear about what it is: you’ve built an open relay inside your network so that a legacy component can keep using an authentication scheme that was retired for being unsafe. Anything that can reach port 25 can now send mail as your organization. In a government environment, that’s a finding waiting to happen.
If you genuinely cannot change the calling application, Microsoft’s High Volume Email is the supported version of that idea, and a better place to stand than a relay you built yourself. But it’s a holding position, not a destination.
What Graph actually asks of you
The real replacement is the Microsoft Graph API. The shape of it: register an application in Entra ID, grant it the Mail.Send application permission — not the delegated one, because no user is signed in when a SQL Agent job fires at 2am — get a token, POST to /users/{id}/sendMail.
That’s the tutorial. Here’s the part the tutorial skips.
The permission you just granted is tenant-wide
Application-scoped Mail.Send does not mean “send as the notifications mailbox.” It means send as any mailbox in the tenant. Your service account. Your director. The mayor.
Nothing in the app registration screen tells you this, and nothing stops it. The control is an application access policy, configured in Exchange Online PowerShell, scoping the app to a mail-enabled security group containing only the mailboxes it should send from.
Create the policy before the app ever gets a token, not after. Once an application identity with unrestricted send exists in your tenant, you’re relying on nobody discovering it, which is not a control.
Then stop using a client secret
A client secret is a password with an expiry date, and you now need somewhere to keep it. Wherever you put it, that’s the new weakest point in the system.
A certificate is better. Better still, if the workload runs outside Azure — and mine run on AWS — is workload identity federation: the compute gets a token from its own platform, Entra trusts that token, and there’s no long-lived credential anywhere. It takes an afternoon and removes an entire category of incident.
Where the send actually lives now
This is the real architectural change, and the one worth thinking about rather than rushing.
Database Mail was called from inside a transaction, in T-SQL, and queued the message itself. Graph is an HTTP call to a service outside your network. Making that call synchronously from a stored procedure means your database transaction now depends on a third party’s availability, and a network blip becomes a failed business operation.
So use an outbox. The database writes a row and commits. A worker service reads the table, calls Graph, and marks the row sent or failed with a retry count.
The caller keeps what made Database Mail good — one statement, fire and forget, no knowledge of how mail works. What you gain is everything it never had: exponential backoff, a dead letter path, and a table you can query when someone asks why the notification didn’t arrive.
Four details that will bite you
Attachments over roughly 3 MB won’t go in a sendMail request. Above that you create a draft message and use an upload session — a different code path you have to write before someone attaches a large PDF, not after.
saveToSentItems defaults to true. Every automated notification lands in a real mailbox’s Sent Items. For a system sending thousands of messages, set it false unless somebody asked for the copy.
Graph throttles, per mailbox and per application, and returns 429 with a Retry-After header. Honor it. A retry loop that ignores Retry-After extends your throttling rather than escaping it, and this only surfaces on the day you send a large batch.
Failures are now yours to see. Database Mail wrote its failures to msdb whether you looked or not. A Graph call that fails inside a worker service is invisible unless you logged it. Build the logging into the first version.
What you lose
Being honest about this makes the plan more credible, not less.
You lose sp_send_dbmail from inside a stored procedure. You lose sysmail_faileditems as a free answer to “did it send.” And you gain a service that has to be deployed, monitored, and kept alive — where before you had a feature that came with the database.
That’s a real trade. It’s still the right one, because the alternative is a component that can no longer authenticate to anything and will not be fixed.
Start with the policy
If you’re doing this, do it in this order: application access policy first, credentials second, outbox third, then the code.
Most teams do it backwards. They get sendMail working in a console app in an hour, feel good about it, and ship an application identity that can send as anyone in the organization.
The code was never the hard part.
Found this useful? Share it.
A ready-made LinkedIn post is one click away, or share the link wherever your team talks.
For twenty years, Database Mail was the right answer. A job fails at 2am, sp_send_dbmail tells someone. No service, no library, no infrastructure. It stopped working for a reason that has nothing to do with SQL Server: Exchange Online retired SMTP AUTH, and Database Mail has no OAuth support and is not getting any. You cannot configure your way out. What replacing it with Microsoft Graph actually involves, beyond the tutorial: • Application-scoped Mail.Send can send as ANY mailbox in the tenant. Your director. The mayor. Scope it with an application access policy before the app ever gets a token. • Stop using a client secret. A certificate, or workload identity federation if you run outside Azure. • Do not call Graph from inside a database transaction. Write an outbox row, commit, and let a worker send with backoff and a dead-letter path. • Four details that bite: the 3 MB attachment limit, saveToSentItems defaulting to true, 429 with Retry-After, and failures that are now yours to log. Policy first, credentials second, outbox third, then the code. The code was never the hard part. {url}