-
Notifications
You must be signed in to change notification settings - Fork 15
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.
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 |
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.
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:
-
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. -
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 theedmozley@mailbox) works fine β but a genuinely different mailbox (e.g.support@while signed in asedmozley@) is correctly caught. - 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.
- 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_ashasn'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 β .
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 fromtemplateGraphContext()and pass it explicitly βtemplateSendViaGraph()requires it as a third argument for exactly this reason, and deliberately has no default.
If "scopes", "delegated vs application permissions", and "admin consent" make your eyes glaze over, read this once and it'll click.
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").
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.Readdelegated 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.ReadWriteapplication 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.
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.
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).
It's optional, not unavoidable β and it's worth being clear-eyed about the trade-off rather than hand-waving it:
-
User.Readis 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
proxyAddressesso 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:
-
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 β noUser.Read. -
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).
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.
You need an App registration in Microsoft Entra ID (Azure AD). The same registration can serve delegated or app-only.
Common steps
- Entra ID β App registrations β New registration. Note the Application (client) ID and Directory (tenant) ID.
- Certificates & secrets β New client secret. Copy the secret value immediately (you can't see it again).
- 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 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.
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.
| 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 |
- Tickets β the module these mailboxes feed
- Basic IMAP mailboxes β connecting a plain username/password mailbox
- Multi-Tenancy: Email routing & mailboxes β pinned vs shared-intake mailboxes, sender-domain routing
- Security β how mailbox secrets and tokens are encrypted at rest
- Installation β first-run setup
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)