Greater Atlanta's IT partner since 1989

Setup guide

Entra app + Exchange Online setup

Set up the shared mailbox, Entra app and Exchange RBAC scope AJC Graph Mailer sends through.

Version 1.2.2 · WordPress 5.8+ · PHP 7.4+ · Licensed GPL-2.0-or-later

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.

Prerequisites

  • Global Administrator or Exchange Administrator in the client tenant. Creating the RBAC role assignment additionally requires membership of the Organization Management role group in Exchange Online.
  • PowerShell with the Exchange Online module:
    Install-Module ExchangeOnlineManagement -Scope CurrentUser
    Connect-ExchangeOnline -Organization contoso.onmicrosoft.com
  • Optionally the Graph module. Everything it is used for below can also be done in the Entra portal, so skip this if you would rather click: The first connection raises a consent prompt; as Global Admin, tick Consent on behalf of your organization. If you use PIM, activate the Global Administrator role first — an eligible but inactive role produces the same 403.
    Install-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 listed

Substitute contoso.onmicrosoft.com / contoso.com throughout.


1. Decide what the site should send as

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.


2. Create the shared mailbox

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.


3. Register the Entra application

entra.microsoft.com → Identity → Applications → App registrations → New registration.

  • Name: WordPress Graph Mailer — contoso.com
  • Supported account types: Accounts in this organizational directory only (single tenant)
  • Redirect URI: leave blank. Client credentials never redirects.

From 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.


4. Create a client secret

Certificates & secrets → Client secrets → New client secret.

  • Description: wordpress-forms-2026-08
  • Expires: 12 or 24 months.

Copy 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.


5. Scope the app to just this mailbox

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-ServicePrincipal will 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.Send in 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.Send in Entra earlier, remove it and revoke admin consent before relying on the scope.

Method B — Application Access Policy (legacy)

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.

  1. API permissions → Add a permission → Microsoft Graph → Application permissions (not Delegated) → Mail.Send → Add.
  2. Grant admin consent for &lt;tenant&gt;. The status column must read Granted.

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.

Propagation

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.


6. Optional: permissions for deeper diagnostics

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.


7. Sending as an alias or a distribution list address

Skip this section if the From address is the shared mailbox itself.

Alias (proxy address)

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 rewrote From back to the mailbox primary even with SendFromAliasEnabled set.
  • 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.

Distribution list address

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.


8. Configure the plugin

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.


9. Verify

  1. Test connection — must report a token. Directory and mailbox probes read skipped on a Mail.Send-only app; that is correct, not a failure.
  2. Send test — the conclusive check. Confirm the message arrives.
  3. Check the From address on the received message, especially if you configured an alias.
  4. Trigger a real site notification — a contact form submission, a password reset — and confirm it arrives.
  5. Re-run 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.

Questions about AJC Graph Mailer?

Talk to the people who build and support it.