Repository navigation
Mail Collection Developer Guide
How inbound email becomes tickets: who starts a check and how often, what one check does from start to finish, and where it all gets recorded. For setting up a mailbox, see Mailbox authentication and Basic IMAP mailboxes.
The one thing to know first: mail is collected only while somebody has the ticket inbox open. There is no scheduled task for mail. If nobody is signed in - overnight, at weekends, on a quiet install - email waits in the mailbox until somebody opens Tickets. A scheduled task is planned; see section 9.
| File | What it does |
|---|---|
tickets/index.php (the inline script near the end) |
The poller: checks every mailbox when the inbox opens and every 60 seconds after |
api/tickets/check_mailbox_email.php |
One check of one mailbox, start to finish. Everything in sections 3-6 is here unless named otherwise |
includes/mailbox_graph.php |
Microsoft Graph helpers: /me or /users/{address}, app-only tokens, identity checks, folder lookup |
includes/gmail.php |
Google: tokens, listing, fetching, mark read, trash |
includes/mailbox_imap.php |
Basic IMAP: connect, search, fetch (attachments ride along), after-import actions |
includes/attachment_limits.php |
The size check (oversize*()), see Attachment size limits
|
includes/tenancy.php β resolveTicketTenantForEmail()
|
Which company a new emailed ticket belongs to |
includes/ticket_numbering.php |
Ticket numbers, and finding a ticket by any number it has had |
includes/mailbox_health.php |
The warnings shown on each mailbox row |
api/tickets/get_inbox_activity.php |
The inbox's "has anything new arrived?" number, used to refresh the list |
tickets/settings/index.php |
Tickets β Settings β Mailboxes: the Check button and the activity viewer |
The rest of the inbound family: chat channels (WhatsApp, Telegram, Slack, Teams, Mattermost) arrive by webhook through includes/messaging/ingest.php, and portal replies through api/self-service/reply_ticket.php. Neither is polled, so neither is covered here.
Every open ticket inbox runs its own poller (checkMailboxes() in tickets/index.php):
-
On page load, and then every 60 seconds (
POLL_INTERVAL) on that tab's own timer. - It asks
api/tickets/get_mailboxes.phpfor the mailboxes, keeps those that are active and signed in, and posts each one's id tocheck_mailbox_email.phpone after another, not in parallel. - The timed polls send
auto: true. Clicking the mail icon in the inbox sendsauto: false. - Afterwards it reads the activity marker (section 7) and refreshes the list if anything new arrived.
A polling flag stops one tab starting a second round while its first is still running. Nothing coordinates separate tabs or analysts: each has its own timer, started whenever that page was opened.
So with five analysts in Tickets, each mailbox gets about five check requests a minute, at times that drift around each other. Since #183, the server turns that into at most one real check a minute:
| A request arrives and... | What happens |
|---|---|
| another check of this mailbox is running | stands down: {"success": true, "skipped": "busy"}
|
| it is automatic, and any check of this mailbox finished less than 50 seconds ago | stands down: {"success": true, "skipped": "recent"}
|
| otherwise | a real check runs |
-
"Running" is a MySQL named lock,
GET_LOCK('freeitsm_mbx_<12 hex of SHA1(database name)>_<mailbox id>', 0). The database name is in it because one MySQL server can host several installs. The lock is released automatically when the request's database connection closes, so a crashed check cannot leave a mailbox locked. -
"Finished" is
target_mailboxes.last_checked_datetime, written at the end of every real check, successful or failed. So a failing mailbox is also tried about once a minute, not once per open inbox. - 50 seconds, not 60, so that the inbox whose own timer is due still gets a turn every minute, despite drift.
-
A manual check is never throttled: the mail icon, and Check in Tickets β Settings β Mailboxes (which sends no
auto). It still waits its turn behind the lock; it stands down if a check is mid-run. - A stand-down changes nothing. No last-checked time, no last error, no log row.
- Best case: within a minute of arriving, while at least one inbox is open.
-
Each check takes at most
max_emails_per_checkemails (per mailbox, default 10). A backlog of 200 takes 20 minutes to clear at one check a minute. -
The order differs by provider. Microsoft Graph asks for the newest unread first (
$orderby=receivedDateTime DESC); IMAP takes the oldest first. So on Microsoft, during a backlog, the oldest mail waits longest. - Nobody signed in, no mail. The mailbox's Not checked for N days warning appears after 24 hours without a check (section 7).
check_mailbox_email.php for one mailbox:
-
Who may run it: a signed-in analyst with the Tickets module (
requireModuleAccessJson('tickets')). -
Load the mailbox (
getMailboxConfig(), secrets decrypted). Inactive β error. - Lock and stand-down (section 2).
-
Get a token for the provider (section 4). If that fails,
failMailboxCheck()records the error on the mailbox and stops. -
Microsoft delegated only - the right inbox?
mailboxBackfillIdentity()andmailboxIdentityMismatch()refuse to read a mailbox whose signed-in account is confirmed to be a different one (issue #26). -
Fetch unread mail from the configured folder:
getEmails()(Graph,isRead eq false),gmailGetEmails()(is:unreadwithin the folder's label) orimapGetEmails()(UNSEEN). Every provider's messages are reshaped to the Graph shape (from.emailAddress.address,body.content, ...), so everything after this step is provider-blind. -
For each email:
-
Allow list. If the mailbox has any allow-list entries (domains or addresses,
mailbox_email_whitelist) and the sender matches none β rejected, the rejected action runs, logged, next email. - Size check (GH #170). Attachments are measured before importing, because "reject the whole email" has to be decided before a ticket exists. Too large under reject β rejected, the sender and/or the addresses in the settings are told. Too large under strip β imported without the offending attachments, which are listed in a note on the message. The fetched attachments are passed on so nothing downloads twice.
-
Import (
saveEmailToDatabase(), section 5). - After-import action (section 6).
- New ticket only: the new ticket template email to the requester.
- Log it (section 7).
-
Allow list. If the mailbox has any allow-list entries (domains or addresses,
-
Record the outcome (
recordMailboxCheckOutcome()): last-checked time always;last_errorset to the joined per-email errors, or cleared.β οΈ A check that found mail and failed to save it has not worked, even though the response sayssuccess: true, so per-email errors become the mailbox's last error too.
| Provider | Kind | How the token is got |
|---|---|---|
| Microsoft, delegated | somebody signed in once |
token_data holds an access and refresh token. Refreshed when within 5 minutes of expiry (getValidAccessToken()), and the new pair saved. Reads /me
|
| Microsoft, app-only | client credentials |
mailboxAppOnlyToken(): a client-credentials token, cached in token_data and reused until 5 minutes before expiry. Reads /users/{target mailbox}
|
| somebody signed in once |
gmailGetValidAccessToken(), the same refresh pattern |
|
| Basic IMAP | username and password | No token. A placeholder 'imap-session' keeps the shared "is there a token?" checks happy; IMAP never makes a web call |
A failed token request carries the provider's own words (oauthTokenErrorMessage()) into last_error, so the mailbox row can say "the client secret has expired" without anybody pressing Check. That gap once went unnoticed for eighteen hours; see the comments in the file.
Sending mail (api/tickets/send_email.php, template emails) uses the same stored tokens, so a mailbox's sending and collecting stand or fall together. See Issue 67 - App-only email sending.
saveEmailToDatabase():
-
Already imported? The de-dup key is
emails.exchange_message_id: the Message-ID header where the provider gives one (internet_message_id; IMAP UIDs repeat across mailboxes), else the provider's own id. Found β returns['already_imported' => true, 'ticket_id' => ...]and nothing is saved. The check logs it as Already imported with that ticket, does not count it as saved, and still runs the after-import action so the email stops coming back. -
A reply?
extractTicketReference()looks for the[SDREF:...]tag that every email FreeITSM sends carries in its subject. It captures whatever is in the tag and lets the database decide if it is real. It never pattern-matches the number's shape, because installs choose their own format (GH #71).findTicketByNumber()then finds the ticket by any number it has ever had (ticket_number_history) and follows merges to the surviving ticket.- Found β a reply: the ticket's updated time is touched, a finished ticket is reopened (if Tickets β Settings allow), a snoozed one is woken, and the quoted thread is stripped (
stripInboundThread()). - Not found, or no tag β a new ticket.
- Found β a reply: the ticket's updated time is touched, a finished ticket is reopened (if Tickets β Settings allow), a snoozed one is woken, and the quoted thread is stripped (
-
The sender becomes a requester (
getOrCreateUser()): found by address, or created. -
New ticket:
-
Company first -
resolveTicketTenantForEmail(): a mailbox pinned to a company decides outright; a shared mailbox routes by the sender's exact address, then their domain, else the ticket goes to triage. A single-company install always uses the Default company. See Multi-tenancy email routing. -
Then the number -
TicketNumbering::next()for that company. π΄ The order matters: per-company numbering draws from the company's own counter, and numbering first gave every emailed ticket the Default company's number. - Status and priority are the configured defaults, never the literal names Open or Normal. Source is the mailbox's default origin (#79).
-
Company first -
-
The message is stored in
emails(direction = 'Inbound',is_initial = 1for the one that opened a ticket). -
New ticket: confidential-mailbox defaults are applied before announcing it (
ticketSensitivityApplyDefaults(), discussion #62), thenticket.createdis dispatched with no actor. Reply:ticket.reply_receivedgoes to the workflow engine instead, never both for one email. -
Attachments are saved (
saveAttachment(); rejected and quarantined files are listed in a note on the message, never dropped silently),cid:images are rewritten to stored files, and the ticket is re-indexed for search now that its attachments exist. - An
email_importrow goes tosystem_logs(section 7).
Per mailbox, separately for imported and rejected mail:
| Setting | Microsoft | IMAP | |
|---|---|---|---|
| Delete | Graph DELETE on the message |
Trash | delete + expunge |
| Move to Deleted Items | move to deleteditems
|
Trash | delete + expunge |
| Mark as read | mark read | mark read | flag \Seen
|
| Move to folder | move to that folder | move + expunge |
Every fetch asks only for unread mail, so each action takes the email out of the next check's view. If the action fails, the email stays unread and is fetched again next time. The de-dup then catches it, logs it as Already imported, and tries the action again. The failure itself is in the log's post_action step and in the mailbox's last error.
| Where | What | Shown in |
|---|---|---|
mailbox_activity_log |
One row per email handled: action (imported, already_imported, rejected), sender, subject, reason, ticket, and processing_log - a JSON list of every step (allow list, size check, import, post action, template email) with its result |
Tickets β Settings β Mailboxes β a mailbox's activity; click a row for the full processing log |
system_logs (log_type = 'email_import') |
One row per real import: sender, subject, ticket, new ticket or reply, attachments | Reporting β System logs β Email imports |
target_mailboxes.last_checked_datetime, last_error, last_error_datetime
|
The mailbox's last real check and whether it worked | The mailbox row's warnings (includes/mailbox_health.php) |
| PHP error log | Failures that must not stop an import: attachment processing, workflow dispatch, search indexing, logging itself | Only on the server |
The mailbox row's warnings: Never checked for mail (signed in but no check yet), Not checked for N days (24 hours without a check, which is deliberately generous), and The last check for mail failed, quoting the provider. The last one is never dismissible and clears itself when a check succeeds.
The inbox's refresh. After its checks, the poller asks api/tickets/get_inbox_activity.php for one number: the id of the newest message on any ticket this analyst can see. When it goes up, the list and folder counts refresh. That catches mail collected by another analyst's inbox, and chat messages that arrive by webhook, neither of which this tab's own check would have reported. The first look only sets the baseline.
- TRAP: no scheduled task collects mail. Some older text said otherwise. Until section 9 ships, an install with nobody signed in collects nothing - including Docker, which runs no scheduler at all.
-
TRAP:
emails.exchange_message_idhas no unique index. The de-dup is aSELECTbefore theINSERT, so only the per-mailbox lock stops two checks importing the same email twice. Anything new that imports mail must go throughcheck_mailbox_email.phpor take the same lock. -
TRAP:
success: truedoes not mean mail was collected. A stand-down is a success, and so is a check whose individual emails failed. Readskipped,details.emails_savedanddetails.errors, or the mailbox'slast_error. - TRAP: the last-checked time is written by failed checks too. It means "last tried", not "last worked". The stand-down relies on that, so a broken mailbox is not hammered once per open inbox.
-
TRAP: the reply tag is matched by content, not shape. Never tighten
TicketNumbering::REF_PATTERNto the current number format: every email ever sent quotes the format of its day. - TRAP: Gmail cannot move to a folder. Move to folder on a Google mailbox marks the email read and leaves it where it is.
Mail collection is getting a scheduled task of its own (agreed alongside #182):
- a command-line script that checks every active mailbox through the same code and the same lock, so mail arrives with nobody signed in;
- while a scheduled run has been seen in the last few minutes, the inboxes' own polls stand down; if it stops, they notice and take over again, so mail never stops for long;
- it becomes one of the jobs on the background job health page.
This page will be updated when it ships.
- Mailbox checks overlapped (#183) - why the lock and the stand-down exist
- Mailbox authentication Β· Basic IMAP mailboxes Β· Gmail mail collection - Developer Guide
- Mailbox folder verification - Developer Guide Β· Attachment size limits - Developer Guide
- Multi-tenancy email routing Β· Ticket numbering - Developer Guide Β· Merging tickets - Developer Guide
- Email send log - Developer Guide - the outbound half
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- 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
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96