Skip to content

Inbox JS Developer Guide

Ed Mozley edited this page Oct 25, 2026 · 1 revision

Inbox.js - Developer Guide

A map of assets/js/inbox.js, the front-end of the ticket inbox, for a developer about to change it Β· user page: Tickets

In plain words

assets/js/inbox.js is the whole browser side of Tickets β†’ Inbox (tickets/index.php): the folder pane on the left, the ticket list in the middle, the reading pane on the right, and every modal, menu and side panel those three open. It is about 12,600 lines of plain JavaScript, no framework and no build step. Everything is a top-level function or let in one shared scope, and the page's markup calls those functions by name through inline onclick= attributes.

Each feature in it has its own wiki page (listed at the end). This page is the map that ties them together: what lives where, what state it keeps, how it starts, how it talks to the server, and the traps worth knowing before an edit.

⚠️ Line numbers below are "around line N" as of 4.0.0. They drift with every edit. Grep for the function name rather than jumping to a number.


The file, top to bottom

Around line Region Main entry points
1 API_BASE, the close-message config cache, core state closeMessageEnabled()
50 Safe email bodies, rendered inside a Shadow DOM safeEmailHtml(), emailBodyHost(), hydrateEmailBodies()
160 Collapsing long messages, folding the older part of a long ticket mcApply(), mcSweep(), tgGroupOlder()
330 Lookup lists and the per-company versions of them ticketCompanyId(), listForTicket(), assignableAnalysts()
435 Small shared helpers logAudit(), getDisplayName(), sharedApiBase()
478 Folder grouping preference, the view filter, Search "Include closed" loadFolderGroupingPreference(), setViewFilter(), setFolderGrouping()
676 Page start-up (DOMContentLoaded) see How it starts
712 Composer attachments and the TinyMCE editor initAttachmentHandlers(), initTinyMCE()
861 Lookup loaders loadDepartments(), loadTicketTypes(), loadTicketClassification(), loadAnalysts() and friends
1084 Folder pane loadFolderCounts(), renderFolders(), toggleFolder(), selectFolder(), selectDeptStatus() etc.
1529 Drag tickets onto folders attachEmailDragHandlers(), attachFolderDropHandlers(), handleTicketDrop()
1900 Ticket list loadEmails(), renderEmailList(), loadInboxSlaIndicators()
2138 Multi-select, keyboard, bulk actions handleEmailRowClick(), onSelectionChanged(), applyToSelection(), bulkSetField()
2810 Reading pane selectEmail(), loadTicketById(), displayEmail()
3291 Recordings strip, links to problems / changes / tickets, the merge banner, the Links strip, tasks, escalate to a tracker buildLinksSection(), linkAddChoose(), loadTicketTasks(), openEscalateTrackerModal()
4388 The message thread, and the chat-channel composer (WhatsApp, Telegram, Slack...) loadCorrespondenceThread(), renderChannelComposer(), sendChannelMessage()
4769 Field changes from the reading pane assignDepartment(), assignStatus(), assignPriority(), assignOwner(), ...
5191 Mandatory fields at closure mandatoryFieldsCheck(), confirmCloseWithMandatoryFields()
5497 Delete, restore, delete for ever deleteTicket(), restoreTicketFromTrash(), permanentlyDeleteFromTrash()
5616 Audit history, refresh, escaping and date utilities, the properties panel showAuditHistory(), refreshCurrentView(), escapeHtml(), formatDateTime()
5895 CMDB objects, domains, projects and equipment on a ticket loadCmdbObjects(), loadTicketDomains(), loadTicketProjects(), loadTicketAssets()
6438 Notes and ticket attachments loadNotes(), renderNotes(), openNoteModal(), saveNote(), showAttachmentList()
6988 Reply and forward openReplyModal(), openForwardModal(), closeEmailModal()
7127 Split, change subject, merge openSplitModal(), openSubjectModal(), openMergeModal()
7778 Reply templates, Reply cleanup (AI), sending toggleReplyTemplateMenu(), cleanupReplyDraft(), sendEmail()
8339 Custom fields, the pane's More menu, the New Ticket modal, the requester picker loadTicketCustomFields(), openNewTicketModal(), createNewTicket(), initRequesterPicker()
8995 Search panel openSearchModal(), performSearch(), performContentSearch()
9295 Schedule modal openScheduleModal(), saveSchedule()
9506 Ask AI chat openTicketAiChat(), askTicketAi(), formatTicketAiResponse()
9711 Pop-out view, time entries toggleTicketPopout(), loadTimeEntries(), saveTimeEntry()
9904 Right-click menu, and the same menu as a phone sheet openTicketContextMenu(), set*FromContext(), openTicketMenuSheet()
10706 Snoozing, and the row display chips snoozeFromContext(), wakeTickets(), inboxRowStripes(), inboxRowChips()
11124 Collision detection (who else is here) startPresence(), beatPresence(), leavePresence()
11328 Trash menu, and the remaining right-click actions emptyTrash(), openContextLinkTask(), openContextLinkCmdb(), openContextRecordTime()
11701 SLA panel loadSlaState(), renderSlaPanel()
11784 Write this up (Knowledge assistant) buildWriteUpButton(), openWriteUpModal(), wuStream()
12040 Signatures in the composer loadMySignatures(), applyDefaultSignature(), pickSignature()
12209 AI reading aids: the standing summary and "Read this ticket for me" loadAiSummary(), openReadForMe(), showAiPanel()

