Greater Atlanta's IT partner since 1989
Setup guide
Set up the shared mailbox, Entra app and Exchange RBAC scope AJC Graph Mailer sends through.
One-time setup in the client's Microsoft 365 tenant. Everything here happens before the plugin is configured in WordPress.
Budget about 30 minutes, plus up to two hours of waiting for permission caches to settle before the first send succeeds.
Install-Module ExchangeOnlineManagement -Scope CurrentUser
Connect-ExchangeOnline -Organization contoso.onmicrosoft.comInstall-Module Microsoft.Graph.Applications, Microsoft.Graph.Users -Scope CurrentUser
# Connect-MgGraph grants only the scopes you ask for. Being Global Admin does
# not change that -- without these, Get-MgServicePrincipal and Get-MgUser fail
# with "Insufficient privileges to complete the operation" / 403
# Authorization_RequestDenied.
Connect-MgGraph -Scopes "Application.Read.All","User.ReadWrite.All"
Get-MgContext | Select-Object -ExpandProperty Scopes # confirm they are listedSubstitute contoso.onmicrosoft.com / contoso.com throughout.
Graph app-only sending has one hard constraint: there must be a real mailbox to send through. The address recipients see is a separate question.
| You want mail to come from | Sending mailbox | From address | Works? |
|---|---|---|---|
A shared mailbox, e.g. forms@contoso.com |
that mailbox | blank | Yes — this is the supported path |
| A licensed user mailbox | that mailbox | blank | Yes, but it burns a licence for no benefit |
An alias on a mailbox, e.g. noreply@ on forms@ |
the mailbox | the alias | Sometimes — see step 7 |
| A distribution list address | not possible directly | — | No — see step 7 |
| A Microsoft 365 Group, mail contact, or B2B guest | not possible | — | No, none have a sendable mailbox |
Recommendation: give each From address its own shared mailbox. Shared mailboxes are free under 50 GB, each has its own primary SMTP address, and Graph honours that primary without any tenant-wide switches. Alias and distribution list sending both depend on tenant behaviour that Microsoft has changed before.
New-Mailbox -Shared -Name "Website Forms" -DisplayName "Contoso Website" `
-PrimarySmtpAddress forms@contoso.com -Alias forms
Verify it is what you think it is:
Get-Mailbox forms@contoso.com |
Format-List PrimarySmtpAddress, RecipientTypeDetails, ExchangeGuid, EmailAddresses
You need RecipientTypeDetails: SharedMailbox and a non-zero ExchangeGuid. An all-zero GUID means the mailbox has not finished provisioning; wait and re-check.
The mailbox display name is what recipients will see. Exchange Online ignores the display name supplied on the message and stamps the one from the directory object the From address resolves to. So set it deliberately now:
Set-Mailbox forms@contoso.com -DisplayName "Contoso Website"
Get-Mailbox forms@contoso.com | Format-List DisplayName, PrimarySmtpAddress
This is per mailbox, not per message or per alias — one mailbox can only ever send under one display name. If the site needs two different display names, it needs two shared mailboxes. The plugin's From name field does not change this and is effectively cosmetic.
Shared mailboxes are created with sign-in disabled. Confirm, because an enabled account with an unknown password is a standing risk:
Get-MgUser -UserId forms@contoso.com -Property AccountEnabled |
Select-Object DisplayName, AccountEnabled
# Expect AccountEnabled : False. If it is True:
Update-MgUser -UserId forms@contoso.com -AccountEnabled:$false
These need the User.ReadWrite.All scope from the prerequisites. In the portal instead: Entra → Identity → Users → the account → Block sign-in.
entra.microsoft.com → Identity → Applications → App registrations → New registration.
WordPress Graph Mailer — contoso.comFrom Overview, copy the Application (client) ID and the Directory (tenant) ID. Both go into the plugin.
Do not add any API permissions yet. Which permissions you grant — if any — depends on the scoping method in step 5, and getting this wrong is the difference between an app that can mail one mailbox and one that can mail the entire company.
Certificates & secrets → Client secrets → New client secret.
wordpress-forms-2026-08Copy the Value column immediately — not the Secret ID, and it is only shown once. Record the expiry date; you will enter it into the plugin so an administrator gets warned 30 days out. An expired secret stops all site mail.
By default a tenant-wide Mail.Send grant lets the application send as every mailbox in the tenant. A leaked client secret on a WordPress box would then be a tenant-wide spoofing capability. Pick one of the two methods below.
This is Microsoft's replacement for Application Access Policies and the method to use for anything new. The critical property: the app gets no Entra permission at all. Exchange itself authorises the call, and the permission never appears in the access token.
First you need the service principal's object ID.
This is not the Object ID on the App registrations page. Both pages show a field called Object ID and they are different values; the one from App registrations identifies the application object and
New-ServicePrincipalwill reject it.In the portal: Entra → Identity → Applications → Enterprise applications → your app → Object ID on the Overview.
Or from PowerShell, having connected with the scopes in the prerequisites:
$sp = Get-MgServicePrincipal -Filter "appId eq '<application-client-id>'"
$sp.Id
Then, in the Exchange Online session:
# Point Exchange at that service principal
New-ServicePrincipal -AppId <application-client-id> `
-ObjectId <service-principal-object-id> `
-DisplayName "WordPress Graph Mailer"
# A scope that resolves to exactly the one mailbox
New-ManagementScope -Name "wp-graph-mailer-scope" `
-RecipientRestrictionFilter "PrimarySmtpAddress -eq 'forms@contoso.com'"
# Grant Mail.Send, restricted to that scope
New-ManagementRoleAssignment -App <service-principal-object-id> `
-Role "Application Mail.Send" `
-CustomResourceScope "wp-graph-mailer-scope"
Verify — this bypasses the permission cache, so it answers immediately:
Test-ServicePrincipalAuthorization -Identity <application-client-id> -Resource forms@contoso.com |
Format-Table RoleName, GrantedPermissions, AllowedResourceScope, InScope
InScope must be True for Application Mail.Send. Check a mailbox that should not be reachable and confirm it comes back False.
The mistake that silently removes all scoping
Entra consent and Exchange RBAC are additive — Microsoft's own documentation is explicit that the effective permission is the union of the two. If you grant
Mail.Sendin Entra and create an RBAC scope, the Entra grant is unscoped and the app can still send as anyone in the tenant. The scope looks correct in PowerShell and does nothing.With Method A, API permissions must be empty. If someone added
Mail.Sendin Entra earlier, remove it and revoke admin consent before relying on the scope.
Microsoft has replaced this and says not to use it for new configuration, but it is still the right answer for a tenant that already standardises on it, or if Method A does not issue a usable token.
Unlike Method A, this method does require the Entra grant — the policy is what constrains it.
Then:
New-DistributionGroup -Name "wp-graph-senders" -Type Security `
-PrimarySmtpAddress wp-graph-senders@contoso.com
Add-DistributionGroupMember -Identity wp-graph-senders -Member forms@contoso.com
New-ApplicationAccessPolicy -AppId <application-client-id> `
-PolicyScopeGroupId wp-graph-senders@contoso.com `
-AccessRight RestrictAccess `
-Description "WordPress Graph Mailer sender scope"
Test-ApplicationAccessPolicy -Identity forms@contoso.com -AppId <application-client-id>
The group must be a mail-enabled security group — New-DistributionGroup -Type Security. A Microsoft 365 Group or a plain Entra security group is accepted by the cmdlet and then fails to scope anything.
Do not add openid, profile, email, offline_access or User.Read. Those are delegated scopes and have no effect on an app-only flow.
Either method takes 30 minutes to 2 hours to take effect. During that window sends fail with ErrorAccessDenied and "Access to OData is disabled." That is expected; do not start re-adding permissions to make it go away.
Mail.Send grants no read access whatsoever. The plugin's Test connection button therefore cannot look the mailbox up, and reports the directory and mailbox probes as skipped. That is the correct result for a least-privilege app — Send test is the conclusive check.
If you want Test connection to verify the mailbox exists, confirm it is not a distribution list, and check that a configured alias really is a proxy address, add these as well:
| Permission | Enables |
|---|---|
User.Read.All (Application) |
Directory lookup and the alias proxy-address check |
Mail.ReadBasic (Application) |
Confirming the mailbox is live in Exchange Online |
Both are read permissions across the tenant unless you scope them the same way as Mail.Send. On a client site, leaving them off is the defensible default.
Skip this section if the From address is the shared mailbox itself.
Graph resolves /users/{id} by object ID, userPrincipalName or primary SMTP address only. An alias will not work as the Send from mailbox. It goes in the plugin's From address field instead, which sets the from property on the message while still sending through the real mailbox.
Add the alias to the mailbox and enable alias sending for the organisation:
Set-Mailbox forms@contoso.com -EmailAddresses @{add="smtp:noreply@contoso.com"}
# Lowercase smtp: = alias. Uppercase SMTP: = primary. Do not use uppercase here
# unless you intend to change which address is primary.
Set-OrganizationConfig -SendFromAliasEnabled $true
Get-OrganizationConfig | Select-Object SendFromAliasEnabled
Verify per tenant — this is not consistent. Two A&J Computers data points:
ajc-mail-relay, 2026-04-28: Exchange rewroteFromback to the mailbox primary even withSendFromAliasEnabledset.- First WordPress deployment, 2026-08-20: the alias was honoured on the address.
So test it rather than assuming either way. Use the plugin's Send test, then look at the From address on the message as it arrived. If it shows the mailbox rather than the alias, alias sending is not available in that tenant — create a dedicated shared mailbox for the address instead.
The alias only ever changes the address. The display name still comes from the mailbox the alias sits on (confirmed 2026-08-20), so an alias cannot give you a different sender name. If the display name matters, that address needs its own shared mailbox.
A distribution list has no mailbox, so there is nothing for Graph to send through — /users/{dl} returns MailboxNotEnabledForRESTAPI no matter what permissions you grant. Move the address onto a mailbox:
# 1. Free the address from the distribution list
Set-DistributionGroup info-list@contoso.com `
-EmailAddresses @{remove="smtp:info@contoso.com"}
# ...or retire the list entirely if nothing else uses it:
# Remove-DistributionGroup -Identity info-list@contoso.com
# 2. Give the address to a shared mailbox
Set-Mailbox forms@contoso.com -EmailAddresses @{add="smtp:info@contoso.com"}
Then treat it exactly as the alias case above: put info@contoso.com in the plugin's From address field and verify what actually arrives.
If the distribution list must keep receiving mail at that address, the address cannot also be a sending identity — pick a different one, or convert the list to a shared mailbox and add the former members as delegates.
Settings → Graph Mailer in WordPress:
| Field | Value |
|---|---|
| Directory (tenant) ID | GUID from Overview, or contoso.onmicrosoft.com |
| Application (client) ID | GUID from Overview |
| Client secret | the Value from step 4 |
| Secret expires | the expiry from step 4 |
| Cloud | Commercial / GCC unless this is GCC High or DoD |
| Send from mailbox | forms@contoso.com — the mailbox, primary SMTP only |
| From address | blank, or the alias from step 7 |
| From name | blank to use the site title |
Optionally keep the secret's encryption key out of the database entirely by adding a dedicated key to wp-config.php:
define( 'AJC_GRAPH_MAILER_KEY', 'a-long-random-string-unique-to-this-site' );
Without it the key is derived from AUTH_KEY and SECURE_AUTH_KEY, which is fine — but note that rotating those salts, or changing this constant, makes the stored secret unreadable and the plugin will refuse to send until it is re-entered. See operations.md.
Mail.Send-only app; that is correct, not a failure.Test-ServicePrincipalAuthorization (or Test-ApplicationAccessPolicy) against a mailbox the site should not be able to send as, and confirm it is denied.If step 1 or 2 fails, the plugin translates the common Graph and Entra error codes into what to fix — see the troubleshooting table in operations.md.
Talk to the people who build and support it.