Skip to content

Mailbox Authentication

Ed Mozley edited this page Aug 15, 2026 · 8 revisions

Mailbox Authentication

How FreeITSM connects to a Microsoft 365 (or Google Workspace) mailbox to read inbound email into tickets and send replies β€” the two authentication modes, when to use each, the safeguards that make sure you're reading the right inbox, and a plain-English primer on OAuth scopes and permissions.

Configure all of this under Tickets β†’ Settings β†’ Mailboxes.


The two modes (Microsoft 365)

A Microsoft mailbox can authenticate in one of two ways. You choose with the Authentication dropdown in the mailbox modal.

Delegated (sign in as the mailbox) App-only (client credentials)
Who is FreeITSM acting as? A user β€” whoever signed in The application itself (no user)
How you connect You click Authenticate and sign in through Microsoft once No sign-in β€” the app uses its own client ID + secret
Which inbox it reads The signed-in user's inbox (Graph /me) The target mailbox you typed (Graph /users/{address})
Azure permission type Delegated permissions Application permissions
Needs admin consent? Usually not (a user can consent for themselves) Yes β€” an admin must grant consent
Token lifetime Short-lived + refresh token (stays signed in) Short-lived, re-fetched automatically (no refresh token)
Survives the person leaving? ❌ If you signed in as a person whose account is later disabled, it breaks βœ… Not tied to any person

When to use which

Use Delegated when:

  • You're setting up quickly and the mailbox account credentials are to hand.
  • The target mailbox is an account you can sign in as (a real licensed mailbox with a password / MFA you control).
  • You don't have a Global Admin handy to grant application consent.

Use App-only when:

  • The target mailbox is a shared mailbox or a service mailbox that nobody "logs in as".
  • You don't want the connection tied to a specific person's sign-in (no breakage when staff leave).
  • You want it to be impossible to accidentally read the wrong inbox β€” app-only always reads exactly the address you configured, regardless of who's signed into Microsoft on the browser.
  • You're happy to get a Global Admin to grant consent once.

Rule of thumb: for a long-lived, hands-off, "set it and forget it" service desk mailbox, app-only is the cleaner choice. For a quick start, delegated is fine β€” just make sure you sign in as the mailbox, not as yourself.


"Reading from the right inbox" safeguards (delegated)

