Greater Atlanta's IT partner since 1989
Operations guide
Rotate secrets, read failures, add a sending mailbox and recover AJC Graph Mailer.
Day-two runbook: rotation, failure modes and troubleshooting.
The plugin has three states. Which one it is in decides what happens to a message.
| State | When | Behaviour |
|---|---|---|
| Unconfigured | No client secret has ever been saved | pre_wp_mail returns null; WordPress sends however it did before. A half-finished install cannot lock an administrator out of password resets. |
| Ready | Tenant ID, client ID, a readable secret and a mailbox are all present | Mail goes through Graph. |
| Broken | A secret is stored, but something needed to send is missing or unreadable | Fails closed. wp_mail() returns false, wp_mail_failed fires, the reason is logged, and every administrator sees a site-wide notice. |
Failing closed is deliberate. The alternative — falling back to PHP mail() — sends unauthenticated mail from the web server's IP with no SPF, DKIM or DMARC alignment. It usually looks like it is working while landing in spam, and nobody finds out for weeks.
Entra client secrets expire. When one does, all site mail stops. Enter the expiry date on the settings screen so administrators are warned 30 days out.
Rotate with no downtime — Entra allows two live secrets on one app registration:
wordpress-forms-<yyyy-mm>, 12 or 24 months.The client secret is encrypted at rest with a key derived from AUTH_KEY and SECURE_AUTH_KEY — or from AJC_GRAPH_MAILER_KEY if that constant is defined.
Rotating those salts makes the stored secret unreadable. This matters because rotating salts is a standard incident-response step, so it tends to happen exactly when nobody is thinking about mail.
The plugin detects this, refuses to send, and says so in an admin notice rather than quietly reverting to PHP mail(). The fix is to paste the client secret in again. If the secret was not recorded anywhere, generate a new one in Entra and follow the rotation steps above.
Defining AJC_GRAPH_MAILER_KEY decouples the plugin from the WordPress salts, so future salt rotations do not break mail. Changing that constant has the same effect as rotating the salts.
The settings screen reports one of three tiers. They are all per-site — no two installs share a key — but they differ in what breaks them.
| Tier | Key material | Survives salt rotation? | In the database? |
|---|---|---|---|
AJC_GRAPH_MAILER_KEY |
the constant in wp-config.php |
yes | no |
| WordPress salts (default) | AUTH_KEY + SECURE_AUTH_KEY |
no | no |
| Database fallback | random, in an option | yes | yes |
The salts tier is the default and it is fragile in one specific way: migration plugins, host-side staging clones and security-plugin hardening all rotate salts, and rotating them makes the stored secret unreadable. Mail then stops, fails closed, and the secret has to be re-entered.
To pin a key that survives all of that, paste the generated line the settings screen offers into wp-config.php above the "stop editing" comment, then re-enter the client secret once. The plugin deliberately does not write that file itself — it is root-owned, generated or immutable often enough that a failed write takes the whole site down rather than just the mailer.
A consequence worth deciding on deliberately: a dedicated key travels with wp-config.php. Clone production to staging with that file and staging can send live mail as the tenant, where the salts default happens to block it by accident.
Do not simply reinstall the old zip. Once the secret has been re-encrypted, it is stored with an ajcgm2: prefix, and 1.0.x only recognises ajcgm1:. Its narrower check falls through to the plaintext passthrough and hands the raw base64 payload to Entra as the client secret, which fails with AADSTS7000215 and no admin notice explaining why.
The migration keeps the superseded ciphertext for exactly this case. Restore it before downgrading:
-- Inspect first. Both values should be present, one ajcgm1: and one ajcgm2:.
SELECT option_value FROM wp_options WHERE option_name = 'ajc_graph_mailer_settings';
Then, with the site's own tooling (WP-CLI is safest, since the option holds a serialised array):
wp option patch update ajc_graph_mailer_settings client_secret \
"$(wp option pluck ajc_graph_mailer_settings client_secret_legacy)"
wp option patch update ajc_graph_mailer_settings crypto_version 0
Then install the 1.0.x zip. If client_secret_legacy is empty the site was never migrated, and downgrading is safe as-is.
If neither applies, the recovery is to re-enter the client secret from your password manager — which is why the entry for it must exist. Entra never shows a secret Value again after creation. Confirm it is recorded before starting a rollout, not during a rollback.
Per mailbox, not per site:
Retry-After if the wait is 10 seconds or less (60 on cron and WP-CLI, where no one is waiting on a page load) and retries once. Longer than that and the message fails with an explanation rather than hanging the request.This is a transactional mail path — form notifications, password resets, order confirmations. It is not a bulk mail transport. A newsletter belongs somewhere else.
sendMail call. The ceiling exists because Graph caps that request at 4 MB and base64 inflates a payload by a third./send works. The Save a copy in Sent Items option only affects the direct path.| Error | Meaning and fix |
|---|---|
MailboxNotEnabledForRESTAPI |
The address has no live mailbox — a distribution list, mail-enabled security group, Microsoft 365 Group, mail contact, B2B guest, on-premises (hybrid) mailbox, or a soft-deleted one. Check with Get-Mailbox: you want RecipientTypeDetails = SharedMailbox or UserMailbox and a non-zero ExchangeGuid. To send from a distribution list address, move that address onto a shared mailbox — see entra-app-setup.md step 7. |
ErrorAccessDenied — "Access to OData is disabled" |
The app is not scoped to this mailbox. Check the RBAC management scope with Test-ServicePrincipalAuthorization, or the Application Access Policy with Test-ApplicationAccessPolicy. Allow 30 minutes to 2 hours after any change. |
ErrorAccessDenied — "Access is denied" |
Under Method B, Mail.Send is missing, was added as Delegated instead of Application, or admin consent was never granted. Under Method A, the role assignment is missing or names the wrong service principal object ID. |
ErrorSendAsDenied |
The From address is not a proxy address on the sending mailbox, or the tenant will not honour alias sending. Add it with Set-Mailbox -EmailAddresses @{add="smtp:..."}, confirm SendFromAliasEnabled, or give the address its own shared mailbox. |
ResourceNotFound / ErrorInvalidUser |
No object matches that address in this tenant. Often an alias in the Send from mailbox field — that field takes the primary SMTP address only. Otherwise the tenant ID points at the wrong tenant. |
AADSTS7000215 |
Wrong client secret — usually the Secret ID was pasted instead of the Value. |
AADSTS7000222 |
The client secret has expired. Rotate it. |
AADSTS90002 |
Tenant not found. Check the tenant ID. |
AADSTS700016 |
The application is not in this tenant. Check the client ID, and that the app registration lives in the client's tenant. |
| HTTP 429 | Exchange Online is throttling the mailbox. See the limits above. |
| From address is wrong on received mail | Exchange rewrote it. Expected when sending as an alias without SendFromAliasEnabled, and observed in some tenants even with it. Use a dedicated shared mailbox instead. |
| Sender display name is wrong | Not fixable from the plugin. Exchange ignores the name on the message and uses the display name of the directory object the From address resolves to. Run Set-Mailbox <mailbox> -DisplayName "...", or give the address its own shared mailbox. One mailbox sends under exactly one display name. |
ErrorAccessDenied right after adding a second sending mailbox |
The RBAC management scope still resolves to the old mailbox only. See "Adding another sending mailbox" below. |
The settings screen keeps the last 50 failures with the resolved explanation. Log context is redacted at every nesting level, so secrets and tokens never reach the database.
Because the display name is a mailbox property, a site that needs a second sender name needs a second shared mailbox. The step people forget is the RBAC scope — a new mailbox outside it fails every send with ErrorAccessDenied.
A single-mailbox filter has to be edited each time:
Set-ManagementScope -Identity "wp-graph-mailer-scope" `
-RecipientRestrictionFilter "PrimarySmtpAddress -eq 'forms@contoso.com' -or PrimarySmtpAddress -eq 'news@contoso.com'"
If you expect this to keep happening, tag the mailboxes instead and scope on the tag, so adding one later is a single Set-Mailbox and no RBAC change:
Set-Mailbox forms@contoso.com -CustomAttribute1 "wp-graph-mailer"
Set-Mailbox news@contoso.com -CustomAttribute1 "wp-graph-mailer"
Set-ManagementScope -Identity "wp-graph-mailer-scope" `
-RecipientRestrictionFilter "CustomAttribute1 -eq 'wp-graph-mailer'"
Either way, confirm before touching WordPress — this bypasses the permission cache and answers immediately:
Test-ServicePrincipalAuthorization -Identity <application-client-id> -Resource news@contoso.com |
Format-Table RoleName, AllowedResourceScope, InScope
Note that a WordPress site sends through one mailbox. Two display names means either two sites, or the ajc_graph_mailer_from_address filter plus matching proxy addresses — which changes the address but not the name.
Deactivating drops the cached access token and returns wp_mail() to WordPress's default transport. Deleting the plugin removes every option and transient it created, on every site of a multisite network.
Deleting the plugin does not touch the tenant. Remove the app registration, the RBAC role assignment or access policy, and the shared mailbox separately if the site is being decommissioned:
Remove-ManagementRoleAssignment -Identity "<assignment name>"
Remove-ManagementScope -Identity "wp-graph-mailer-scope"
Remove-ServicePrincipal -Identity <application-client-id>
# then delete the app registration in Entra
Talk to the people who build and support it.