-
Notifications
You must be signed in to change notification settings - Fork 16
Mailbox Folder Verification Developer Guide
The Verify button beside a mailbox's folder field answers one question: if FreeITSM collected mail from this mailbox right now, would it find this folder?
Three providers answer it three completely different ways. Microsoft Graph wants an opaque identifier, IMAP wants a connection string, and Gmail does not have folders at all. This page is the tour of all three, with the code.
The user-facing pages are Mailbox authentication and Basic IMAP mailboxes.
Related: Multi-tenancy: email routing Β· Architecture
| File | Role |
|---|---|
api/tickets/verify_mailbox_folder.php |
Loads and decrypts the mailbox, branches on provider, returns one shape whichever branch ran |
| File | Function | Provider |
|---|---|---|
includes/mailbox_graph.php |
mailboxResolveFolderId(), mailboxGraphGet()
|
Microsoft 365 |
includes/mailbox_imap.php |
imapVerifyFolder(), imapListFolders(), imapStreamFor()
|
Basic IMAP |
includes/gmail.php |
gmailVerifyFolder(), gmailResolveListLabelId(), gmailListLabels(), gmailFindLabel()
|
| File | Role |
|---|---|
tickets/settings/index.php |
runFolderVerify() β one routine driving both Verify buttons (intake folder, and the move-imported-mail-to folder) |
Every provider branch returns the same shape:
[
'displayName' => string, // the name the SERVER uses, not the name typed
'totalItemCount' => int|null, // null when the provider cannot cheaply say
'unreadItemCount' => int,
'note' => string|null, // optional caveat, rendered in amber
]and throws an Exception whose message is shown to the user verbatim. That is deliberate: this is a diagnostic button, so the message is the feature. "Not found" on its own is a puzzle; "not found, and here are the folders that do exist" is an answer.
The rule that matters more than any of the above: verify through the same code the mail collection uses. Not equivalent code. The same function.
This is not a style preference, it is issue #77. Verify once had its own folder lookup, which cheerfully confirmed
Folder "freeitsm" found (1 items, 1 unread)
for a folder the reader then rejected with 400 ErrorInvalidIdMalformed. Two implementations of "does this folder exist?", and the one wired to the reassuring green message was the one that was wrong. A verifier that can pass where the reader fails is worse than no verifier, because it converts a fixable problem into a confident denial that there is one.
The endpoint used to open with this, unconditionally:
if (empty($mailbox['token_data'])) {
echo json_encode(['success' => false, 'error' => 'Mailbox is not authenticated']);
exit;
}token_data holds the OAuth token. That gate is correct for Microsoft, meaningless for IMAP, and actively misleading for Google:
| Provider | Holds token_data? |
What the gate did |
|---|---|---|
| Microsoft | yes | correct |
| Basic IMAP | never β signs in with a stored password | refused every mailbox, on every install, without a network call |
| yes, but a Google token |
let it through into the Microsoft flow, which then sent Google credentials to graph.microsoft.com and login.microsoftonline.com
|
Google is the instructive one. A gate that merely fails for an unsupported provider is annoying. A gate that passes it into code written for somebody else is how you end up asking Microsoft to refresh a Google token. So the branch is now the first thing after decryption:
$provider = $mailbox['provider'] ?? 'microsoft';
$authMode = $mailbox['auth_mode'] ?? 'delegated';
if ($provider === 'imap') {
try {
$folder = imapVerifyFolder($mailbox, $folderName);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit;
}
echo json_encode(['success' => true, 'folder' => $folder]);
exit;
}
if ($provider === 'google') {
// ... token sanity, then Google's OWN refresh, not Microsoft's ...
$accessToken = gmailGetValidAccessToken($conn, $mailbox, $tokenData);
$folder = gmailVerifyFolder($accessToken, $mailbox, $folderName);
echo json_encode(['success' => true, 'folder' => $folder]);
exit;
}
mailboxResolveGraphBase($mailbox); // Microsoft from here downThe full story is in The Verify button only ever worked for Microsoft.
/mailFolders/<x>/messages does not accept folder names. It accepts opaque identifiers like AAMkADNkNDYyYTQxLTY3ODEtβ¦, plus a short list of well-known aliases:
$wellKnown = [
'inbox' => 'inbox', 'drafts' => 'drafts',
'sent items' => 'sentitems', 'sentitems' => 'sentitems',
'deleted items' => 'deleteditems', 'deleteditems' => 'deleteditems',
'junk email' => 'junkemail', 'junkemail' => 'junkemail',
'archive' => 'archive', 'outbox' => 'outbox', 'clutter' => 'clutter',
'conversationhistory' => 'conversationhistory',
];INBOX worked for years for exactly one reason: it is on that list. Every folder anyone created themselves failed, and always had.
mailboxResolveFolderId() walks the path segment by segment. Two details are load-bearing:
$base = 'https://graph.microsoft.com/v1.0' . mailboxGraphBase();
$url = $current === null
? $base . '/mailFolders' // top level
: $base . '/mailFolders/' . rawurlencode($current) . '/childFolders';
// Ask for the name we want, but page through rather than trusting a
// filter: displayName filtering is case-sensitive in Graph, and a folder
// typed "Freeitsm" would otherwise read as missing.
$url .= '?' . http_build_query(['$top' => 200, '$select' => 'id,displayName']);-
/mailFoldersreturns top-level folders only. A folder inside Inbox is invisible to it, so nested paths are written with a slash (Inbox/freeitsm) and each later segment resolves throughchildFolders. -
No
$filter. Graph'sdisplayNamefilter is case-sensitive, so matching is done in PHP withstrcasecmp()instead.
The HTTP call is injected rather than hard-coded, which is what makes the resolver testable without a mailbox:
function mailboxResolveFolderId($folderName, callable $get) { ... }Verify then reads the folder back by identifier β the exact operation that used to fail β rather than settling for "a folder of that name exists":
$res = $get('https://graph.microsoft.com/v1.0' . mailboxGraphBase()
. '/mailFolders/' . rawurlencode($folderId)
. '?' . http_build_query(['$select' => 'id,displayName,totalItemCount,unreadItemCount']));IMAP has no REST surface here. PHP's imap_* extension takes a reference string that encodes host, port, encryption and folder in one:
function imapMailboxRef(array $mailbox, ?string $folder = null): string {
$host = $mailbox['imap_server'] ?? '';
$port = (int) ($mailbox['imap_port'] ?? 993);
$enc = strtolower($mailbox['imap_encryption'] ?? 'ssl');
$folder = $folder ?? ($mailbox['email_folder'] ?: 'INBOX');
$flags = '/imap';
if ($enc === 'ssl') { $flags .= '/ssl'; } // implicit TLS
elseif ($enc === 'tls') { $flags .= '/tls'; } // STARTTLS
else { $flags .= '/notls'; }
return '{' . $host . ':' . $port . $flags . '}' . $folder;
}which produces {mail.example.com:993/imap/ssl}INBOX.
Because the folder is part of the connection string, "open the folder" and "connect to the server" are the same call β so a naive verifier cannot tell a wrong password from a wrong folder name. Both surface as imap_open() returning false. That is why verification connects to INBOX first, which always exists:
// Connect against INBOX, which always exists, so a failure here is
// unambiguously credentials/TLS/extension rather than a bad folder name.
$stream = imapStreamFor($mailbox, 'INBOX');
$folders = imapListFolders($stream, $mailbox);Anything that goes wrong at that point is a real connection problem, and is reported as one. Only then is the folder name matched against the server's own list.
$boxes = @imap_getmailboxes($stream, imapMailboxRef($mailbox, ''), '*');
imap_errors(); // drain the error stack so it can't leak into later output
foreach ($boxes as $box) {
$name = (string) ($box->name ?? '');
$brace = strpos($name, '}');
if ($brace !== false) {
$name = substr($name, $brace + 1); // strip the {host:port/flags} prefix
}
if (function_exists('mb_convert_encoding')) {
$decoded = @mb_convert_encoding($name, 'UTF-8', 'UTF7-IMAP');
if (is_string($decoded) && $decoded !== '') {
$name = $decoded; // IMAP's own modified UTF-7
}
}
$out[] = ['name' => $name, 'delimiter' => (string) ($box->delimiter ?? '')];
}Three things to know:
-
Every entry repeats the full
{host:port/flags}prefix. It has to come off. -
Names are in modified UTF-7, IMAP's own encoding. Skip the decode and a folder called
AnfragenorΓbergabecomes back mangled β in the one message whose entire job is to be readable. -
The hierarchy delimiter is per-server,
.on many,/on others. SoInbox/Supportis accepted for a server that names itINBOX.Support:
$candidates = [$folder['name']];
if ($folder['delimiter'] !== '') {
$candidates[] = str_replace($folder['delimiter'], '/', $folder['name']);
}Matching is case-insensitive, and a miss names what is there β which on a Dovecot server with an INBOX. namespace is usually the whole answer:
Folder "Support" was not found in this mailbox.
Folders on the server: INBOX, INBOX.Support, Drafts, Sent, Spam, Trash, Archive.
Counts come from the folder that was actually selected, not a separate STATUS:
$folderStream = imapStreamFor($mailbox, $match['name']);
$total = @imap_num_msg($folderStream);
$unseen = @imap_search($folderStream, 'UNSEEN', SE_UID);
β οΈ ext/imapis not bundled with PHP 8.4.imapStreamFor()raises a specific message whenimap_open()is missing, rather than letting it read as a connection failure. Self-hosters must enable it.
Gmail has no folders. It has labels, a message carries several at once, and "in the Inbox" is itself just a label β archiving removes INBOX and leaves the rest. So the configured folder resolves to a label, through the same two helpers the reader uses:
$labels = gmailListLabels($accessToken);
$match = gmailFindLabel($labels, $folderName);A miss lists the labels that exist, exactly as the IMAP branch lists folders.
The scope for counting is the label id β the same scope gmailResolveListLabelId() hands the reader:
$isInbox = strcasecmp($folderName, 'INBOX') === 0;
$detail = $get('https://gmail.googleapis.com/gmail/v1/users/me/labels/' . rawurlencode((string) $match['id']));
$total = (int) ($detail['body']['messagesTotal'] ?? 0);
$unread = (int) ($detail['body']['messagesUnread'] ?? 0);
β οΈ messages.listβresultSizeEstimateis not a count. Google documents it as an estimate; measured against a live account it returned 201 forINBOX,SENTandTRASHalike, when the true totals were 2, 27 and 1. Empty labels agreed at zero, which is what makes it so easy to adopt and so hard to catch.
Collection takes unread mail from the label wherever it sits, so a label with a long unread history is a backlog waiting to become tickets. Verify says the number first, in amber:
$note = null;
$perCheck = (int) ($mailbox['max_emails_per_check'] ?? 10);
if (!$isInbox && $perCheck > 0 && $unread > $perCheck) {
$note = $unread . ' unread message(s) carry this label, including any that have been '
. 'archived out of the Inbox. All of them are eligible to become tickets, '
. $perCheck . ' per check. Mark the older ones as read first if you only want new mail.';
}An earlier version of this note warned the opposite β that archived mail would be missed β because the reader used to AND the label with labelIds=INBOX. That was fixed in #1173, and the note had to be inverted in the same change: a caveat describing a bug becomes a confident falsehood the moment the bug is fixed.
The full model, the old behaviour, and the traps are in How Gmail mail collection works. Read that before changing anything in includes/gmail.php.
One routine drives both Verify buttons:
async function runFolderVerify(inputId, resultId, btnId) { ... }Two behaviours worth preserving:
// No escapeHtml: textContent below escapes already, so this
// double-encoded any folder with an & or a quote in its name.
let msg = 'Folder "' + data.folder.displayName + '" found';
// A Gmail label that exists but sits outside the Inbox verifies
// fine and collects nothing. Green would read as "working".
if (data.folder.note) {
msg += ' - ' + data.folder.note;
resultEl.style.color = '#856404'; // amber
} else {
resultEl.style.color = '#155724'; // green
}displayName is echoed from the server, not from the input box, so a folder typed inbox.support comes back as INBOX.Support and the user can see the difference.
- Write
xVerifyFolder()next to that provider's reader, in the same include. It must call the reader's own resolution, not its own copy. - Add a branch in
verify_mailbox_folder.phpabove the OAuth-token gate. - Return the shape in Β§2, and throw with a message a human can act on.
- Check
toggleProviderFields()intickets/settings/index.phpβ the folder row carries none of the.provider-*classes, so the button is shown for every provider whether or not you implemented it. - Decide what "authenticated" means for it and add it to
mailboxAuthenticatedSql()inincludes/mailbox_auth.phpβ see IMAP mailboxes reported as not authenticated for what happens when four screens each answer that question separately.
The useful trick is an install carrying all three provider types at once, so every result has two controls beside it. A forged session plus curl exercises the endpoint directly:
curl -s -b "PHPSESSID=$SID" -H "Content-Type: application/json" \
-d '{"mailbox_id":4,"folder_name":"INBOX"}' \
"http://localhost/freeitsm-app/api/tickets/verify_mailbox_folder.php"Test the negative case as hard as the positive one. A bogus folder name returning the server's real folder list is far stronger evidence than a successful lookup: it proves the login worked, the LIST ran, and the failure path is wired up β three things one green result cannot distinguish between.
For Graph specifically, tests/mailbox-folder-resolve.php carries fixture assertions, of which the load-bearing one is the negative control: a custom folder must never resolve to the name that was typed. If it ever does, issue #77 is back, and every other assertion still passes.
- The Verify button only ever worked for Microsoft β why two of three providers were broken
- IMAP mailboxes reported as not authenticated β the companion bug
- Mail could only ever be collected from Inbox β the folder-resolution bug this whole design is a reaction to
- How Gmail mail collection works β Developer Guide β labels, not folders, and why that keeps biting
- Mailbox authentication Β· Basic IMAP mailboxes
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
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- 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)