Skip to content

Mailbox Folder Verification Developer Guide

Ed Mozley edited this page Aug 24, 2026 · 2 revisions

Verifying a mail folder β€” 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


1. πŸ“ The files involved

🟒 The endpoint

File Role
api/tickets/verify_mailbox_folder.php Loads and decrypts the mailbox, branches on provider, returns one shape whichever branch ran

πŸ”΅ The three provider implementations

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() Google

βšͺ The caller

File Role
tickets/settings/index.php runFolderVerify() β€” one routine driving both Verify buttons (intake folder, and the move-imported-mail-to folder)

2. The contract, and the one rule

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.


3. Why the branch happens before the token check

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
Google 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 down

The full story is in The Verify button only ever worked for Microsoft.


4. Microsoft Graph: a name is not an identifier

/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']);
  • /mailFolders returns 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 through childFolders.
  • No $filter. Graph's displayName filter is case-sensitive, so matching is done in PHP with strcasecmp() 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']));

5. Basic IMAP: the connection string is the API

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.

Listing folders is fiddlier than it looks

$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 Anfragen or Übergabe comes back mangled β€” in the one message whose entire job is to be readable.
  • The hierarchy delimiter is per-server, . on many, / on others. So Inbox/Support is accepted for a server that names it INBOX.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/imap is not bundled with PHP 8.4. imapStreamFor() raises a specific message when imap_open() is missing, rather than letting it read as a connection failure. Self-hosters must enable it.


6. Gmail: labels, not folders

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.

Counting: use labels.get, never resultSizeEstimate

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 β†’ resultSizeEstimate is not a count. Google documents it as an estimate; measured against a live account it returned 201 for INBOX, SENT and TRASH alike, 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.

The backlog note

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.


7. What the browser gets back

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.


8. Adding a fourth provider

  1. Write xVerifyFolder() next to that provider's reader, in the same include. It must call the reader's own resolution, not its own copy.
  2. Add a branch in verify_mailbox_folder.php above the OAuth-token gate.
  3. Return the shape in Β§2, and throw with a message a human can act on.
  4. Check toggleProviderFields() in tickets/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.
  5. Decide what "authenticated" means for it and add it to mailboxAuthenticatedSql() in includes/mailbox_auth.php β€” see IMAP mailboxes reported as not authenticated for what happens when four screens each answer that question separately.

9. Testing notes

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.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally