Scripts: github.com/tom-daly/demos/grant-azure-function-email-access
You have an Azure Function that needs to send email. Booking confirmations, alerts, a nightly digest.
A service like SendGrid can do it, and once you’ve set up your domain’s authentication it can send as your domain too. But if you already have Microsoft 365, there’s a simpler route: send through a mailbox you already own, using Microsoft Graph. No extra service, no extra account, and replies land in a mailbox your team can open.
So you look up the permission. You find Mail.Send. You grant it. It works.
That grant is unscoped. The Function App can now send as any mailbox in the tenant, not only the one you had in mind.
The Bit Nobody Mentions#
App-only doesn’t mean narrowly scoped. It means the application operates without a signed-in user.
And broad Graph permissions really are broad. Files.Read.All gives the application access to all files represented by that permission. An Entra-granted Mail.Send application permission authorizes it to send mail as any user.
Not every application permission works this way — Graph does have scoped ones, such as Sites.Selected and Files.SelectedOperations.Selected, where you nominate the specific resources afterwards. Mail is not among them.
There’s no “just this one mailbox” option on the Entra consent screen. Mailbox scoping is handled in Exchange Online — which is why it’s so easy to miss.
There are two ways this may be configured:
- The legacy model grants
Mail.Sendin Entra and then restricts it using an Exchange application access policy. - The recommended model grants a scoped
Application Mail.Sendrole through Exchange Application RBAC, without retaining the tenant-wide Entra permission.
That last point matters: Exchange RBAC does not override or narrow an existing Entra grant. The permissions are additive. If tenant-wide Mail.Send remains in Entra, the application can still send outside the Exchange RBAC scope.
The short version: if an app holds tenant-wide Mail.Send in Entra and no Exchange application access policy restricts it, its sending is open — anything that can authenticate as that app can send as any mailbox in the tenant. A scoped Exchange RBAC assignment does not close that while the broad Entra grant remains.
What You’re Building#
Three pieces. The first two take about a minute each; the third is where the work is.
- A shared mailbox —
notifications@yourdomain.com. Usually no licence, nobody signs in, and mail from it is properly yours: your domain, your reputation. Two caveats: a shared mailbox does need a licence once it passes 50 GB, or if you want a bigger archive, put it on litigation hold, or apply certain retention features. And DKIM signing isn’t automatic on a custom domain — switch it on for the domain, or your mail is signed asonmicrosoft.com. - A managed identity on the Function App — one toggle in the portal. An identity with no password. No application secret to store or rotate.
- A permission that covers only that mailbox — the rest of this post.
Two Ways to Scope It#
| Where the permission lives | Ever tenant-wide? | Status | |
|---|---|---|---|
| RBAC for Applications | Exchange Online only | No | Current guidance |
| Application access policy | Entra ID, restricted afterwards by Exchange | Yes, for a while | Legacy |
Side by side, the difference is where the permission is created — and therefore whether a tenant-wide grant ever exists at all.
The Current Way: RBAC for Applications#
Exchange has its own role-based access control for applications. Nothing is granted in Entra ID. You register the app inside Exchange, describe which mailboxes it may touch, and give it a role limited to those.
Three commands. Two IDs come from the Function App’s managed identity: $ObjectId is its service principal object ID (the “Object (principal) ID” on the Function App’s Identity page), and $AppId is its application (client) ID, shown on the matching entry under Enterprise applications in Entra.
# 1. Tell Exchange this app exists.
New-ServicePrincipal -AppId $AppId -ObjectId $ObjectId -DisplayName "Notifications sender"
# 2. Describe which mailboxes are in play. Here: one.
New-ManagementScope -Name "Notifications mailbox" -RecipientRestrictionFilter "Alias -eq 'notifications'"
# 3. Grant Mail.Send, already limited to that scope.
New-ManagementRoleAssignment -App $AppId -Role "Application Mail.Send" -CustomResourceScope "Notifications mailbox"Then prove it took:
Test-ServicePrincipalAuthorization -Identity $AppId -Resource notifications@contoso.comRoleName InScope AllowedResourceScope
-------- ------- --------------------
Application Mail.Send True Notifications mailboxThat tells you the Exchange side is right. It does not tell you what the app can actually do. It reads Exchange’s own roles and nothing else, and it evaluates them there and then rather than through the token and caching a real request goes through. An old Mail.Send sitting in Entra wouldn’t appear here at all.
So check the other place too:
# Expect nothing back.
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$role = ($graphSp.AppRoles | Where-Object Value -eq "Mail.Send").Id
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ObjectId -All |
Where-Object { $_.AppRoleId -eq $role }Then check it for real: get a token as the app and call Graph twice — once from the mailbox you scoped to, which should send, and once from a mailbox you didn’t, which should come back ErrorAccessDenied. Only the second call proves the boundary is actually there.
Give it time before you trust that result. Microsoft says Exchange caches app permissions for anywhere from 30 minutes to two hours, and the test cmdlet above skips that cache. So a real send made straight after setup can still reflect the old permissions. If the out-of-scope send succeeds in the first couple of hours, wait and try again before concluding anything.
Three commands and two checks. My full script is about 200, and nearly all the difference is the four things below.
Global Administrator wasn’t enough for me#
This cost me an afternoon.
Assigning an application role in Exchange needs what Exchange calls a delegating assignment for that role. Microsoft puts it on the Organization Management role group. On paper, Global Administrator and Exchange Administrator in Entra both map to that group, and Microsoft’s article names Exchange Administrator as the Entra role you need.
In my tenant it didn’t work out that way. As Global Admin I got through every other step. Service principal, fine. Scope, fine. Then the last command refused, and the error talked about delegating role assignments — which doesn’t obviously mean “join a group”. What fixed it was adding my account to Organization Management directly:
Connect-ExchangeOnline -UserPrincipalName you@contoso.com
Add-RoleGroupMember -Identity 'Organization Management' -Member you@contoso.com
Disconnect-ExchangeOnline -Confirm:$false
Connect-ExchangeOnline -UserPrincipalName you@contoso.comConnecting twice isn’t a typo. An open Exchange session won’t see the new membership. Give it a few minutes, too.
Joining that group is the quick way through. If handing someone Organization Management is more than you want, a custom Exchange role group carrying the delegating assignment does the same job with less attached to it.
My script checks for the permission before creating anything, so you find out in the first two seconds rather than the last.
Use Alias, not the email address#
Look at the scope filter again:
-RecipientRestrictionFilter "Alias -eq 'notifications'"The obvious version is PrimarySmtpAddress -eq 'notifications@contoso.com'. Microsoft’s own docs say don’t. A filter on the primary address also searches every proxy address on the recipient. So if the address you typed turns out to be a secondary address on some other mailbox, that other mailbox is the one your scope covers.
It quietly moves a security boundary and never shows you an error. Filter on the alias, then check the scope picks up exactly the mailbox you meant and nothing else:
Get-Recipient -RecipientPreviewFilter (Get-ManagementScope "Notifications mailbox").RecipientFilterExchange fails without failing#
This one isn’t specific to mail, and it’s the one I’d watch for.
In my runs, some Exchange Online cmdlets reported a refusal as a non-terminating error and carried on, even with $ErrorActionPreference = "Stop" set at the top of the script. Normally that setting turns these into hard stops. My best guess is that the Exchange cmdlets live inside a module, and a preference set in your script doesn’t always reach in there. Whatever the cause, the refusal printed in red, PowerShell carried on, and the script reached the end and printed my success message.
You get a green tick for something that didn’t happen.
So don’t trust the command — check afterwards. Below, the command is deliberately allowed to report and carry on, and the check decides whether it worked. The check looks for the right role and the right scope, so an unrelated assignment on the same scope doesn’t count:
$assigned = {
Get-ManagementRoleAssignment -RoleAssignee $AppId -ErrorAction SilentlyContinue |
Where-Object { $_.Role -eq "Application Mail.Send" -and $_.CustomResourceScope -eq $Scope }
}
New-ManagementRoleAssignment -App $AppId -Role "Application Mail.Send" `
-CustomResourceScope $Scope -ErrorAction Continue | Out-Null
if (-not (& $assigned)) {
# It didn't take. Say so, loudly.
}Every step in both scripts is checked after it runs, because whether the permission actually got created isn’t something to take on trust.
Connect-ExchangeOnline may not connect where you asked#
It can reuse a cached session belonging to a different tenant. If you work across several — consultants, this is you — you can run a script aimed at one customer and create objects in another.
That happened to me.
With more than one connection open, it isn’t obvious which one your commands will use, so the safe answer is to insist on exactly one, in the right tenant:
$ci = @(Get-ConnectionInformation | Where-Object State -eq 'Connected')
if ($ci.Count -ne 1 -or $ci[0].TenantId -ne $Tenant) {
# Stop. Nothing has been created yet.
}Both scripts take the tenant ID as a required parameter and refuse to move until the session matches.
One smaller thing: Exchange won’t create the role assignment until the tenant has been customized. Most were, years ago, as a side effect of something else. If not, you need Enable-OrganizationCustomization. It’s harmless but irreversible, so my script names the command instead of running it for you.
The Older Way: Application Access Policies#
If you’ve done this before, you did it this way: grant Mail.Send in Entra, then restrict it with an Exchange application access policy. Microsoft now marks it legacy, but it works and plenty of tenants run on it.
It’s still worth understanding, because the order you do it in is a security decision.
Between granting Mail.Send and creating the policy, the app’s sending is wide open. Grant first, then discover you’re not an Exchange admin — or your session drops, or you get pulled into a meeting — and that window stays open as long as it stays open.
So: policy first, permission last. And check you’re allowed to create the policy before granting anything.
# The restriction, first.
if (-not (Get-Command New-ApplicationAccessPolicy -ErrorAction SilentlyContinue)) {
throw "Not an Exchange administrator - stopping before anything is granted."
}
New-ApplicationAccessPolicy -AppId $miSp.AppId `
-PolicyScopeGroupId $grp.PrimarySmtpAddress `
-AccessRight RestrictAccess `
-Description "Restrict mail to the service mailbox"
# The grant, last.
Add-GraphAppRole "Mail.Send"Order alone isn’t the whole story, though. A new application access policy isn’t in force the moment the cmdlet returns — Microsoft says it can take more than an hour to affect Graph requests. Grant Mail.Send straight after creating the policy, as the snippet above does, and the app can hold an unrestricted grant for that long before Exchange starts refusing anything.
So create the policy, give it time, then grant. Once it’s granted, test a mailbox outside the group and confirm it’s refused.
If you’d rather not depend on timing at all, use RBAC for Applications instead, where no tenant-wide grant is ever created in the first place.
For a shared mailbox, use a mail-enabled security group#
You can point a policy at an ordinary user mailbox, but not at a shared one. Shared mailboxes don’t count as security principals, so you get:
The identity of the policy scope is not a security principal.So you make a mail-enabled security group whose only member is the mailbox:
New-DistributionGroup -Name "notifications-scope" -Type Security `
-Members notifications@contoso.com `
-Notes "Application access policy scope"A group of one feels like overkill, but that’s the shape the cmdlet takes. It also means the restriction is only as tight as the group’s membership — anyone who can add a member widens what your app may send as. Worth deciding who owns it.
Real requests lag behind the test#
Create the policy, test a mailbox that should be blocked, and it may still say Granted. Or the test may look right while real Graph requests still behave the old way — Microsoft says those can lag by more than an hour, even when the test cmdlet shows the result you expect.
So don’t build any fixed wait into your expectations. My script checks the service mailbox is allowed, then prints a note telling you to come back later and confirm, with a real send, that a mailbox outside the group is refused. It doesn’t treat the policy as in force until something has actually been refused.
Don’t Leave Both in Place#
This is the additive behaviour from the top of the post, and it’s where migrations come unstuck. Set up Exchange RBAC on an app that still holds a tenant-wide Mail.Send grant from years ago, and your careful one-mailbox scope changes nothing — the old grant leaves sending open across the tenant.
Running both side by side for a short time is fine. Microsoft’s own migration steps do exactly that. The mistake is stopping there. Once the RBAC assignment is in place, remove the old Entra grant, then remove the old access policy.
Find the Entra grant and remove it:
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$role = ($graphSp.AppRoles | Where-Object Value -eq "Mail.Send").Id
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $miObjectId -All |
Where-Object { $_.AppRoleId -eq $role } |
ForEach-Object {
Remove-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $miObjectId -AppRoleAssignmentId $_.Id
}The Easy Part: Actually Sending#
After all that, the app code is unremarkable — no secret, no connection string, no client ID and password sitting in configuration.
const mailbox = process.env.EMAIL_FROM_MAILBOX; // notifications@contoso.com
if (!mailbox) {
context.log('[email] skipped - EMAIL_FROM_MAILBOX is not set');
return;
}
const credential = new DefaultAzureCredential();
const token = await credential.getToken('https://graph.microsoft.com/.default');
const response = await fetch(
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mailbox)}/sendMail`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
subject: 'Your booking is confirmed',
body: { contentType: 'HTML', content: html },
toRecipients: [{ emailAddress: { address: 'guest@example.com' } }],
},
saveToSentItems: false,
}),
},
);
if (!response.ok) {
throw new Error(`[email] Graph refused the send: ${response.status} ${await response.text()}`);
}Three things in there are deliberate.
The response is checked. fetch doesn’t throw when Graph says no — a 403 because the scope isn’t right, or a 429 because you’re being throttled, comes back as an ordinary response with ok: false. Ignore it and the app carries on and logs a successful send for a message that never went. Note also that a good send returns 202 Accepted, which means Graph has taken the message, not that anybody received it — bounces turn up later in the mailbox and in message trace.
saveToSentItems: false. The mailbox belongs to the app, not a person. A copy of every notification ever sent piling up in Sent Items is a mailbox somebody has to empty. Log enough to answer “did we send it, and to whom” — recipient, subject, time, what Graph said — and leave the message body out unless you’ve decided it’s safe to keep.
The mailbox address is a setting, not a constant. I leave it empty on purpose in early deployments. That’s the check at the top of the sample: while the setting is empty, every send is skipped and logs why — so the whole path can be built, deployed and watched working before the mailbox exists or IT has run the script. The day it’s created, one setting turns sending on. No deployment.
On the project this came from, the client’s IT team ran the permission script weeks after the code was finished, and turning it on was one setting.
Where That Leaves You#
An Entra-granted Mail.Send covers every mailbox. Scoping happens in Exchange, not Entra. Grant the scoped role through Exchange RBAC and no tenant-wide grant is ever created — but only if you don’t also leave one sitting in Entra, because the two add together rather than the narrower one winning.
Both scripts are at github.com/tom-daly/demos/grant-azure-function-email-access — the RBAC one, the legacy one, and the TypeScript sending code. Most of their length is checks and error messages rather than the three commands that do the work, because they get handed to a client’s IT team to run on a tenant I’m not sitting in front of.
If you have apps in your tenant holding Mail.Send, check both places: the Entra app-role assignments, and whatever Exchange has scoped. A scope in one doesn’t undo a grant in the other.