How it starts

inbox.js has no init function of its own; the work is in one DOMContentLoaded listener (around line 676):

  1. Fire the lookup loaders in parallel, none awaited: loadDepartments(), loadTicketTypes(), loadTicketClassification(), loadTicketTeams(), loadTicketOrigins(), loadTicketStatuses(), loadTicketPriorities(), loadAnalysts(), loadMoveCompanies(), loadMultiSelectPanePreference(), loadSearchIncludeClosedPreference().
  2. Wait for both loadFolderGroupingPreference() and loadViewFilterPreference() (inboxPrefsReady), then updateViewFilterUI(), loadFolderCounts() and loadEmails(). The counts and the list both depend on the view filter, so neither is fetched before it is known.
  3. initTinyMCE() and initAttachmentHandlers() for the reply/forward composer.
  4. If the URL has ?ticket_id=, call loadTicketById() after a 500 ms delay. This is how deep links from other modules open a ticket.

Several regions also wire their own document-level listeners as the file loads (outside clicks and Escape for the view filter, the menus, the search panel; pagehide / visibilitychange for presence).


Global state

All state is top-level let. The ones that matter across regions:

Variable Holds
emails The rows in the ticket list, from get_emails.php
selectedEmailId What the reading pane is showing. A negative value is a ticket with no email (the row id is -ticket_id)
selectedEmailIds, selectionAnchorId, selectionFocusId The multi-selection, the Shift+click anchor, the keyboard cursor
currentEmail The ticket open in the reading pane (set by displayEmail())
currentFilter Which folder is chosen: { type: 'all' }, department, dept_status, team, team_status, analyst, analyst_status, unassigned, unassigned_status, all_status, trash, snoozed
folderGrouping 'department', 'analyst' or 'team'
viewFilter { mine, hide_closed }
folderCounts, expandedFolders The badge numbers, and which folders are open
departments, ticketTypes, ticketOrigins, ticketStatuses, ticketPriorities, ticketTeams, ticketCategories, ticketResolutionCodes, analysts Lookup lists, loaded once at start-up
ticketTypesByCompany, ticketOriginsByCompany, ticketCategoriesByCompany, ticketCodesByCompany, classificationByCompany Per-company lookups, used only in the All companies view (allCompaniesView)
ticketClassification Which of category / closure category / resolution code are switched on. Starts all false
moveCompanies, isMultiCompany Companies the analyst can move tickets into
currentTicketChannel, currentChannelWindowOpen, currentChannelProvider, channelRefreshTimer Chat-channel state for the open ticket ('email' for an ordinary ticket)
currentNotes, ticketAttachments, currentTimeEntries, currentRecordings Per-ticket panels
emailEditor, emailAttachments, composeMode The TinyMCE composer, its files, and 'new' / 'reply' / 'forward'
ctxTargetTicketId, ctxActsOnSelection Which ticket the right-click menu was raised on, and whether it acts on the whole selection
loadEmailsToken Guards loadEmails() against out-of-order responses

Most feature regions keep their own small set beside their code (for example splitSelected, mergeCandidateList, snoozeModalTargets, presenceTicketId, scheduleTargetId, _aiSummaryTicketId). They are declared at the top of their region, not at the top of the file.


How the parts hang together

