Repository navigation
Ticket Table Developer Guide
How the ticket list becomes a paginated, server-side table without becoming a second ticket list: one query shared with the message list, the shared data table's new server mode, rows dressed as message-list rows so every inbox action still works, and a CSV export that is a setting.
User page: The ticket table. Design: The ticket workspace - design (PR #206), section 6. Review: Ticket workspace, task editor and table - review (#211-#213).
Contributed by @Dr4inder as #213, the third of the pull requests that replaced draft #206. Changelog #2635, #2637.
Colour key: 📖 read · ✏️ write · 🖥️ UI · 🧩 shared engine · 🧪 tests
| File | 🎨 | What it does |
|---|---|---|
includes/ticket_list.php |
📖 |
The inbox's query, moved here from api/tickets/get_emails.php (ticketListBase()), and everything the table adds: ticketListQuery() (validation), ticketListSql(), ticketListRead() (a page + filter counts), the column maps, TicketListExport, ticketListCanExport(), ticketListCsvCell()
|
api/tickets/get_emails.php |
📖 | The message list - now three lines around ticketListBase()
|
api/tickets/table.php |
📖 | One page of the table |
api/tickets/table_export.php |
📖 | The whole list as CSV, if the setting allows |
assets/js/tickets-table.js |
🖥️ |
TicketTable: builds the table, maps the folder to filters, dresses the rows, the Messages / Table switch |
assets/js/data-table.js, assets/css/data-table.css, includes/data-table-skeleton.php
|
🧩 | The shared table: server mode, sortable / filterable column flags, onRowContextMenu / onRowsRendered hooks, $dtShowCsv
|
assets/js/inbox.js |
🖥️ |
ticketRowSelector(), and loadEmails() / renderEmailList() handing over to the table |
assets/css/tickets-table.css |
🖥️ | The table in the inbox's middle column |
tickets/index.php |
🖥️ | The container, the action-row dropdown, window.TICKETS_LIST_LAYOUT
|
includes/table_views.php |
📖 |
'tickets' added to TABLE_VIEW_KEYS - saved views |
tickets/settings/index.php, tickets/settings/manifest.php, api/settings/save_system_settings.php
|
✏️ |
Who can export the ticket list (ticket_list_export), checked against the enum |
system/preferences/index.php, includes/preference_templates.php
|
🖥️ |
tickets_list_layout (messages / table) |
tests/ticket-workspace-table.php, tests/data-table-server.php, tests/ticket-table-live.php
|
🧪 | See section 8 |
What you do not touch: the other five tables (Assets, Tasks, Calendar, Changes, Domains). They pass no server, so the engine behaves for them exactly as before - tests/data-table-server.php checks that.
The single most important rule: the table must never show a ticket the message list would not, nor miss one it would. So there is one query:
ticketListBase($conn, $analystId, $request, $extraColumns, $extraJoins)
├── get_emails.php - the message list, $_GET passed straight in
└── ticketListSql() - the table: adds search + column filters
├── ticketListRead() - COUNT, one page, the filter counts
└── table_export.php - every row, in batches
ticketListBase() holds the company scope, trash, snooze, merged-away tickets, and the My tickets / Hide closed filter - with all the comments they had in api/tickets/get_emails.php.
🔑 TRAP: change which tickets an analyst may see in
ticketListBase()only. A rule added to one endpoint and not here is a list and a table that disagree - and with the export, a CSV that contains a ticket the screen would not show.
The first draft added the table's columns with str_replace() on the inbox's SQL - which would silently match nothing the next time anyone edited that query. Now they are parameters:
[$sql, $args] = ticketListBase($conn, $analystId, [...],
['t.created_datetime', 't.status_id', 't.ticket_type_id', 't.priority_id',
'tp.display_order AS priority_order', 'qd.name AS department_name', ...],
['LEFT JOIN departments qd ON qd.id = t.department_id', ...]);They are trusted code - never request values.
ticketListQuery($_GET) turns the raw request into a checked array, and nothing after it reads $_GET. Anything it does not recognise is a 400, not ignored:
-
pagea whole number,page_size25, 50 or 100; -
sorta key ofticketListSorts(),dirasc/desc,viewactive/snoozed/trash; -
filtersJSON, at most one entry per filter column, at most 200 values each, each a positive id or'unassigned'; -
qat most 500 characters; the booleans0or1.
Every column name that reaches SQL comes out of one of three fixed maps, keyed by the table's own column keys:
| Map | Key → | Used for |
|---|---|---|
ticketListFilters() |
[row field, tickets column, lookup table, label column] |
the IN (...) filters, and the filter counts with their labels |
ticketListSorts() |
row field |
ORDER BY (priority sorts by display order, not name) |
ticketListExportColumns() |
row field | the CSV's columns |
🔑 TRAP: the search box goes through
likeContainsPattern()(includes/functions.php), never'%' . $term . '%'. Email subjects carry typographic quotes - Gmail sends We’re with U+2019 - and a keyboard types the straight one, so the plain pattern missed them (reported on the first day). Every apostrophe or quote becomes_, and a typed%or_is escaped.api/tickets/search_tickets.phpuses it too.
ticketListOrder() puts empty values last whichever way you sort, and adds ticket_id DESC as a tie-break - without it, rows with the same value could swap between two page loads and one ticket would appear on both pages while another appeared on neither.
ticketListRead() runs the query once per filter column leaving that column's own filter out ($omitFilter) and keeping every other one. So ticking Status = Open still shows how many are Closed - the list of values you could switch to. Six small grouped queries per page load; they share the page's scope, so a count can never reveal a ticket the analyst cannot see.
assets/js/data-table.js was built to load every row and do the rest in the browser. A ticket queue can be tens of thousands of rows, so the table gained a second mode, chosen by passing server:
createDataTable({
...,
server: {
load: async query => ({rows, total, page, pageSize, facets}),
exportCsv: async (query, abortSignal) => { ... },
},
});
// query = {page, pageSize, sort, search, filters, context}-
Any change to sort, filter or search reloads page 1 -
render()compares a signature of the query (less the page) with the one the rows on screen answer, and fetches when they differ. -
Last request wins. Each load takes a
generation; a slower, older response is dropped. -
A failed load keeps the old rows, greyed and
inert, with Retry. Never acted on - they may no longer be true. -
Filters list the server's values and counts (
facets), with the server's label for each id, and keep a ticked value at 0 so it can be unticked. -
manual: true- the table is started by its module (table.start()), not on page load, because the ticket table may never be shown. -
getContext/applyContextcarry state outside the table into a saved view and back: for tickets, the view (active / snoozed / trash), My tickets, Hide closed, and content search.
Every other table passes no server, and runs exactly as before.
The inbox's actions - click, Ctrl/Shift-click, the right-click menu, drag to a folder, the SLA dot, bulk actions, merge - all find rows with #emailList .email-item[data-email-id=...]. Rather than teach each of them about a table, onRowsRendered gives every <tr> what they look for:
node.classList.add('email-item', 'ticket-queue-row');
node.dataset.emailId = row.inbox_id; // the message list's row id
node.dataset.ticketId = row.ticket_id;
node.dataset.ticketNumber = row.ticket_number;
node.draggable = true;and assets/js/inbox.js looks in whichever list is showing:
function ticketRowSelector(suffix = '') {
return (window.TicketTable?.isActive() ? '#ticketTableContainer' : '#emailList') + ' .email-item' + suffix;
}row.inbox_id is the message list's row id: an email id, or the negative ticket id for a ticket with no email (see the 🔑 in ticketListBase()). It must be the same id the message list uses, or the reading pane, which keys off it, would not find the row.
⚠️ Exactly one list is in the page's flow at a time (TicketTable.paint()setshiddenon the other). If both were, a Shift-click range or a select-all would pick up rows from the hidden list.
⚠️ A page change clears the selection (onLoading). A bulk action must never reach a ticket on another page that is no longer on screen.
The folder is the starting filter. folderFilters() turns the folder pane's currentFilter into table filters - a department, team or analyst folder, its status sub-folder, or an Unassigned folder (empty in whichever column the folders are grouped by). Change a column filter so the rows no longer match the folder, and the folder highlight goes to All tickets rather than claim a folder the rows are not.
loadEmails() - called on a folder click, the view filter, and when the 60-second mailbox check finds something new - hands over to TicketTable.reload(), which replaces the filters on a new folder, goes to page 1 on a new view filter, and otherwise re-reads the current page.
A one-click export of the whole filtered queue is a new way for ticket data to leave FreeITSM, so it is a setting (design decision 2), TicketListExport:
enum TicketListExport: string
{
case Admins = 'admins'; // the default, also when never saved
case TicketUsers = 'ticket_users';
case Nobody = 'nobody';
}-
ticketListCanExport()is checked byapi/tickets/table_export.phpon every request, not only by hiding the button ($dtShowCsvintickets/index.php). A direct request is refused with 403. - The setting is under the
TICKETS_GENERALcapability, like the rest of the General tab - whoever may change General settings may open the export to everyone. That is deliberate; it is the same trust as the other settings there. -
Columns: only
ticketListExportColumns(), each once - an export is never a way to reach a field the table does not offer. - Every row, in batches of 500, in one transaction, so every batch reads the same snapshot and a ticket closed mid-export cannot appear twice or not at all.
-
Written to a buffer and sent only when complete (
php://temp, spilling to the system temp directory past 2 MB - never the web root), so a failure half way returns an error, not half a file that looks whole. -
Formula injection:
ticketListCsvCell()puts an apostrophe before a cell starting with= + - @(after any spaces or control characters). Ticket subjects are text strangers wrote. -
Times: created and snoozed-until are UTC and get a
Z. Work begins / ends are the wall-clock times as typed, with no zone - aZwould invent one (aTRAP:in the code). - UTF-8 with a BOM, so Excel reads accents.
The browser fetches it with an AbortController; clicking CSV again cancels.
- Select it in
ticketListSql()'s extra columns (with any join it needs). - Add it to
setup()'s column list inassets/js/tickets-table.js: key, the row field it shows, visible by default, sortable. - Sortable? Add it to
ticketListSorts(). Filterable (an id column)? Add it toticketListFilters()with its lookup table and label, and to thefilterablelist insetup(). In the CSV? Add it toticketListExportColumns(). - Its heading:
tickets.queue.columns.<key>inlang/en/tickets.php- used by the table and the CSV header. - A date: decide whether it is UTC or wall-clock, and format it in
displayValue()and inapi/tickets/table_export.phpto match.
| Test | Needs | What it covers |
|---|---|---|
tests/data-table-server.php |
Chrome | The engine's server mode against a fake server: paging, global sorting, stale responses, failure and Retry, the export's query - and that a table without server still loads all its rows |
tests/ticket-workspace-table.php |
database + site | The export policy (default, admins, nobody), unauthorised settings changes refused, the table's tickets equal the message list's, 525 rows across pages and CSV batches, sort and filter validation (an SQL expression as a sort is a 400), formula protection, date labelling, saved-view visibility |
tests/ticket-table-live.php |
database + site + Chrome | The real page: folder kept, the reading pane, selection, right-click, drag, search, paging, export |
The database tests need FREEITSM_TEST_URL and FREEITSM_TEST_DATABASE, and should run against a disposable install - see the workspace guide's testing section.
- The ticket table · Saved table views - Developer Guide · Inbox.js - Developer Guide
-
Ticket workspace - Developer Guide · Review (#211-#213) · The folder said 99 and the list showed 96 - why the query is driven from
tickets
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
- 🤝 Contributing (read before a pull request)
- 🧪 Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- 📅 Date & Time Formats
- 💱 Currencies
- 🧭 The sidebar (optional module rail)
- 🧩 Preference templates
- 🔀 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
- ↳ 🔌 Switching modules off
- ↳ 🛠️ Module availability — Developer Guide
- ↳ 📐 Module availability — design (#217)
- 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
- ↳ 📋 Proposal: custom fields, inventory import and change evidence (#194)
- ↳ 🛠️ Custom fields, evidence + inventory - review (#195-#197)
- ↳ 🛠️ Long forms in a task's Work view - review (#198)
- ↳ 🎫 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
- ↳ 🛠️ Inbox.js map — Developer Guide
- ↳ 🛠️ Tickets settings — Developer Guide
- ↳ 🗂️ The ticket workspace
- ↳ 📊 The ticket table
- ↳ 📋 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
- ↳ 📄 Case reports - a ticket and its work as one PDF
- ↳ 📅 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
- ↳ 🧩 Asset type layouts
- ↳ 🕒 Inventory freshness
- ↳ 🔗 Linked configuration item
- ↳ 🔌 Connected assets
- ↳ 🔍 Asset review
- ↳ 🏷️ QR asset labels
- ↳ 📋 Who holds what, and handover documents
- ↳ 🖥️ The inventory agent (PowerShell)
- ↳ 🗄️ Proxmox VE servers
- ↳ ☁️ VMware Cloud Director servers
- ↳ 🪟 Hyper-V and Failover Clusters
- ↳ 🔗 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)
- 📡 Zabbix (monitoring)
- 💬 Microsoft Teams alerts
- 📞 3CX (phone system)
- 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
- ↳ ⚙️ #214 A "contains" condition did not match capitals
- ↳ ⚙️ #216 A workflow email from a chosen mailbox always failed
- ↳ 🐳 The Docker image was missing PHP extensions
- ↳ 🌐 A failed DNS lookup read as a missing record
- ↳ 🔒 A link could change data
- ↳ 🔒 Access outlived its removal
- ↳ 🔒 Knowledge articles could run scripts
- ↳ 🔒 Knowledge writes ignored permissions
- ↳ 🔒 Company boundaries (Oct 2026)
- ↳ 🔒 Smaller gaps (Oct 2026)
- ↳ 🔒 Escaped text could leave its attribute
- ↳ 🔒 Task descriptions were not cleaned
- ↳ 🔒 Task documents ignored company scope
- ↳ 🔒 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