Skip to content

Mailbox Authentication

Ed Mozley edited this page Aug 19, 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 ⚠.


Default ticket origin

Each mailbox carries a Default ticket origin, chosen from a dropdown in its settings. Tickets that mailbox opens are recorded as having come from it, so your reports can separate email tickets from any other kind.

It is set per mailbox, not once for all email, because a helpdesk address and a monitoring address both arrive as email and are not the same source. Point one at Email and another at an origin you have added called Monitoring, and the tickets can finally be told apart.

The origin is stored as a reference rather than as the word itself, so renaming it later changes what you see everywhere and breaks nothing.

On installations with more than one company: a mailbox pinned to a company may use that company's own origins as well as the standard ones; a shared-intake mailbox may use only the standard ones, because its tickets land in whichever company the sender's domain matches and one client's private label has no business on another client's ticket. The standard origins are always available, so there is no mailbox that cannot be given one.

Leaving it blank is allowed β€” the mailbox simply records no origin, and the ! described below will mention it.

Added in #1112, from issue #79. Existing tickets are not backfilled: nothing knows which mailbox an old ticket came through, and inventing an answer would be worse than the blank. See A ticket from email did not say it came from email.


The ! beside a mailbox name

A mailbox can be authenticated, green and collecting mail perfectly and still not be doing what you assume. That is not hypothetical β€” it is how the missing ticket origin above went unnoticed: nothing was broken enough to error, and the only symptom appeared weeks later, on the tickets.

So each mailbox carries an ! next to its name when something needs attention. Click it for the list, in plain terms, each item saying what the consequence is rather than naming a setting.

Severity What it covers
Error β€” mail is not being collected, or is being collected wrongly Reading the wrong inbox Β· never signed in Β· stored credentials cannot be decrypted Β· the chosen ticket origin has been deleted
Warning β€” mail is collected, but something downstream is not set up No ticket origin set Β· cannot confirm which account is signed in Β· never checked for mail Β· not checked for several days Β· set to file imported mail into a folder that was never named Β· an IMAP mailbox with no outgoing server, so replies cannot be sent

A mailbox with nothing wrong has no mark at all.

Dismissing a warning

Most warnings describe something you may well have meant β€” a mailbox that genuinely should record no origin, or a receive-only address with no outgoing server. Each one therefore has a Dismiss button.

Dismissing says "I know". The mark clears, and the item stays listed inside the dialog with a Restore beside it β€” acknowledging something is not the same as hiding it.

Errors cannot be dismissed. Reading the wrong inbox is a fault rather than a preference, and it is the one case where letting somebody silence the warning would be worse than never having shown it.

Two things are deliberately not flagged at all, being ordinary choices already shown as a badge on the same row: a mailbox that is switched off, and a mailbox left as shared intake rather than pinned to a company.

Why the dismiss button exists at all: a warning sign that can never be cleared is one people stop reading, and it would take the useful marks down with it.


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
"/me request is only valid with delegated authentication flow" on a mailbox that is delegated You recently switched this mailbox's auth mode. Before the fix, changing modes cleared the recorded identity but kept the old token, and an app-only token is refused at /me β€” the same wording as the row above, for a completely different reason Click Authenticate and sign in again. Fixed in #1081: changing the mode or target address now clears the token too, so the mailbox reports that it needs signing in rather than looking authenticated
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