Folder pane β†’ list. Clicking a folder calls selectFolder() (or selectDeptStatus(), selectTeamStatus(), selectAnalystStatus(), selectUnassignedStatus(), selectAllStatus()), which sets currentFilter and calls loadEmails(). renderFolders() branches on folderGrouping. updateActiveFolderClasses() moves the highlight without rebuilding the tree, so the expand transition still plays.

List β†’ reading pane. loadEmails() builds the get_emails.php query from currentFilter plus viewFilterParams(), then renderEmailList() draws the rows, wires drag handlers and calls loadInboxSlaIndicators() (one batch request for every SLA dot). A row click goes through handleEmailRowClick(). A plain click ends in selectEmail(), which fetches get_email_detail.php and hands the result to displayEmail().

displayEmail() is the one funnel. Every way of opening a ticket ends there: list, deep link, search result, keyboard. It builds the toolbar, the fields and the action buttons as one innerHTML, then calls hydrateEmailBodies() and starts every per-ticket loader: loadAiSummary(), loadTicketChecklists() (from checklists/ticket_view.js, if loaded), loadCorrespondenceThread(), loadNotes(), loadTicketAttachments(), loadCmdbObjects(), loadTicketDomains(), loadTicketProjects(), loadTicketAssets(), loadTicketTasks(), loadTimeEntries(), loadSlaState(), loadTicketCustomFields(). Then startPresence(), syncPopoutToTicketState(true) and updateSelectionSurfaces(). A new per-ticket panel belongs in that list.

Field changes. The reading-pane dropdowns call assign*() functions, which post one field to assign_ticket.php and then call logAudit() with the readable old and new values. Closing goes through confirmCloseWithMandatoryFields() first, from every path: the dropdown, the right-click menu, a drag onto a closed status and the bulk actions.

Right-click menu. openTicketContextMenu() sets ctxTargetTicketId, which may not be the ticket in the reading pane. The populateContext*Submenu() functions build the submenus from the same lookups as the dropdowns. The set*FromContext() handlers act on that ticket, or on the whole selection when ctxActsOnSelection is true. On a phone (ticketMenuIsSheet(), 768 px or narrower) the same element is shown as a full-screen sheet by openTicketMenuSheet().

Bulk actions. applyToSelection() sends the selected ticket ids in chunks of BULK_CHUNK (25) to bulk_update_tickets.php or bulk_delete_tickets.php, and shows progress in its own surface (the summary pane or the bar), not in a toast. The server side calls the same TicketsService method as a single change, once per ticket. What the screen shows while several are selected comes from the analyst's tickets_multiselect_pane preference: summary, keep or bar.

Writing. Notes use noteModal (openNoteModal(), saveNote() β†’ save_note.php). Email replies and forwards use emailModal with TinyMCE on #emailBody (openReplyModal(), openForwardModal(), sendEmail() β†’ send_email.php). Templates, signatures and Reply cleanup all work on that editor. Chat-channel tickets get an inline composer instead (renderChannelComposer()) that posts to the messaging API. While the thread is open the composer refreshes every 15 seconds.

Search is a draggable panel (openSearchModal()). performSearch() searches ticket fields through search_tickets.php. performContentSearch() searches message bodies and notes through search_content.php. Picking a result calls selectEmail(), and the panel stays open.

AI aids are four separate things: Ask AI chat (api/knowledge/ai_chat.php), Reply cleanup (ai_cleanup_reply.php), the standing summary and "Read this ticket for me" (api/tickets/ai_summary.php, api/tickets/ai_read.php, both off unless window.TICKET_AI says otherwise), plus the channel composer's suggest-reply and summarise actions and the merge summary.


Talking to the server

API_BASE is window.API_BASE || 'api/'. tickets/index.php sets window.API_BASE = '../api/tickets/' before loading the script. URLs are built three ways:

Form Resolves to Used for
API_BASE + 'x.php' ../api/tickets/x.php Most ticket calls
sharedApiBase() + 'system/...' ../api/system/... User preferences only
API_BASE.replace('tickets/', 'messaging/') ../api/messaging/ Channel composer (templates, send, attachments, AI)
Literal '../api/<module>/...' as written Other modules: problem / change management, CMDB, domains, projects, integrations, knowledge, tenants, and some ticket endpoints (tasks, assets, CMDB links, linked tickets, AI summary)
function sharedApiBase() {
    return API_BASE.replace(/[^/]+\/?$/, '');
}