Delegated mode has a sharp edge: the token belongs to whoever signed in, and it reads their inbox via /me. If you sign in as the wrong account β€” or if you sign in as support@, then later change the mailbox's target to helpdesk@ without re-authenticating β€” FreeITSM would happily keep reading the original account's mail while the label says something else. (This was the subject of GitHub issue #26.)

FreeITSM guards against that:

  1. It records who actually signed in β€” and every address that account owns. On authentication, FreeITSM captures the signed-in account's full set of addresses: the primary SMTP, the UPN, and all aliases (read from Graph proxyAddresses). The primary is shown in the UI; the whole set is stored for matching.
  2. It checks before every read and send. If the configured target mailbox isn't any of the signed-in account's addresses, the operation is blocked with a clear message rather than silently reading the wrong inbox. Because the whole alias set is checked, pointing a mailbox at an alias (e.g. ed@ on the edmozley@ mailbox) works fine β€” but a genuinely different mailbox (e.g. support@ while signed in as edmozley@) is correctly caught.
  3. Changing the address invalidates the sign-in. Edit a mailbox's target address (or switch its auth mode) and the stored identity is cleared, forcing a fresh sign-in. A stale token can't keep reading the old inbox.
  4. The list shows you the truth. Each mailbox row carries a plain-language status:
Badge Meaning
βœ“ Reading from addr Signed-in account matches the target β€” all good
App-only Reads the target directly via client credentials
Unverified Authenticated, but we haven't confirmed the account yet (e.g. authenticated under an older version) β€” clears itself on the next email check
⚠ Wrong account The token belongs to a different account than the target β€” blocked until you re-authenticate or switch to app-only

"Unverified" is harmless and self-healing. It just means authenticated_as hasn't been populated yet. Click the Check emails (envelope) icon once; the identity is back-filled from the token and the badge settles to green βœ“ or red ⚠.


For developers: the auth mode changes three things, not one

Anything that talks to Graph on a mailbox's behalf has to branch on auth_mode, and it is easy to remember only the first of these. Missing the other two is what caused issue #67 β€” written up in full, including a plain-English explanation of /me versus /users/..., in Why app-only mailboxes could not send email.

Delegated App-only
Endpoint /me/... β€” Graph reads the user out of the token /users/<target_mailbox>/... β€” there is no user, so /me is a 400
Token Refreshed with the stored refresh_token No refresh token exists. Re-minted from the client secret (mailboxAppOnlyToken())
"Is it usable?" Has a stored token_data Has tenant/client/secret/target. token_data is empty until the first mint, and testing for it hides a working mailbox

Use the shared helpers rather than re-deriving any of this:

Helper File Gives you
mailboxIsAppOnly($mailbox) includes/mailbox_graph.php The branch itself (Microsoft + auth_mode = app_only)
mailboxResolveGraphBase($mailbox) includes/mailbox_graph.php /me or /users/<addr>
mailboxCanSend($mailbox) includes/mailbox_graph.php Whether a mailbox can send right now, per the third row above
templateGraphContext($conn, $mailbox) includes/template_email.php ['token' => …, 'base' => …] β€” both halves together, which is the point

⚠️ mailboxGraphBase() caches the base in a static that defaults to /me. A caller that forgets to resolve silently gets /me, and a loop over several mailboxes keeps the previous one's value. On send paths, take the base from templateGraphContext() and pass it explicitly β€” templateSendViaGraph() requires it as a third argument for exactly this reason, and deliberately has no default.


Scopes & permissions 101 (for dummies)

If "scopes", "delegated vs application permissions", and "admin consent" make your eyes glaze over, read this once and it'll click.

What's a scope?

A scope (a.k.a. a permission) is a single capability you're asking Microsoft for, like Mail.Read ("read mail") or Mail.Send ("send mail as"). When FreeITSM connects, it asks for a list of scopes; Microsoft issues a token that is stamped with exactly those capabilities and nothing more. The token is like a backstage pass that lists which doors it opens.

FreeITSM asks for: Mail.Read, Mail.ReadWrite, Mail.Send, the lightweight User.Read, plus openid, email, offline_access (the last three are sign-in plumbing β€” identity + "keep me signed in").

Delegated permission vs Application permission

This is the part everyone trips on. Same-sounding permission, two completely different flavours:

  • Delegated permission = "the app, acting on behalf of a signed-in user, may do X to the things that user can already reach." Mail.Read delegated means "read the mail of whoever signed in." There's always a human in the loop at sign-in time.
  • Application permission = "the app, acting as itself with no user, may do X." Mail.ReadWrite application means "read/write mail in mailboxes the app is allowed to" β€” no human, no sign-in. This is what app-only mode uses.

The same scope name (e.g. Mail.ReadWrite) appears in both lists in Azure. When setting up app-only, you must add the Application versions (Azure labels them "Application" vs "Delegated"). Adding the delegated ones won't work for client credentials, and vice-versa.

What is "admin consent"?

Some permissions are powerful enough that an ordinary user isn't allowed to approve them for the whole organisation β€” a Global Administrator has to click "Grant admin consent" in Azure. All application permissions need admin consent (there's no user to consent, so an admin must). Many delegated permissions a user can consent to themselves at sign-in.

What about User.Read?

User.Read is a delegated permission to read a user's own basic profile (including their proxyAddresses β€” the alias list) via Graph /me. It's about as low-privilege as a permission gets, and a normal user can consent to it themselves β€” no admin consent needed.

FreeITSM requests it so it can resolve aliases: the access token only carries the account's UPN/primary address, so the only way to know that ed@ is just an alias of the edmozley@ mailbox is to ask Graph for the alias list β€” which needs User.Read. With it, pointing a mailbox at an alias works correctly. Without it (older mailboxes, or if you trim it from the scopes), FreeITSM falls back to matching the token's primary identity only β€” everything still works, you just can't use a non-primary alias as the target.

It is not needed for app-only mode (which uses Application permissions and doesn't sign anyone in).

Prefer not to grant User.Read?

It's optional, not unavoidable β€” and it's worth being clear-eyed about the trade-off rather than hand-waving it:

  • User.Read is the single lowest-privilege delegated Graph permission. It reads the signed-in user's own basic profile (name, email, alias list) β€” it grants no access to other users, the directory, groups, or anything beyond the person who just signed in. Granting it is a very small ask, and a user can self-consent (no admin).
  • The only thing FreeITSM uses it for is reading that account's own proxyAddresses so a mailbox alias can be recognised as the same inbox. Nothing else.

If your org would still rather not grant it, you have two clean alternatives that need zero extra permission:

  1. Point the mailbox at its primary address (the …@ you actually sign in with / the primary SMTP), not a friendlier alias. Exact-match then works off the token alone β€” no User.Read.
  2. Use app-only mode, which sidesteps the whole "who signed in" question β€” it reads the exact target mailbox directly and never touches User.Read.

So it's a convenience/cleanliness trade-off, not a forced compromise: grant a tiny scope to use aliases freely, or skip it and use the primary address (or app-only).

Why "least privilege" still matters

Only ask for what you need. Beyond User.Read (for alias resolution) and the Mail.* scopes (the actual job), FreeITSM requests no directory access or anything broader. Fewer scopes = smaller blast radius if a secret ever leaks, and an easier conversation with whoever approves the Azure app.


Setting up the Azure app registration

You need an App registration in Microsoft Entra ID (Azure AD). The same registration can serve delegated or app-only.

Common steps

  1. Entra ID β†’ App registrations β†’ New registration. Note the Application (client) ID and Directory (tenant) ID.
  2. Certificates & secrets β†’ New client secret. Copy the secret value immediately (you can't see it again).
  3. Enter the tenant ID, client ID and secret into the FreeITSM mailbox modal.

For delegated 4. Authentication β†’ Add a platform β†’ Web, and set the Redirect URI to your install's oauth_callback.php (FreeITSM pre-fills this). 5. API permissions β†’ Microsoft Graph β†’ Delegated: add Mail.Read, Mail.ReadWrite, Mail.Send, User.Read, offline_access, openid, email. (User.Read lets FreeITSM recognise mailbox aliases β€” see the scopes primer above; it's optional but recommended.) 6. Save, then in FreeITSM click Authenticate and sign in as the target mailbox.

For app-only 4. API permissions β†’ Microsoft Graph β†’ Application: add Mail.ReadWrite and Mail.Send. 5. Click Grant admin consent (requires a Global Admin). 6. (Optional, recommended) Lock the app down to just the mailboxes it should touch with an Application Access Policy β€” otherwise an app-only app can in principle read every mailbox in the tenant. 7. In FreeITSM, set Authentication = App-only. No sign-in step β€” it just works on the next check.


Google Workspace (brief)

Google mailboxes use the Gmail API with OAuth 2.0 and behave like delegated mode β€” you authorise once and FreeITSM reads/sends as that account. There's no app-only equivalent in the FreeITSM UI for Google; the redirect URI uses google_oauth_callback.php.


Not on Microsoft 365 or Google Workspace?

Both providers above require an OAuth app registration (Azure/Entra or Google Cloud). For a plain mailbox β€” a hosting/cPanel inbox, Fastmail, or any server that speaks IMAP + SMTP with a username and password β€” see Basic IMAP mailboxes.


Troubleshooting

Symptom Cause Fix
Badge stuck on Unverified authenticated_as not populated yet Click Check emails once β€” it back-fills from the token
⚠ Wrong account / "Authentication mismatch" The signed-in account doesn't own the target address Re-authenticate signing in as the target, set the target back to an address the account owns, or switch to app-only
⚠ Wrong account but it is the right mailbox (target is an alias) The mailbox was authenticated without User.Read, so FreeITSM couldn't read the alias list and only knows the primary address Add User.Read to the delegated scopes and re-authenticate (the alias list is then fetched and the alias is accepted), or set the target to the mailbox's primary address instead of the alias
App-only: "/me request is only valid with delegated authentication flow" (HTTP 400) on any outgoing email A FreeITSM older than the fix for issue #67. Sending addressed the message to "the signed-in user's mailbox", which does not exist in app-only mode β€” so reading worked and sending did not Update FreeITSM. It affected every outgoing route (workflow email actions, ticket acknowledgements, SLA alerts, portal verification, password resets), so a mailbox that collected mail perfectly well could still send nothing
App-only: sending worked, then started failing with "authentication has expired" roughly an hour after the last mailbox check Same fix as the row above. The send path asked for its token the delegated way, which needs a refresh token that client credentials never issue β€” it appeared to work only while the mail poller kept the cached token fresh Update FreeITSM. Until you can, running Check emails immediately before a send is a stopgap, not a fix
App-only: "No email mailbox is configured" on a password reset or portal email when one clearly is Same fix as the rows above. A mailbox was judged sendable by whether it had a stored token β€” which an app-only mailbox does not have until its first mail check Update FreeITSM, or click Check emails once to populate the token
App-only: "client-credentials token request failed" Secret wrong/expired, or admin consent not granted Check the client secret value; grant admin consent on the Application permissions
App-only reads nothing / 404 on the mailbox App not allowed to access that mailbox Confirm the target address is correct and (if you set one) the Application Access Policy includes it
Delegated: "Mailbox is not authenticated" No stored token Click Authenticate and sign in
Replies fail: "Could not determine mailbox for this ticket" Manual ticket with no mailbox_id Use the Send replies from dropdown when raising manual tickets

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally