-
Notifications
You must be signed in to change notification settings - Fork 16
Gmail Mail Collection Developer Guide
Gmail is the odd one out among FreeITSM's three mailbox providers, and almost every mistake made against it comes from one fact: Gmail has no folders.
If you are touching includes/gmail.php, read this first. The model is genuinely different from Microsoft 365 and IMAP, and code that assumes otherwise fails quietly rather than loudly.
Related: Verifying a mail folder β Developer Guide Β· Mailbox authentication Β· Architecture
A Gmail message does not live in a place. It carries a set of labels, and it carries as many as you like at once.
| What you would expect | What Gmail actually does |
|---|---|
| A message is in one folder | A message carries several labels at once |
| The Inbox is a folder |
INBOX is just another label
|
| Archiving moves a message | Archiving removes the INBOX label and changes nothing else |
| Moving to a folder | Adding one label, usually removing another |
| Sent items is a folder |
SENT is a label, and a message can be both SENT and INBOX
|
The consequence that matters:
"Archived" is not a place. It is the absence of the
INBOXlabel.
So a message that a Gmail filter sent straight to a Support label, skipping the Inbox, carries Support and does not carry INBOX. It is not hidden, not deleted, not in some other folder. It simply never had the one label that most code assumes everything has.
There is also a second, smaller trap: the labels a user creates have ids equal to their names, but Gmail's own labels have fixed ids (INBOX, SENT, CATEGORY_PROMOTIONS) that are not localised, while their display names are. Always match and store by id, never by display name.
gmailGetEmails() used to build its query like this:
$q = 'is:unread';
$labelId = 'INBOX'; // β always
$folder = strtoupper($mailbox['email_folder'] ?? 'INBOX');
if ($folder !== 'INBOX') {
$q .= ' label:' . strtolower($mailbox['email_folder']); // β an EXTRA term
}
$listUrl = 'https://gmail.googleapis.com/gmail/v1/users/me/messages?'
. http_build_query(['q' => $q, 'maxResults' => $maxResults, 'labelIds' => $labelId]);Read that carefully. labelIds=INBOX is unconditional. The configured folder is added on top of it, never instead of it. The effective question being asked was:
unread AND in the Inbox AND labelled Support
when the question the user thought they were asking was:
unread AND labelled Support
For a mailbox configured with a label, that meant FreeITSM collected only mail that had been left in the Inbox and labelled. Anyone using a Gmail filter to route support mail to a label β which is the entire reason to have a label β got nothing, forever, with no error, no warning, and a mailbox that reported itself as Connected and checked on schedule.
Why it survived so long: the failure mode is an empty result set, and an empty result set is exactly what a quiet mailbox looks like. There is nothing to alert on. "No new mail" and "this configuration can never collect mail" were the same observation.
That is the same shape as the Operational status bug and the Watchtower name lookups: a lookup that matches nothing returns a confident zero, never an error.
The configured folder resolves to a label id, and that id becomes the scope:
function gmailResolveListLabelId(string $accessToken, array $mailbox): string {
$folder = trim((string) ($mailbox['email_folder'] ?? '')) ?: 'INBOX';
if (strcasecmp($folder, 'INBOX') === 0) {
return 'INBOX';
}
$label = gmailFindLabel(gmailListLabels($accessToken), $folder);
if ($label === null) {
// Loudly, rather than quietly returning nothing forever.
throw new Exception('Gmail label "' . $folder . '" was not found in this account.');
}
return (string) $label['id'];
}and the reader is then simply:
$labelId = gmailResolveListLabelId($accessToken, $mailbox);
$listUrl = 'https://gmail.googleapis.com/gmail/v1/users/me/messages?'
. http_build_query(['q' => 'is:unread', 'maxResults' => $maxResults, 'labelIds' => $labelId]);Three things fell out of this that were not the point but are worth knowing:
-
Labels with spaces work. The old code built
label:customer support, which Gmail's search syntax reads as two terms. Scoping by id sidesteps search syntax entirely. - A missing label is now an error. It used to produce zero results, indistinguishable from a quiet mailbox.
-
Matching is case-insensitive, so
sent,SentandSENTall resolve.
Resolution behaviour, probed against a live account:
email_folder |
Resolves to |
|---|---|
NULL |
INBOX |
'' |
INBOX |
INBOX / inbox
|
INBOX |
SENT / Sent
|
SENT |
NoSuchLabel |
throws, naming the label |
This one cost a round of rework, and it will catch you too.
The obvious way to count messages is messages.list and read resultSizeEstimate. Google documents it as an estimate. It is worse than that β it is not usable per-label at all. Measured against a live account:
| Label |
labels.get (true) |
resultSizeEstimate |
|---|---|---|
INBOX |
2 | 201 |
SENT |
27 | 201 |
TRASH |
1 | 201 |
DRAFT |
0 | 0 |
SPAM |
0 | 0 |
It returned the same number for three labels whose real sizes were 2, 27 and 1. Note that the zero cases agreed, which is exactly what makes this dangerous: a quick check against an empty label confirms the approach and tells you nothing.
Use labels.get, which returns exact figures for the label:
$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);This is only correct because the label is now the collection scope. Under the old INBOX-AND-label behaviour, labels.get would have over-reported, which is why the first version of Verify deliberately avoided it. Fixing the reader is what made the simple count correct β worth remembering when a workaround starts looking like the design.
Collection takes unread mail from the label, wherever it sits. On an account where a label has been accumulating archived unread mail for months, that backlog is now eligible to become tickets, max_emails_per_check at a time.
That is the correct behaviour β it is the mail the user asked to collect β but it should not be a surprise. So gmailVerifyFolder() says so before it happens:
$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.';
}rendered in amber rather than green, because a green "found" reads as "nothing to think about".
Note the inversion: the previous version of this note warned that archived mail would be missed. Fixing the reader made that warning false, so it had to change in the same commit. A caveat that documents a bug has to be re-examined the moment the bug is fixed, or it becomes a confident statement of something untrue.
| File | Role |
|---|---|
includes/gmail.php |
gmailResolveListLabelId() (folder β scope), gmailListLabels(), gmailFindLabel(), gmailApiGet(); gmailGetEmails() reads through the resolved scope; gmailVerifyFolder() counts through labels.get
|
api/tickets/check_mailbox_email.php |
Calls gmailGetEmails(); a thrown missing-label now surfaces as a failed check rather than an empty one |
api/tickets/verify_mailbox_folder.php |
The Google branch, above the OAuth-token gate |
-
Reading and verifying must share the resolver.
gmailVerifyFolder()callsgmailListLabels()/gmailFindLabel(), the same functions the reader uses. A verifier cleverer than the reader is issue #77 again. -
is:unreadis the collection trigger, not a date. There is no "only mail since X" concept here, which is why the backlog warning matters. -
Post-import actions are label operations.
gmailMarkAsRead()removesUNREAD;gmailTrashMessage()moves toTRASH. Neither "moves" anything in the folder sense. -
Testing without creating tickets: resolve the scope in isolation rather than running a collection. A short read-only script that loads the mailbox row, decrypts it, and calls
gmailResolveListLabelId()against each candidate folder value proves the branch that matters without importing anything.
- Verifying a mail folder β Developer Guide β the same three providers, from the Verify button's side
- The Verify button only ever worked for Microsoft β where this was found
- Mail could only ever be collected from Inbox β the Microsoft equivalent of the same class of mistake
- Never identify a lookup row by its name β a lookup matching nothing returns a confident zero
- Mailbox authentication
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
- π Date & Time Formats
- 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
- β³ π Ticket notes: internal or shared
- β³ ποΈ 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)