⚠️ Because of the literal '../api/...' URLs, the file only works from a page one folder deep. Today the only page that loads it is tickets/index.php.

The busiest endpoints under api/tickets/:

Endpoint Called from
get_emails.php loadEmails()
get_ticket_counts.php loadFolderCounts()
get_email_detail.php selectEmail() (?id=), loadTicketById() (?ticket_id=)
get_ticket_thread.php loadCorrespondenceThread()
assign_ticket.php every assign*() and set*FromContext() field change, and drag-and-drop
log_ticket_audit.php logAudit()
bulk_update_tickets.php, bulk_delete_tickets.php applyToSelection()
get_tickets_sla_batch.php, get_ticket_sla.php SLA dots on rows, the SLA panel
get_notes.php, save_note.php notes
send_email.php sendEmail()
search_tickets.php, search_content.php the search panel
snooze_ticket.php, wake_ticket.php snoozing
split_ticket.php, merge_tickets.php, undo_split.php, undo_merge.php split and merge
ticket_presence.php collision detection

Field changes made through assign_ticket.php are audited client-side with logAudit(), because the page already knows the old value. A few paths audit server-side and skip logAudit(): moveToCompanyFromContext() (the endpoint writes it) and the bulk endpoint (it reads each ticket's real old value).


Preferences and remembered state

Key Where Read / written by
tickets_folder_grouping user preference loadFolderGroupingPreference(), setFolderGrouping()
tickets_view_filter (JSON) user preference loadViewFilterPreference(), setViewFilter()
tickets_search_include_closed user preference loadSearchIncludeClosedPreference(), saveSearchIncludeClosed()
tickets_multiselect_pane user preference (read only here, set in Preferences) loadMultiSelectPanePreference()
freeitsm_expanded_messages localStorage mcOpened(), mcRemember()
tickets_popout localStorage toggleTicketPopout(), selectEmailFullScreen(), syncPopoutToTicketState()
aiSummaryCollapsed localStorage renderAiSummary(), toggleAiSummary()

User preferences go through system/get_user_preference.php and system/set_user_preference.php. Saves are fire-and-forget.


The page that loads it

tickets/index.php holds every piece of markup inbox.js looks up by id: folderList, emailList, readingPane, noteModal, emailModal, newTicketModal, searchModal, ticketContextMenu and the rest. Many modals are static markup in the page and the script only fills them. Before the script tag, the page sets these globals:

Global Used for
window.API_BASE '../api/tickets/'
window.translations the common and tickets namespaces, for t() / tf() from assets/js/i18n.js
window.CURRENT_ANALYST_ID "My tickets", own time entries
window.MESSAGE_COLLAPSE message collapsing settings (includes/ticket_display.php)
window.TICKET_AI whether the AI summary and "Read this for me" are switched on
window.INBOX_ROW_DISPLAY what each row shows (includes/inbox_display.php)
window.KB_WRITEUP_ENABLED, window.TICKETS_SHOW_DOMAINS, window.TICKETS_SHOW_PROJECTS module access, so buttons that could only be refused are not drawn
window.SNOOZE_WAKE_HOUR the label for "Tomorrow" in the snooze menu
window.CASE_REPORT_URL, window.MYACCOUNT_API, window.PREFS_URL, window.KB_BASE URLs for the case report, signatures, Preferences, Knowledge

Script order at the bottom of the page:

Script Why it matters to inbox.js
assets/js/safe-html.js must load first: safeEmailHtml() / emailBodyHost() use it to clean every message body. If it is missing, bodies show as escaped text (fails closed)
assets/js/tz.js must load first: the date helpers (parseUTCDate, tzOpts, ymdInZone, the fmt* formatters). inbox.js no longer has its own copies
assets/js/schedule.js FreeITSMSchedule, read at load time for SCHEDULE_DEFAULT_MINUTES and parseNaiveDateTime
checklists/search_scoring.js, checklists/ticket_view.js checklists in the reading pane (loadTicketChecklists())
assets/js/ticket-fields.js shared ticket field rendering
assets/js/inbox.js
assets/js/mobile.js loaded after, because it wraps the global selectEmail and selectFolder for the phone pane stack

i18n.js, record-preview.js and TinyMCE are loaded in the <head>. showToast() and showConfirm() come from toast.js and confirm.js, which the header's includes/waffle-menu.php loads. An inline script after mobile.js polls mailboxes and get_inbox_activity.php every 60 seconds and calls refreshCurrentView() when something new has arrived.

Cache-busting

The script tag carries a hand-kept version: inbox.js?v=157 today. Any change to the file needs that number bumped in tickets/index.php, or browsers keep running the old copy. This includes adding a function nothing calls yet. Never trust the number written here, grep for it:

grep -rho "inbox.js?v=[0-9]*" --include=*.php .

inbox.css, mobile.js and mobile.css have their own ?v= numbers on the same page.


Traps

The marked TRAP: comments in the file, and the other warnings that cost the most when ignored:

Around line Trap
278 The thread is rendered newest first (loadCorrespondenceThread() reverses it). So "older messages" are the last ones on the page. tgGroupOlder() once folded the newest away.
397 assignableAnalysts() filters only what is offered. analysts stays complete, because getDisplayName('owner') reads it. Filtering the array would blank the name of an owner from another company.
3834 loadTicketTasks() clears tasksForTicket before the fetch, so a failed fetch cannot leave the previous ticket's tasks for the close warning in assignStatus() to count.
9635 formatTicketAiResponse() escapes the whole AI answer first, then adds its own markup. The model reads text a customer wrote, so it can be steered into replying with HTML.
11422 On a phone, Link to task opens a modal (openLinkTaskModal()), not the strip picker, because mobile.js has moved the Links strip out of the reading pane.
1943 loadEmails(): last response wins, not last click. loadEmailsToken drops any response that a newer request has overtaken.
19 closeMessagePromise is module scope and caches the promise. Moved inside the status handler, it would refetch on every close.
482 / 645 'team' must be in both grouping whitelists, or a saved Team grouping is silently dropped on reload.
381 ticketClassification defaults to all off. That is the safe direction: an unloaded field is hidden, not drawn empty and saved as null.
2812 selectEmail(): a negative id is a ticket with no email, loaded through loadTicketById(-id).
2422 displayEmail() replaces the reading pane's innerHTML. Anything that must survive it (the "keep" warning strip) is re-created afterwards, not placed in the page markup.
6808 formatFileSize() returns 'NaN undefined' for a null size. Callers guard rather than change it.
6799 A DMS link URL is typed by an analyst: safeExternalUrl() must vet it before it becomes an href.
- mobile.js wraps window.selectEmail and window.selectFolder. They must stay top-level function declarations. A let/const or a rename breaks the phone layout without an error on desktop.

Feature guides

Each page below documents one feature that lives (at least partly) in inbox.js.

The inbox itself The folder pane Β· Just my tickets, or no closed ones Β· Ticket views Β· All companies ticket view Β· Bulk actions Β· Snoozing tickets Β· Collision detection Β· Recent trail Β· Search corpus

Reading a ticket Email rendering and images Β· Reading long tickets Β· Ticket AI reading Β· Confidential tickets Β· Ticket categories Β· Mandatory fields Β· Case reports

Writing on a ticket Ticket notes Β· Canned responses Β· Email signatures - developer guide Β· Email signatures Β· Raising tickets for others Β· Telegram channel Β· Knowledge assistant Β· Time tracking

Changing the shape of tickets Splitting tickets Β· Merging tickets

Linking a ticket to other things Tasks on a ticket Β· Linking equipment to tickets Β· Domains Β· Projects internals 1 - architecture Β· Projects internals 8 - connections, templates and people Β· External issue trackers Β· Approvals and evidence 1 - change approvals Β· Checklists and SOPs Β· Checklists - closure gating Β· Checklists module house style

Across the app Architecture Β· Theming and dark mode Β· Notifications Β· LDAP Β· Self-service portal Β· Multi-company CMDB case study Β· Mobile-friendly FreeITSM Β· Mobile: Tickets Β· Mobile: Knowledge Β· Mobile-friendly techniques

Bugs fixed in inbox.js The folder said 99 and the list showed 96 Β· Escaped text could leave its attribute Β· Share with Requester reached nobody (#103) Β· Time logged from the right-click menu was two hours out (#116) Β· Workflow notes could never be written (#120) Β· Notes stamped with the server's clock (#126) Β· A new ticket arrived with no status (#79) Β· Asset deep link selected nothing (#84)

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally