-
Notifications
You must be signed in to change notification settings - Fork 15
Canned Responses Developer Guide
How reply templates work under the hood, and why the permission model is shaped the way it is. Shipped as #909.
The user-facing page is Canned responses.
Colour key: ποΈ schema Β· βοΈ shared logic Β· π API Β· π₯οΈ UI Β· π¨ assets Β· π permissions Β· π i18n Β· π docs
| π¨ | File | What it does |
|---|---|---|
| ποΈ | database/freeitsm.sql |
CREATE TABLE ticket_reply_templates β the two indexes and both FKs |
| ποΈ | includes/db_verify_schema.php |
the same table in $schema, so a grown install gains it on verification |
| ποΈ | includes/db_verify_indexes.php |
generated β php scripts/gen_db_verify_indexes.php, never hand-edited |
| βοΈ | includes/reply_templates.php |
the whole brain. Merge-code list, the escaping renderer, the visibility rule, the write-scope lookup |
| βοΈ | includes/template_email.php |
reused, not modified β buildTicketMergeData() supplies the merge values |
| βοΈ | includes/tenancy.php |
reused β getTenantConfigRows() resolves shared templates per company |
| π | includes/capabilities.php |
Cap::TICKETS_REPLY_TEMPLATES |
| π | tickets/settings/manifest.php |
the reply-templates tab entry β which is what creates the capability and its tab |
| π | api/tickets/get_reply_templates.php |
list what this analyst may insert (?all=1 includes inactive) |
| π | api/tickets/save_reply_template.php |
create/update β holds the escalation rule |
| π | api/tickets/delete_reply_template.php |
delete, with the same scope split |
| π | api/tickets/render_reply_template.php |
resolve merge codes against a ticket β the only place substitution happens |
| π₯οΈ | tickets/settings/index.php |
the Reply templates tab, its TinyMCE modal, and the tab's JS |
| π₯οΈ | tickets/index.php |
the Templates button on the reply modal + the small "save as template" modal |
| π¨ | assets/js/inbox.js |
the picker: load, render, insert, save-personal, delete-personal |
| π¨ | assets/css/inbox.css |
.reply-tpl-* β the menu, its hover-revealed mini buttons |
| π |
lang/en/tickets.php + lang/pt-BR/tickets.php
|
44 keys, added in the same commit |
| π |
CHANGELOG.local.md, README.md, this wiki |
logged as #909 |
includes/reply_templates.php
β
βββββββββββββββββΌβββββββββββββββββ
β β β
settings tab reply picker render endpoint
(shared only) (both lists) (merge + escape)
Three callers need the same two answers β which templates may this analyst see? and what does this one say for this ticket? β so both answers live in one file. Two copies of the visibility rule is the dangerous kind of duplication: nothing breaks when they drift, one of them just quietly shows an analyst somebody else's private templates.
CREATE TABLE ticket_reply_templates (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
body LONGTEXT NOT NULL,
analyst_id INT NULL, -- NULL = SHARED team template
tenant_id INT NULL, -- NULL = global default (the CONFIG meaning)
is_active TINYINT(1) NOT NULL DEFAULT 1,
display_order INT NOT NULL DEFAULT 0,
...
)
β οΈ The trap. Two nullable columns, two different meanings ofNULL, in one row.
analyst_id IS NULLβ shared with the team.tenant_id IS NULLβ a global default shared by every company β the config-list meaning used byticket_types, not the scoped-data meaning used bytickets(whereNULLmeans "unrouted").A global shared template is therefore both columns
NULL, and a private one isanalyst_id = me. The pair is never a wildcard: reads filter on both axes. See Multi-Tenancy Concepts for why this codebase has three meanings ofNULLand why you must read a table's own comment before scoping it.
Shared templates resolve through getTenantConfigRows() β the same "global default + per-company override, minus what this company hid" primitive as ticket types. Private templates are deliberately never tenant-filtered: it's one person's text and it follows them between companies.
A settings tab equals a capability, everywhere in this app. That rule alone would have produced a broken feature.
The problem. If the Reply templates tab owns all templates, then an analyst's own private templates sit behind a settings permission β and most analysts don't have one. Personal canned responses, the half of the feature people use most, would have required an administrator to grant settings access to everyone.
The resolution. Split by scope, not by table:
| Scope | Where it's managed | What it needs |
|---|---|---|
shared |
Tickets β Settings β Reply templates | Cap::TICKETS_REPLY_TEMPLATES |
mine |
the reply window's own picker | nothing beyond Tickets module access |
Both live in one table and are served by one pair of endpoints. save_reply_template.php therefore contains two different permission rules, and the seam between them is where every interesting bug would be.
// Publishing to the team β or keeping something published there β is the settings
// action. Checked on the TARGET scope, so promotion is covered by the same line.
if ($scope === 'shared') {
requireCapabilityJson(Cap::TICKETS_REPLY_TEMPLATES);
}Not the scope the row had β the scope it's becoming. Without that, every analyst could promote their own draft into the team list and the settings permission would mean nothing.
The mirror case matters just as much:
if ($current === 'shared') {
requireCapabilityJson(Cap::TICKETS_REPLY_TEMPLATES);
}Editing something already shared is a settings action even when the save is trying to demote it to private β otherwise "make it mine" becomes a way to quietly delete a team template you had no permission to touch.
replyTemplateWriteScope() returns 'shared', 'mine', or null, by looking the row up. A client that could name its own scope could delete anything. null covers both "no such template" and "somebody else's private one", and says the same thing for each β a different message would confirm the id exists.
There is deliberately no branch that lets an administrator see other people's private templates. Don't add one.
Templates use the same [merge_code] placeholders as ticket_email_templates, resolved from the same builder:
$merge = buildTicketMergeData($conn, $ticketId); // includes/template_email.phpAn admin who has written one kind of template can write the other without relearning anything, and a merge code added there works here for free.
π But do not reuse
resolveMergeCodes(). It substitutes raw.
That's survivable for an outbound email whose only reader is the requester. It is not survivable here: a canned response is merged, dropped into the analyst's TinyMCE editor, and re-rendered in the inbox β and requester_name ultimately comes from the From header of an email a stranger sent us. A requester called <img src=x onerror=β¦> would be running script in the analyst's browser at the moment they try to answer them.
So renderReplyTemplate() escapes every value before merging:
foreach ($merge as $code => $value) {
// The template is trusted (an analyst authored it). The value is not.
$safe = htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
$body = str_replace("[$code]", $safe, $body);
}Template body = trusted. Substituted values = never. That is the whole security model of this file, and it's the reason rendering happens server-side at all β the picker could have merged client-side from the ticket already loaded in the inbox, in fewer lines, but the escaping rule would then live in JavaScript where the next caller needs its own copy and eventually gets it wrong. See safe-html for the same lesson learned the hard way on message bodies.
An unresolved code is left visible rather than blanked: a literal [requester_first_name] in a draft is obviously wrong to the analyst about to send it, whereas an empty gap reads as a finished sentence.
sanitiseUserHtml() is deliberately not applied to template bodies. It's the customer allow-list β it drops colours and styling, which is exactly the rich formatting a template exists for. And it would buy nothing: an analyst can already put arbitrary HTML in an outbound reply through send_email.php, so a template grants them no capability they lack. Revisit this the day template authoring reaches anyone who isn't staff.
| Endpoint | Guard | Notes |
|---|---|---|
get_reply_templates.php |
module access | No capability. Inserting a canned response is the everyday job, like replying at all β gate it and you don't tighten anything, you just show most of the service desk an empty picker |
render_reply_template.php |
module access | Re-checks analystCanAccessTicket() and template visibility |
save_reply_template.php |
module access, plus the capability when scope = shared
|
See Β§3 |
delete_reply_template.php |
module access, plus the capability for shared rows | Scope from the DB |
Capabilities guard writes, not reads β except a read returning credentials. Neither read here returns a secret, so both stop at module access. Both originally stopped at "is logged in", which D005 caught: these endpoints read a ticket's requester details through the merge codes, so an analyst whose only module is Calendar had no business calling them. Run
api/system/debug-tools/D005_endpoint_permissions.phpbefore any release.
render_reply_template.php re-resolves visibility through replyTemplatesVisibleTo() rather than a bare SELECT β¦ WHERE id = ?:
$visible = replyTemplatesVisibleTo($conn, $analystId, false);
foreach ($visible as $t) { if ((int)$t['id'] === $templateId) { $match = $t; break; } }A by-id lookup here is exactly how somebody else's private template would leak, one guessed integer at a time.
Nothing clever, but two behaviours are deliberate.
Insert at the cursor, never replace. emailEditor.insertContent(data.body) β a canned response is usually the middle of a reply, and silently discarding something the analyst had already typed would be unforgivable.
Re-fetch on every menu open. The inbox stays open for a whole shift; a colleague may have added a shared template since page load.
Function (assets/js/inbox.js) |
Does |
|---|---|
loadReplyTemplatesForPicker(force) |
fetch + cache the two lists |
toggleReplyTemplateMenu(event) |
open/close; re-fetches on open |
renderReplyTemplateMenu() |
Team section, Mine section, then Save draft as template |
insertReplyTemplate(id) |
POSTs to the render endpoint, inserts the result |
openSaveReplyTemplateModal(mode, id) |
'new' or 'update'; refuses an empty draft |
savePersonalReplyTemplate() |
always posts scope: 'mine'
|
deleteMyReplyTemplate(id) |
confirm, delete, re-render |
The settings tab's own set (loadReplyTemplates, renderReplyTemplates, openReplyTemplateModal, editReplyTemplate, deleteReplyTemplate, initReplyTemplateEditor, renderReplyTemplateMergeCodes, insertReplyTemplateMergeCode) lives inline in tickets/settings/index.php and always posts scope: 'shared' β which the endpoint re-checks, because the client naming its own scope proves nothing.
Two front-end details that will bite if you copy this pattern:
-
The settings page had no TinyMCE. Rich-text templates meant adding
assets/js/tinymce/tinymce.min.jstotickets/settings/index.php; the email-template body next door is still a plain textarea with an edit/preview toggle. -
Neither editor's content is visible to native form validation β the real input is an iframe. Both forms validate the body in JS and skip
required.
php -l proves nothing: a PHP fatal is served as HTTP 200, and a JS function can sit in a file that never parsed. What was actually done, and what to repeat:
-
The upgrade path. Ran
db_verifyon an install without the table and confirmed it reported "Table created with 9 columns" plus both restored indexes β not just thatfreeitsm.sqllooked right. - Every refusal paired with a positive control. An analyst without the capability was refused on create-shared, promote-own-to-shared, edit-shared, and delete-shared β and then succeeded at editing their own. A refusal on its own can just mean the endpoint is broken.
- Cross-analyst blindness, both ways. Confirmed the admin's picker does not list another analyst's private template, and vice versa.
-
A real payload.
users.display_namewas set to<img src=x onerror=alert(1)>, the template rendered, and the output checked for<imgβ then the name was put back. -
Headless Chrome parse-check with a negative control. Both pages were loaded in real Chrome and every new function's
typeofreported, alongside a deliberately fake name that had to come backundefined. Grepping rendered HTML for function names would have proved nothing. - D005 re-run, which found the Layer-1 gap in Β§5.
One live-test gotcha worth knowing: sending an em dash through a Windows shell mangles the UTF-8,
json_decode()correctly returnsnull, and the save fails with a confusing "Name is required". That was the harness, not the app β build such payloads withjson_encode()into a file andcurl --data-binary @file.
- Another channel. The picker is on the email reply modal only. Portal, WhatsApp and web-chat replies would each need the button plus a call to the same render endpoint β the server side already works for any ticket.
-
New merge codes. Add them to
buildTicketMergeData()and they appear in both kinds of template; add the label toreplyTemplateMergeCodes()to surface a chip in the editor. -
Categories, usage counts, per-department templates β all unbuilt. Categories are the most-requested shape; they'd be a column plus a grouping in
renderReplyTemplateMenu(). -
Seeded defaults on install would mean rows in
freeitsm.sql, and they'd need translating β the table stores one body per template, with no locale column.
- Canned responses β the analyst-facing page
- Roles β Developer Guide β how a manifest tab becomes a capability
- Multi-Tenancy β Developer Guide β the config-list scoping primitive
-
Database Verification β Developer Guide β
$schema, the drift guard, generated indexes - Internationalisation β why the pt-BR twin ships in the same commit
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)