Tickets Settings Developer Guide - edmozley/freeitsm GitHub Wiki

Tickets settings - Developer Guide

A map of tickets/settings/index.php (Tickets -> Settings): how the page is put together, which tab does what, which endpoints each tab calls and which wiki page documents it. Read this before changing the file; read the tab's own page before changing a tab.

In plain words

Tickets -> Settings is one PHP file of about 9,450 lines. It holds 26 tabs, from "a list of words" (ticket types) to "the credentials of the company's mailbox" (mailboxes). Each tab has its own permission, so the page is built from the module's settings manifest: an analyst only gets the tabs they hold, and a tab they lack is never written into the page at all.

The file has four parts, top to bottom:

  1. PHP set-up - login and module checks, the manifest, and a few values worked out on the server so a tab opens on what is really in force (checklist mode, mandatory fields, row display).
  2. The tab bar and the panels - one <div class="tab-content"> per tab, each wrapped in settingsTabVisible().
  3. The modals - written once, after all the panels, outside any tab check.
  4. One big <script> - the loaders, renderers and save handlers for every tab.

How manifests, capabilities and setting keys work in general is in Roles - Developer Guide. This page only says how Tickets uses them.

The tabs

In manifest order, which is the order the tab bar draws them. The panels in index.php are not in the same order (Indexing, for example, sits before Merge behaviour in the markup) - the bar follows the manifest, not the file.

All capabilities are Cap::TICKETS_* cases. Endpoints are under api/tickets/ unless a folder is given.

Tab id What it configures Capability Endpoints Documented in
departments Departments, their default confidentiality, and which teams see them TICKETS_DEPARTMENTS get_departments, save_department, delete_department, get_teams, save_department_teams Admin Access Control, Confidential tickets
ticket-types Ticket types, and hiding a type per company TICKETS_TICKET_TYPES get_ticket_types, save_ticket_type, delete_ticket_type, set_ticket_type_hidden Multi-Tenancy - Developer Guide
ticket-origins Ticket origins, and hiding an origin per company TICKETS_TICKET_ORIGINS get_ticket_origins, save_ticket_origin, delete_ticket_origin, set_ticket_origin_hidden Multi-Tenancy - Developer Guide
categories Categories, sub-categories, resolution codes, and whether each field shows on a ticket TICKETS_CATEGORIES get_ticket_classification, save_ticket_classification_settings, save_ticket_category, delete_ticket_category, save_ticket_resolution_code, delete_ticket_resolution_code Ticket categories - Developer Guide
checklists Whether outstanding mandatory SOP steps block closing TICKETS_CHECKLISTS save_checklist_settings Checklist closure gating - Developer Guide
mandatory-fields Fields that must be filled before closing, and who is emailed TICKETS_MANDATORY_FIELDS (sensitive) save_mandatory_fields_settings Mandatory fields - Developer Guide
custom-fields Custom fields per ticket type, and where the portal shows the answers TICKETS_CUSTOM_FIELDS get_ticket_fields_settings, get_ticket_types, save_ticket_field, delete_ticket_field, reorder_ticket_fields, department_field_settings; api/settings/ for ticket_portal_field_answers Custom ticket fields - Developer Guide, Department field settings
statuses Ticket statuses (closed, pauses SLA, default) TICKETS_STATUSES get_ticket_statuses, save_ticket_status, delete_ticket_status Configurable lookups
priorities Ticket priorities TICKETS_PRIORITIES get_ticket_priorities, save_ticket_priority, delete_ticket_priority Configurable lookups
sla SLA targets per priority, calendars and holidays, breach notification rules, cron activity TICKETS_SLA get_sla_settings, save_sla_global_settings, save_priority_sla, list_timezones, get_sla_calendar, save_sla_calendar, delete_sla_calendar, get_sla_notification_rules, save_sla_notification_rule, delete_sla_notification_rule, get_sla_cron_runs SLA Management
rota-locations Rota locations TICKETS_ROTA_LOCATIONS get_rota_locations, save_rota_location, delete_rota_location Rota copy and paste
mailboxes Ticket mailboxes, sign-in, allow list, folder checks, activity log, oversized-email defaults TICKETS_MAILBOXES (sensitive) get_mailboxes, save_mailbox, delete_mailbox, mailbox_oauth_start, mailbox_logout, check_mailbox_email, verify_mailbox_folder, get_mailbox_whitelist, save_mailbox_whitelist, set_mailbox_health_dismissed, get_mailbox_activity, get_mailbox_outbound, oversize_settings; api/system/get_tenants Mailbox authentication, Mailbox providers, Folder verification, Attachment size limits, Email send log
messaging Messaging channels (WhatsApp through Twilio or Meta, Telegram, Teams, Mattermost) and message templates TICKETS_MESSAGING (sensitive) api/messaging/: get_channels, save_channel, delete_channel, test_channel, save_base_url, teams_package, telegram_connect, get_templates?manage=1, save_template, delete_template WhatsApp, Telegram, Teams and Mattermost
webchat Website chat widgets TICKETS_WEBCHAT api/webchat/: get_widgets, save_widget, delete_widget Web Chat - Developer Guide
email-templates Requester email templates, sender rules, the simulator, the public web address TICKETS_EMAIL_TEMPLATES get_email_templates, save_email_template, delete_email_template, simulate_email_template, get_public_base_url, save_public_base_url, set_base_url_warning_dismissed Email template sender rules, Public web address
reply-templates The team's shared reply templates TICKETS_REPLY_TEMPLATES get_reply_templates, save_reply_template, delete_reply_template Canned Responses - Developer Guide
time-tracking Time recording on or off, per company, screen and API separately TICKETS_MANAGE get_time_tracking_settings, save_time_tracking_settings Time tracking - Developer Guide
merge-behaviour What a merge does to references and conversations TICKETS_MERGE api/settings/ (merge_* keys) Merging tickets - Developer Guide
indexing The two switches that drain the attachment text queue TICKETS_INDEXING api/settings/ (attachment_extract_* keys) Attachment text extraction
rota Rota shifts, and whether the rota includes weekends TICKETS_ROTA get_rota_shifts, save_rota_shift, delete_rota_shift; api/settings/ for rota_include_weekends Rota copy and paste
numbering How ticket numbers are made, and renumbering TICKETS_NUMBERING (sensitive) numbering_preview, numbering_renumber; api/settings/ (ticket_number_* keys) Ticket numbering - Developer Guide
general Reopen on reply, snooze wake hour, the ticket workspace, who can export the ticket list, long-message display, long tickets, team folders, the AI reading aids TICKETS_GENERAL api/settings/ (keys below) Reading long tickets, Ticket AI reading, Snoozing tickets, Confidential tickets, Ticket workspace, Ticket table
privacy What a requester sees of their own ticket in the portal TICKETS_PRIVACY api/settings/ (portal_third_party_visibility) Portal privacy
reply-cleanup The reply-cleanup AI provider and key, plus tone and custom instructions TICKETS_REPLY_CLEANUP (sensitive) the shared AI panel (api/system/ai/), get_reply_cleanup_settings, save_reply_cleanup_settings AI providers
csat The customer satisfaction survey TICKETS_CSAT get_csat_settings, save_csat_settings CSAT company scope
row-display What your own ticket rows show none ('cap' => null) save_inbox_display -

api/settings/ means get_system_settings.php to read and save_system_settings.php to write.

How the page is assembled

require_once '../../includes/settings_manifest.php';
requireModuleAccess('tickets');

$settingsManifest = settingsManifestFor('tickets');
$conn             = connectToDatabase();
$visibleTabs      = settingsVisibleTabs($conn, (int) $_SESSION['analyst_id'], $settingsManifest);
$activeTabId      = settingsFirstTabId($visibleTabs);
  • settingsManifestFor('tickets') (includes/capabilities.php) finds tickets/settings/manifest.php among the manifests settingsManifests() globs.
  • settingsVisibleTabs() keeps a tab when its cap is null, when the analyst holds it, or when they hold one of its also_visible_to capabilities. Tickets uses no also_visible_to. A manifest stores a capability as a string - capKey(Cap::TICKETS_SLA) - and settingsVisibleTabs() turns it back with Cap::from() before calling analystHasCapability().
  • renderSettingsTabBar($visibleTabs, $activeTabId) draws the bar. Each button carries data-tab, data-capability (none for a preference tab) and onclick="switchTab('<id>')".
  • Each panel is wrapped like this:
<?php if (settingsTabVisible($visibleTabs, 'sla')): ?>
<div class="tab-content<?php echo $activeTabId === 'sla' ? ' active' : ''; ?>" id="sla-tab"
     data-capability="<?php echo capKey(Cap::TICKETS_SLA); ?>">

A panel's id is always <tab id>-tab. The row-display panel has no data-capability attribute.

Hiding a tab by capability

A tab is hidden by not holding its capability - there is nothing else to switch. To give a role one tab, tick that tab's capability in System -> Roles; TICKETS_MANAGE is the umbrella that holds all of them (and is itself the capability of the Time tracking tab). An administrator holds everything.

Inside a tab, a control can be gated on its own. The Row display tab is everyone's, but its "make this the default for everyone" button is only rendered when analystHasCapability($conn, ..., Cap::TICKETS_MANAGE), and save_inbox_display.php checks the same thing for scope = install.

Setting keys

Several tabs save through the generic writer api/settings/save_system_settings.php. That endpoint checks every posted key before writing any of them:

  1. settingKeyOwner($key) (includes/settings_keys.php) - no owner, and the whole post is refused with "Unknown setting".
  2. analystCanWriteSettingKey($conn, $analystId, $key) - the analyst must hold the owning tab's capability.

The owners come from setting_keys on each manifest tab, gathered by settingKeyOwners(). Tickets declares its keys on these tabs:

Tab Keys
categories ticket_category_enabled, ticket_closure_category_enabled, ticket_resolution_code_enabled
checklists ticket_checklist_closure_mode
mandatory-fields ticket_mandatory_fields, ticket_mandatory_mode, ticket_mandatory_notify, ticket_mandatory_record
custom-fields ticket_portal_field_answers, ticket_portal_field_ask, ticket_department_shown, ticket_department_default
merge-behaviour merge_reference_mode, merge_originals_mode, merge_ai_summary
indexing attachment_extract_cron, attachment_extract_opportunistic
rota rota_include_weekends
numbering ticket_number_style, ticket_number_format, ticket_number_start, ticket_number_scope, ticket_number_reset, ticket_number_renumber
general reopen_on_customer_reply, snooze_wake_hour, ticket_workspace_mode, ticket_list_export, ticket_reply_to, ticket_collapse_* (5), ticket_group_older, ticket_group_show, ticket_flag_duplicates, ticket_team_folders, ticket_ai_summary_* (4), ticket_ai_read_enabled, ticket_ai_confidential
privacy portal_third_party_visibility
reply-cleanup tickets_reply_cleanup_provider, _model, _api_key, _verify_ssl, _tone

Two Tickets keys are still listed by hand in settingKeyOwners() rather than in the manifest: time_tracking_enabled and time_tracking_api_enabled (owned by TICKETS_MANAGE). The comment there says the manifest is the pattern to copy, not that block.

Adding a setting to a tab that saves through save_system_settings.php: add its key to that tab's setting_keys. Forget, and saving the tab fails with "Unknown setting" - for administrators too.

The Reply cleanup tab's provider and key go through the shared AI panel (renderAiSettingsPanel('tickets_reply_cleanup'), includes/ai_settings_panel.php, assets/js/ai-settings.js), whose endpoints authorise with analystCanManageAiNamespace() - which looks up the owner of tickets_reply_cleanup_api_key.

The JavaScript

All of it is one inline <script> near the end of the file.

Constants

Name What it is
API_BASE '../../api/tickets/'
API_SETTINGS '../../api/settings/'
API_TICKETS '../../api/tickets/' again, used by the Reply cleanup, CSAT and Numbering code
MSG_API '../../api/messaging/'
WEBCHAT_API '../../api/webchat/'
MAILBOX_PROVIDERS json_encode(MailboxProvider::forJs()) - the Mailboxes tab's provider badges. See Mailbox providers
MESSAGING_PROVIDERS json_encode(MessagingProviderKind::forJs()) - the Messaging tab's badges, drawn by messagingProviderBadge(). A channel with no provider saved is drawn as Twilio. The enum has more cases (the web chat widget, Slack) than the channel dialog's provider dropdown offers
EVENT_LABELS The email template events and their English labels

Switching tabs

switchTab(tab) sets currentTab, moves the active class to the button with data-tab="<tab>", and shows #<tab>-tab. It assumes the panel exists.

On load the page opens a tab in two cases:

  • ?oauth=success&mailbox_id=... (back from a mailbox sign-in) opens mailboxes.
  • Otherwise a hash names a tab: settings/#sla opens SLA. It only switches if the tab button is shown and #<id>-tab exists, so a hash for a tab the analyst lacks leaves the page on its first tab. Feature Bingo's "Take me there" links use this.

Loading

There is no per-tab lazy loading. Two DOMContentLoaded handlers start the loaders for every tab when the page opens - the first calls loadTeams() then loadDepartments(), and loadTicketTypes(), loadTicketOrigins(), loadCustomFields(), loadTicketStatuses(), loadTicketPriorities(), loadRotaLocations(), loadMailboxes(), loadChannels(), loadWidgets(), loadMsgTemplates(), loadEmailTemplates(), loadReplyTemplates(), loadRotaShifts(), loadRotaWeekendSetting(), loadSlaTab(); the second calls loadGeneralSettings(), loadPrivacySettings(), loadMergeSettings(), loadReplyCleanupSettings(), loadCsatSettings(). loadOversizeDefaults() and loadIndexingSettings() are called directly as the script runs.

Because those run whatever the analyst can see, a loader must return early when its panel is not there. loadWidgets() (if (!tbody) return;), loadMergeSettings() and loadIndexingSettings() show the pattern.

Several tabs read the whole of get_system_settings.php and pick their keys out of data.settings; there is no key filter.

The newer tabs - Time tracking, Categories, and the close-gate block (Checklists and Mandatory fields) - are self-contained IIFEs that look for their own panel or button and return when it is absent. The close-gate block is deliberately separate from the Categories one: the Categories IIFE returns early when its tab is absent, so a role holding Checklists but not Categories once had a Save button that did nothing.

Checklists and Mandatory fields have nothing to load in JavaScript: their controls are rendered already set from $checklistClosureMode and $mandatoryCfg, which the PHP at the top reads. Row display is the same, from inboxDisplayForAnalyst().

Saving

Each tab saves with its own fetch() - a JSON POST, then showToast() (from assets/js/toast.js) with the result. There is no shared save helper. Forms (generalSettingsForm, csatSettingsForm, mergeBehaviourForm and so on) use a submit listener with preventDefault(); the newer tabs use a button click listener.

Modals

The modals are written once, after the last panel and outside every tab check, so they are in the page for everyone. Each is a <div class="modal" id="..."> opened by adding the active class.

  • editModal is shared by the simple lookups. openAddModal(type), editItem(type, id) and deleteItem(type, id, name) take a type of department, ticket-type, ticket-origin, status, priority or rota-location and pick the endpoint from a map; configureModalFields(type) shows the fields that type has. The maps also still hold a team entry from before teams moved to System -> Teams.
  • The rest belong to one tab each: tfModal (custom fields), tcCategoryModal and tcCodeModal (categories), mailboxModal, channelModal, widgetModal, msgTemplateModal, plus the email template, reply template, rota shift and SLA calendar modals. slaNotifModal is the exception - it sits inside the SLA panel.

renderOversizeFields($px, $withLimits) is a PHP function at the top of the file that draws the oversized-email fields twice: the install defaults on the Mailboxes tab (prefix ovd) and a mailbox's own rules in its dialog (prefix ovm). The script reads and fills them with oversizeFields(px) and setOversizeFields(px, s).

Traps

There are no TRAP: comments in this file. These are the warnings its comments carry, and what they protect.

  • The script runs even when a tab is not rendered. The panel is capability-gated; the <script> is not. One addEventListener on a null element throws, and every top-level statement after it in that script never runs - so a broken guard on one tab can silently break Save on tabs further down. Guard every DOM lookup (if (!el) return;), or put the tab's code in an IIFE that checks for its panel first. The submit listeners use ?.addEventListener, and the start-up block calls each tab's loader only when hasTab('<id>') finds its <id>-tab panel - add a new tab's loader the same way.
  • Make active conditional on $activeTabId. Hard-coding class="tab-content active" on the first panel leaves an analyst who cannot see that tab looking at no panel at all. See Roles - Developer Guide.
  • An unloaded checkbox looks like OFF. The Categories block stays locked until its load succeeds, because its three switches are drawn unticked and a failed load would otherwise save "all off" as fact. The AI summary and AI reading switches on General default to false on purpose: for those, OFF is the safe way to be wrong.
  • A saved 0 is not "not set". ticket_ai_summary_auto_after is read with Number.isInteger(), not || N, because 0 means "never refresh by itself".
  • Defaults on screen must match the engine's. loadGeneralSettings(), loadMergeSettings() and loadPrivacySettings() each name the PHP function whose default they mirror (customerReplyReopensTickets(), mergeSettings(), portalThirdPartyPolicy() and others). Change one, change both. The workspace (Off) and export (Administrators only) dropdowns fall back the same way as ticketWorkspaceMode() and ticketListCanExport() when a value is missing or unknown.
  • An enum setting is checked before it is stored. ticket_workspace_mode, ticket_list_export and ticket_reply_to must be a case of TicketWorkspaceMode / TicketListExport / TicketReplyTo; api/settings/save_system_settings.php refuses the whole save otherwise (its $enumKeys table - add a row there for the next one). Their dropdowns are built from ::cases(), so a new case appears without touching the page.
  • Click handlers on icon buttons use closest(). The buttons hold an inline <svg>, so a click lands on the svg or its <path>; testing e.target.classList silently does nothing.
  • The Row display preview is a second renderer. It emits the real inbox class names against the real inbox.css, but it is not inbox.js. Rename a class in one and rename it in the other.
  • A mailbox field hidden for one provider must lose required. A hidden required input still blocks the form; toggleProviderFields() and toggleAuthModeFields() switch required to match.

Related pages

The Department field (4.9.0, #218)

The Department field panel on the Custom fields tab (#deptFieldPanel) hides the ticket Department field and sets the department new tickets start in, install-wide and per company. It reads and saves through api/tickets/department_field_settings.php (under TICKETS_CUSTOM_FIELDS), not the generic writer, because that endpoint runs the guards: Department cannot be hidden where Mandatory fields requires it at closure, and a default must be an active department. Two other tabs carry guards for it - Mandatory fields (save_mandatory_fields_settings.php) refuses ticking Department where it is hidden, and Departments (save_department.php, delete_department.php) refuses deactivating or deleting a department that is a default. Hiding is presentation only; departments decide folders, rules and routing, not who may open a ticket.

The full write-up - storage, the default on every channel, the guards, the panel and the inbox code - is Department field settings - Developer Guide.

Reply goes to (ticket_reply_to, #120)

Who the Reply box is addressed to. includes/ticket_reply_to.php:

enum TicketReplyTo: string
{
    case Message   = 'message';     // the person on the message being read - the default
    case Requester = 'requester';   // always the ticket's requester
}
function ticketReplyTo(PDO $conn): TicketReplyTo   // Message when never saved or unknown

tickets/index.php hands the value to the inbox as window.TICKET_REPLY_TO, and openReplyModal() asks replyAddress() in assets/js/inbox.js:

function replyAddress(email, mode) {
    if (!email) return '';
    if (mode === 'requester' && email.requester_email) return email.requester_email;
    return correspondentAddress(email);
}
  • Message is correspondentAddress(), unchanged: the person on the message open - and on our own outbound message, its recipient, never our mailbox.
  • Requester is requester_email, whichever message is open. Anybody else is added by hand.

TRAP: a requester with no address falls back to correspondentAddress(), not to an empty box - and that function already refuses our own mailbox, so neither path can address the service desk to itself (the loop the comment above correspondentAddress() describes).

It only pre-fills the box. The analyst can always change the address before sending, so the setting decides a default, never who may be emailed.

tests/ticket-reply-to.php checks the setting's owner, its two values and the save endpoint's validation, then runs the real replyAddress() / correspondentAddress() in headless Chrome.

A reply without a ticket number (ticket_thread_replies, #120)

A reply with no [SDREF:...] in its subject - typically a requester replying to their own first email - used to become a second ticket. This setting lets it join the ticket its conversation already belongs to. The default is unchanged.

The shape of it

File What it does
includes/email_threading.php The enum, the setting reader, the IMAP key, the match
api/tickets/check_mailbox_email.php Asks Graph for conversationId; saveEmailToDatabase() matches and stores the key
includes/gmail.php Returns Gmail's threadId as thread_key
includes/mailbox_imap.php Fetches References / In-Reply-To with PEEK; imapReplyHeaders()
emails.thread_key + idx_emails_thread The stored key, indexed with mailbox_id
tickets/settings/index.php, manifest.php, save_system_settings.php The select on the General tab, its owner, enum validation
enum TicketThreadReplies: string
{
    case NewTicket  = 'new_ticket';    // as it always was - the default
    case SameTicket = 'same_ticket';   // join the ticket the conversation belongs to
}
function ticketThreadReplies(PDO $conn): TicketThreadReplies   // NewTicket when never saved or unknown

The thread key, per provider

Each provider already knows which conversation a message belongs to. One value per inbound email goes into emails.thread_key:

Provider Thread key
Microsoft 365 (Graph) conversationId - added to the $select of the message list
Gmail threadId
Basic IMAP the first Message-ID in References (the conversation's root), else In-Reply-To, else the message's own Message-ID

The IMAP rule makes an original and every reply to it agree: the original has no References, so its key is its own Message-ID, and a reply's References starts with that same ID.

function emailImapThreadKey(string $references, string $inReplyTo, string $messageId): ?string
{
    foreach ([$references, $inReplyTo, $messageId] as $h) {
        if (preg_match('/<[^<>\s]+>/', $h, $m)) return mb_substr($m[0], 0, 255);
    }
    return null;
}

The IMAP fetch asks for the two headers alongside what it already fetched - with PEEK, so reading them never marks the message read:

$item = $client->uidFetch($uid, '(UID ENVELOPE BODYSTRUCTURE INTERNALDATE BODY.PEEK[HEADER.FIELDS (REFERENCES IN-REPLY-TO)])');

TRAP: the server answers under BODY[HEADER.FIELDS ...], without PEEK - and some servers change the case or spacing. imapReplyHeaders() finds the key by its start, case-insensitively, and unfolds folded header lines before reading them.

The match

In saveEmailToDatabase(), right after the [SDREF] lookup, and only when that found nothing:

if ($ticketRef) {
    $ticketId = findTicketByNumber($conn, $ticketRef);
}
if (!$ticketId && ticketThreadReplies($conn) === TicketThreadReplies::SameTicket) {
    $ticketId = emailThreadTicket($conn, (int)$mailboxId, $threadKey, $email['in_reply_to'] ?? null);
}
if ($ticketId) {
    // the existing reply path: touch updated_datetime, reopen, wake, stripInboundThread()
}

So a thread match is handled exactly like a quoted ticket number - same reopen rule, same snooze wake, same ticket.reply_received workflow event, is_initial = 0.

emailThreadTicket() looks for the newest email in the same mailbox that is on a ticket:

SELECT ticket_id FROM emails
 WHERE mailbox_id = ? AND ticket_id IS NOT NULL
   AND (thread_key IN (?, ?) OR exchange_message_id IN (?, ?))
 ORDER BY id DESC LIMIT 1
  • The keys are the email's thread key and (Basic IMAP) its In-Reply-To.
  • exchange_message_id holds a Basic IMAP message's Message-ID, so a reply finds an original stored before thread keys existed. Graph and Gmail originals have no such fallback - their provider ids are not the conversation id - so for those the feature starts from the upgrade.
  • The result goes through resolveMergedTicket(), so a conversation on a since-merged ticket lands on the surviving one - as a quoted number does.
  • idx_emails_thread (mailbox_id, thread_key) keeps it an index lookup.

Why the same mailbox only: two mailboxes are two inboxes, often two teams or two companies. A conversation id matching across them would let an email to one desk land on another desk's ticket.

Storing the key before Database Verify has run

TRAP: mail collection must not stop on an install that has not run Database Verify yet. emailThreadingReady() checks once per request (SHOW COLUMNS FROM emails LIKE 'thread_key'); the importer only adds the column to its INSERT when it is there, and the match returns null without it.

$withThread = emailThreadingReady($conn);
$sql = "INSERT INTO emails (... is_initial, direction, mailbox_id" . ($withThread ? ", thread_key" : "") . "
    ) VALUES (... 'Inbound', ?" . ($withThread ? ", ?" : "") . ")";
...
if ($withThread) $params[] = $threadKey;

TRAP: never str_replace() a fragment of a multi-line SQL literal. The first version did, looking for "mailbox_id\n ) VALUES" - but the file is CRLF, so the literal holds \r\n, the replace never matched, and the extra parameter made every import fail with HY093. The test caught it; the column list is now built directly.

tests/email-thread-replies.php covers the keys, the header reader, the match (same mailbox, another mailbox, a legacy IMAP original, In-Reply-To, a merged ticket) and the real importer - see Developer Tests - Tickets.

⚠️ **GitHub.com Fallback** ⚠️