Greater Atlanta's IT partner since 1989

Operations guide

Operations

Rotate secrets, read failures, add a sending mailbox and recover AJC Graph Mailer.

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

Day-two runbook: rotation, failure modes and troubleshooting.

Failure behaviour

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.

Rotating the client secret

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:

  1. Entra → the app registration → Certificates & secrets → New client secret. Description wordpress-forms-<yyyy-mm>, 12 or 24 months.
  2. Copy the Value.
  3. WordPress → Settings → Graph Mailer → paste into Client secret, update Secret expires, save. Saving drops the cached access token, so the new secret is used on the very next send.
  4. Send test and confirm the message arrives.
  5. Only then, delete the old secret in Entra.

Rotating WordPress salts

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.

Which key encrypts the secret

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.

Rolling back to 1.0.x after the encryption upgrade

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.

Exchange Online limits

Per mailbox, not per site:

  • 30 messages per minute. On a 429 the plugin honours 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.
  • 10,000 recipients per day.
  • 500 recipients per message.

This is a transactional mail path — form notifications, password resets, order confirmations. It is not a bulk mail transport. A newsletter belongs somewhere else.

Attachments

  • Under 2.5 MB total, attachments ride inline on the single sendMail call. The ceiling exists because Graph caps that request at 4 MB and base64 inflates a payload by a third.
  • Above that the plugin creates a draft, attaches each file — directly under 3 MB, through a chunked upload session at or above it — then sends. If any step fails the draft is deleted, so failures do not accumulate in the mailbox.
  • Messages that go via the draft path always land in Sent Items, because that is how Graph's /send works. The Save a copy in Sent Items option only affects the direct path.

Troubleshooting

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.

Adding another sending mailbox

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.

Uninstalling

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

Questions about AJC Graph Mailer?

Talk to the people who build and support it.