-
Notifications
You must be signed in to change notification settings - Fork 15
Issue 67 App Only Email Sending
A mailbox set to app-only authentication could receive mail perfectly well but could not send a single message. Reported in issue #67 by jbournestreamsystems, who also diagnosed it correctly and supplied a patch.
This page explains what was going wrong, in plain English, because the underlying cause is one small piece of Microsoft jargon that hides a genuinely important distinction.
The general setup guide is Mailbox authentication.
When FreeITSM wants Microsoft to do something with a mailbox, it sends a web request to an address. That address has to say which mailbox you mean. There are two ways to say it:
| You write | It means |
|---|---|
/me/sendMail |
"send from the mailbox belonging to whoever is holding this pass" |
/users/support@yourcompany.com/sendMail |
"send from this specific named mailbox" |
/me is a convenience. It saves you naming the mailbox, because Microsoft can work it out from who signed in.
The catch is that it only works if somebody actually signed in.
Think of it as the difference between two kinds of building pass:
- A personal staff pass has your photo on it. If you hand it to reception and say "put this in my pigeonhole", they know exactly which pigeonhole, because the pass says who you are. That's
/me. - A contractor's master key has no name on it. It opens the doors, but it isn't anybody. Hand that to reception and say "put this in my pigeonhole" and the question is meaningless β you don't have one. You have to say "put this in the pigeonhole marked Support". That's
/users/support@....
So /me is not a shorthand you can always use. It is only meaningful when the credentials belong to a person.
| Delegated | App-only | |
|---|---|---|
| How it authenticates | A human signs in through a Microsoft sign-in page | FreeITSM authenticates as itself, using a client ID and secret |
| Is there a "who"? | Yes β the token carries the signed-in person | No. Nobody signed in. There is no person anywhere in this |
So /me means |
That person's mailbox β works fine | Nothing at all |
This is why the failure only ever appeared on app-only installs. If your mailbox is delegated, /me has always been correct and still is.
Every place FreeITSM sends a message posted to the same hardcoded address:
https://graph.microsoft.com/v1.0/me/sendMail
On a delegated mailbox that resolves fine. On an app-only mailbox Microsoft refuses it outright, with an error that is admirably clear once you know the above:
/me request is only valid with delegated authentication flow. (HTTP 400)
Reading mail was never affected, because the code that reads a mailbox had already been taught the difference and names the mailbox properly. Only sending had been missed. That is why the symptom is so odd: tickets arrive by email, but nothing ever goes back out.
It affected every outgoing route, not just the workflow action in the bug report:
- workflow Send email actions
- ticket acknowledgement templates (new ticket, assigned, closed)
- SLA breach and warning alerts
- self-service portal account verification emails
- password reset emails
Correcting the address alone would have produced a fix that tested green and then failed intermittently in production. Two more problems were sitting behind it.
A token is the temporary pass; it expires after about an hour and has to be renewed. The two modes renew differently:
-
Delegated is issued a
refresh_tokenβ a "renew this without making the human sign in again" voucher. - App-only is never issued one. It doesn't need one. It just asks for a brand new token using the client secret, any time it likes.
The send path only ever knew the delegated method. Given an app-only mailbox it looked for a refresh voucher, found none, gave up and reported "authentication has expired" β which is doubly unhelpful, because nothing had expired and the message points you at the wrong problem entirely.
This was hidden by a coincidence: the mail-checking job stores a fresh token every time it collects mail. So as long as mail was being polled regularly, sending borrowed a token that happened to still be valid. Turn polling off, or send more than an hour after the last check, and it broke again β with a different error than the one you'd just fixed.
Three places decided whether a mailbox was usable by checking whether it had a stored token.
That is a reasonable test for delegated, where a token only exists once somebody has signed in. It is the wrong test for app-only, which has no stored token until the first time it mints one β but can mint one whenever it wants.
The result: a brand-new, perfectly configured app-only mailbox was reported as "No email mailbox is configured" on password resets and portal emails. It would start working later, on its own, once mail had been collected once β which is exactly the kind of intermittent behaviour that makes a bug hard to pin down.
Colour key: βοΈ shared logic Β· π API Β· π₯οΈ module code Β· π docs
| π¨ | File | What changed |
|---|---|---|
| βοΈ | includes/mailbox_graph.php |
the decision lives here. Added mailboxIsAppOnly() and mailboxCanSend(); mailboxResolveGraphBase() refactored onto the former |
| βοΈ | includes/template_email.php |
added templateGraphContext(); templateSendViaGraph() now takes the base path as a required third argument |
| βοΈ | includes/self_service_email.php |
portal verification email; ssGetSendingMailbox() now uses mailboxCanSend()
|
| βοΈ | includes/sla_notifications.php |
SLA breach and warning emails |
| π₯οΈ | workflow/includes/engine.php |
the send_email action β the route in the bug report |
| π | api/auth/request_password_reset.php |
password reset; also loses its private duplicate of the token refresher |
| π |
CHANGELOG.local.md, this wiki |
logged as #1080 |
Two files were already correct and were deliberately left alone: api/knowledge/send_share_email.php and api/change-management/send_share_email.php both named the mailbox properly.
The rule going forward is that auth mode changes three things, and nothing should re-derive any of them by hand:
| Helper | Answers |
|---|---|
mailboxIsAppOnly($mailbox) |
Is this app-only? (Microsoft and auth_mode = app_only) |
mailboxResolveGraphBase($mailbox) |
/me or /users/<address>
|
mailboxCanSend($mailbox) |
Can this mailbox send right now? β per Β§4b |
templateGraphContext($conn, $mailbox) |
the token and the address together, as one result |
templateGraphContext() returning both halves at once is the important part. The token source and the endpoint are two answers to the same question, and issue #67 happened precisely because one was updated for app-only and the other wasn't. Handing them out as a pair makes them hard to get half-right.
The reported patch was correct about the cause and fixed the visible symptom. It added a new mailboxAddress argument to the send function, passing the target mailbox address, or an empty string when there wasn't one.
This implementation differs in three ways, none of which is a criticism of the diagnosis β it was spot on, and it's what made the fix quick.
1. It reuses the existing decision rather than adding another one.
FreeITSM already had mailboxResolveGraphBase(), which turns a mailbox into /me or /users/<address>, and the ticket-reply path already used it. Adding a second, separate way to work out the same thing would have meant two places to keep in step β and the bug itself was caused by exactly that kind of drift. So the send path takes the resolved base path from the existing helper instead of re-deriving it from an address.
2. It fixes the token and the eligibility test as well. The patch addressed the endpoint. As set out in Β§4, correcting only the endpoint leaves a mailbox that sends correctly until its borrowed token expires, then fails with an unrelated-sounding error. Both were fixed together because they are the same bug wearing two hats.
3. The new argument is required, with no default.
Defaulting it to /me would have been the safer-looking choice, and it is the one to avoid: a default of /me recreates the exact silent wrong-endpoint behaviour being fixed. Any send path that forgets to pass it should fail loudly during development rather than quietly send from the wrong mailbox. There are five callers and they are all in this repository, so there is nothing to break.
Both halves were run against real Microsoft infrastructure on a live app-only mailbox, not simulated:
| Workflow run | When | Result |
|---|---|---|
| 139 | before the fix |
failed β /me request is only valid with delegated authentication flow (HTTP 400)
|
| 140, 141 | after the fix | success β email delivered |
Plus a 19-check logic test covering both modes, including two negative controls (the old logic has to fail the same assertions, or the test is proving nothing) and a check that templateSendViaGraph() really has no /me default left.
The most important of those checks is the least obvious one: delegated must still resolve to /me. A fix that simply sent everyone to /users/<address> would have looked like it worked on the reporter's install and broken every delegated install in existence.
Switching the test mailbox back to delegated produced the identical error again:
/me request is only valid with delegated authentication flow. (HTTP 400)
Which looks precisely like the fix not working. It wasn't.
Changing a mailbox's auth mode cleared the recorded identity but left the stored token in place. The mailbox was now delegated, so the delegated path ran, found a token, and used it - except it was the app-only token minted during the test. Graph refuses an app-only token at /me, and says so in exactly the same words.
Two lessons worth keeping:
- The same message can have two causes. Here it was "the code asked the wrong question" the first time and "the credential is the wrong kind" the second. Reading the message alone would have sent you round in circles.
- Clearing an identity is not the same as clearing a credential. The mailbox went on looking authenticated, so nothing ever prompted for a fresh sign-in.
Fixed as #1081: changing the auth mode or the target address now clears token_data alongside the identity, and a token of the wrong kind is refused outright if one somehow survives. Nobody switches modes often, which is why it had gone unnoticed - we did it twice in an hour.
- Mailbox authentication β setting up either mode, and the troubleshooting table
-
Workflows β the
send_emailaction - Tickets β the module these mailboxes feed
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)