-
-
Notifications
You must be signed in to change notification settings - Fork 29
Reading Long Tickets Developer Guide
The three automatic behaviours in the reading pane: shortening a long message, folding the older part of a long ticket, and flagging a message that has arrived before. All from discussion #104.
The AI half β the summary and the briefing β is in Ticket AI Reading β Developer Guide. The user-facing page is Reading Long Tickets.
Every behaviour documented here is presentation only. Read this before changing any of it:
-
Nothing is removed from the database. No
DELETE, noUPDATEof a body, no rewriting ofemails.body_content. The three behaviours add a wrapper element, move a node, or set a flag on a response. -
Nothing is withheld from the browser. The full body of every message β
including one flagged as a duplicate β is in the JSON and in the DOM. Collapsing
sets a CSS class; folding calls
appendChildinto a hidden<div>. Both are one tap from reversed. -
Duplicate detection only ever flags. It sets
same_as_id/same_kindon the response and never filters the array. - Everything survives the feature being switched off. Turn all three settings off and every message renders exactly as it did before any of this existed, because none of it changed anything that was stored.
Why so absolute: deciding where quoted history ends is genuinely hard. Every mail client marks a quoted chain differently, plenty do not mark it at all, and a forwarded message can look identical to a reply. Any approach is right most of the time and wrong some of the time β so a destructive rule is wrong every so often, permanently and invisibly, while a presentational rule that is wrong costs one click. That is the entire reason this is built the way it is, and it is why a change here that starts dropping content is a change to the design, not an optimisation.
β οΈ The one boundary, so this guide does not overstate itself. The invariant covers everything from the stored message onwards. It does not describe ingestion:stripInboundThread()inapi/tickets/check_mailbox_email.phpcuts the quoted chain off an inbound reply before it is stored, so what reaches the database is the new part of the message only. That is a deliberate, long-standing choice and is out of scope here β but if you are chasing "where did the rest of the email go?", it is there and not in anything on this page.The display-side twin,
stripQuotedThread()inget_ticket_thread.php, is non-destructive like everything else here: it shapes the response, not the row.
| File | What it holds |
|---|---|
includes/ticket_display.php |
The eight settings, with defaults and clamping |
assets/js/inbox.js |
mcApply / mcSweep / mcOpened / mcRemember, and tgGroupOlder
|
assets/css/inbox.css |
.mc-wrap, .mc-toggle, .tg-fold, .tg-toggle, .dup-note
|
api/tickets/get_ticket_thread.php |
Near-duplicate detection, server-side |
tickets/settings/manifest.php |
The eight keys on the general tab |
tickets/settings/index.php |
The controls, plus load and save |
tickets/index.php |
Emits window.MESSAGE_COLLAPSE
|
The feature request asked for a line threshold. Measuring real inbound mail says that is the wrong unit.
On a live install: 122 inbound emails, averaging 8,337 characters of which only
about 1,800 are visible text. 38 of them were laid out with <table> and 26
carried their own <style>. A vendor notification β "Your Microsoft invoice is
ready" β was 57,750 characters, a handful of source lines, and rendered about
a metre tall.
Any line-based rule gets that message exactly backwards. So the trigger is the height the browser actually lays out:
/* β οΈ Measured AFTER the body is in the document. The HOST is what to
measure: its height comes from its shadow content, so it reports the
real rendered height while the shadow root itself has no scrollHeight
and its first child is the injected <style> β which is 0 tall and would
quietly make every message look short enough to leave alone. */
const full = Math.max(host.scrollHeight, host.offsetHeight);
const limit = MC.collapse_px || 264;That comment is the bug that nearly shipped. Message bodies are isolated in a
shadow root (see emailBodyHost), and measuring the shadow root gives you
its first child β the injected <style> element, zero pixels tall. Every message
would have measured as short and nothing would ever have collapsed, with no error
anywhere.
collapse_px is derived, never stored. The setting stays in lines because
"collapse after about 12 lines" is a sentence an administrator can reason about
and "collapse after 264 pixels" is not. One constant does the conversion:
/** Roughly what one line of message text occupies once rendered. */
const TICKET_COLLAPSE_LINE_PX = 22;
// β¦at the end of ticketDisplaySettings():
// The one place lines become pixels.
$defaults['collapse_px'] = $defaults['collapse_lines'] * TICKET_COLLAPSE_LINE_PX;$defaults[$k] = in_array($k, ['collapse_lines', 'group_show'], true)
// Clamped, not trusted. 4 lines is a peephole and 80 is no
// collapsing at all; both are worse than the default.
? max($k === 'group_show' ? 2 : 4, min(80, (int)$row['setting_value']))
: (int)(bool)(int)$row['setting_value'];The whole array is wrapped in try/catch returning defaults, because a reading
pane that cannot reach the settings still has to render.
Emitted server-side rather than fetched, so the first paint is already correct:
// tickets/index.php
window.MESSAGE_COLLAPSE = <?php echo json_encode(ticketDisplaySettings(connectToDatabase())); ?>;One object, read from includes/ticket_display.php, so the browser cannot
disagree with the server about what the settings say.
hydrateEmailBodies() is where every message body enters the DOM, so that is
where both sweeps hang. Nothing else needs to know:
// Isolate each thread body in a shadow root (see emailBodyHost).
hydrateEmailBodies(container);
tgGroupOlder(container);and mcSweep() walks the same hosts:
function mcSweep(root) {
if (!MC.collapse_enabled || !root || !root.querySelectorAll) return;
const hosts = root.querySelectorAll('.thread-message-body, .email-body-content');
hosts.forEach((h, i) => mcApply(h, {
newest: i === hosts.length - 1,
id: h.closest('[data-email-id]') ? h.closest('[data-email-id]').getAttribute('data-email-id') : ''
}));
}newest is positional β the last host in document order β rather than a date
comparison, because the thread is already rendered in order and re-deriving it
from timestamps is one more thing that can disagree.
if (host.dataset.mcDone) return; // rendered twice; measure once
β¦
if (!forced && full <= limit + 40) return; // a shade over is not worth a controlThe + 40 slack matters: without it a message one line over the limit gets a
Show more button that reveals one line, which is worse than no control.
Per device, in localStorage, never on the server:
function mcRemember(id, open) {
if (!MC.collapse_remember || !id) return;
try {
const set = mcOpened();
open ? set.add(id) : set.delete(id);
// Keep the last 400: a reading position from six months ago is not
// worth carrying, and localStorage has a hard quota that throws.
localStorage.setItem(MC_KEY, JSON.stringify([...set].slice(-400)));Both the read and the write are in try/catch β localStorage throws in a
private window and when the quota is hit, and a reading position is never worth
breaking a page for.
Eighty short messages defeat a per-message limit completely. Every one of
them is under it, mcApply returns early on all eighty, and the ticket is still
unreadable. This needs its own answer.
older.forEach(meta => {
const parts = [];
let n = meta.previousElementSibling;
if (n && n.classList.contains('thread-separator')) parts.push(n);
parts.push(meta);
n = meta.nextElementSibling;
while (n && !n.classList.contains('thread-meta') && !n.classList.contains('thread-separator')) {
const next = n.nextElementSibling;
parts.push(n);
n = next;
}
parts.forEach(el => fold.appendChild(el));
});A "message" in the rendered thread is a .thread-meta block plus everything up
to the next one β separator, meta, any duplicate note, and the body. Moving the
meta alone leaves the bodies behind, orphaned under a heading that has gone.
const next = n.nextElementSibling; before pushing. appendChild
moves the node, so reading nextElementSibling afterwards reads it from its new
parent and the walk stops after one element.
const metas = [...container.querySelectorAll('.thread-meta')];
if (metas.length <= show + 1) return; // folding one message saves nothingbtn.textContent = t('tickets.reading.older_messages').replace('{n}', older.length);
btn.setAttribute('aria-expanded', 'false');
fold.parentNode.insertBefore(btn, fold);
fold.hidden = true;hidden rather than display: none, so it is one property to toggle and screen
readers treat it correctly.
It is O(nΒ²) on the message count and get_ticket_thread.php already has the
whole thread in hand. Doing it in the browser would mean shipping every body to
compare it against every other body.
$text = strtolower(trim(preg_replace('/\s+/u', ' ',
html_entity_decode(strip_tags((string)($email['body_content'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'))));
$len = mb_strlen($text);Markup is not content. Two deliveries of the same message routinely differ in
their HTML β a tracking pixel, a different Message-ID in a footer, one hop's
worth of style rewriting β while reading identically. Comparing the rendered text
is comparing the thing a human would call "the same message".
// Too short to say anything useful. "Thanks" is not a duplicate of
// "Thanks" in any sense worth acting on.
if ($len >= 120) {
$hash = md5($text);
$head = mb_substr($text, 0, 300);
foreach ($fingerprints as $prev) {
$kind = null;
if ($prev['hash'] === $hash) {
$kind = 'identical';
} elseif ($prev['head'] === $head && $prev['len'] > 0
&& abs($len - $prev['len']) / $prev['len'] <= 0.03) {
$kind = 'near';
}
if ($kind !== null) {
$email['same_as_id'] = $prev['id'];
$email['same_as_time'] = $prev['time'];
$email['same_kind'] = $kind;
break; // the FIRST match is the original
}
}near is the one that earns its keep. The common real case is somebody
resending after hearing nothing, with "Resending as I have not heard back" added
at the bottom β same opening, length within a few percent, and a completely
different md5. An exact hash misses every one of those.
break on the first match is deliberate: the earliest matching message is the
original, and the note should point at it rather than at the most recent copy.
The message is returned in full. The browser folds it away regardless of height:
/* A message flagged as one that has arrived before is folded away whatever
its height: its length is not the reason it is noise. */
const forced = host.classList.contains('mc-force');
if (!forced && full <= limit + 40) return;
β¦
const startOpen = !forced && ((opts.newest && MC.collapse_expand_newest) || mcOpened().has(id));mc-force overrides both the height test and startOpen β including the
"newest message is always open" rule, since a duplicate arriving last is exactly
the case worth folding.
Two genuinely different messages that happen to open identically are a mild annoyance. A hidden one is not β hence flag, never remove.
A clean database is the worst place to test any of this. The emails that break a reading pane are the ones a real service desk receives.
scripts/insert_messy_thread.sh # newest open ticket
scripts/insert_messy_thread.sh 109 # a specific ticket id
scripts/insert_messy_thread.sh --clean # remove everything it madeIt builds Outlook top-posts with no <blockquote> (which our quote stripping
is known to miss), an EXTERNAL EMAIL banner, a four-paragraph disclaimer, a mobile
signature, an out-of-office, a bounce with raw headers, plus an exact duplicate
and a near-duplicate resend. Every row carries a [MESSY-TEST] subject prefix.
# β οΈ sed, not `grep -P`: this environment reports "-P supports only unibyte
# and UTF-8 locales" and refuses.
DBPASS="$(sed -n "s/.*DB_PASSWORD'[^']*'\([^']*\)'.*/\1/p" c:/wamp64/db_config.php 2>/dev/null | head -1)"
# β οΈ stderr is filtered, NOT discarded. Swallowing it made a failed INSERT
# look like a silent success under `set -e` β the exact trap this script is
# meant to help find in other people's data.
run() { "$MYSQL" -u "$DBUSER" -p"$DBPASS" "$DB" -sN -e "$1" 2>&1 | grep -v "insecure" || true; }Measuring "is anything wider than the screen?" is not enough β see Mobile Friendly Techniques Β§28. Drive the pane and check:
-
.tg-foldexists, ishidden, and holdstotal β group_showmessages - the button reads "{n} older messages" and opens the fold on click
-
.dup-noteappears on both the exact duplicate and the resend - the duplicates are inside
.mc-wrap.mc-collapsed -
with the settings saved as
0: no fold, every message visible, no notes
That last one is the important one. A toggle that persists but does not take effect looks identical to a working one in the database.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- 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: 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: 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
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ 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
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π 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
- 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
- β³ πΌοΈ 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