Canned Responses Developer Guide - edmozley/freeitsm GitHub Wiki
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.
1. ๐ The files involved
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 |
Where the logic actually lives
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.
2. ๐๏ธ The table, and its two different NULLs
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 of
NULL, 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 the Multi-Tenancy Developer Guide 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.
3. ๐ The permission model โ the part worth reading
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.
๐ Check the capability against the scope being SAVED
// 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.
Scope is read from the database, never from the request
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.
4. โ๏ธ Merge codes: reuse the vocabulary, not the substitution
Templates use the same [merge_code] placeholders as ticket_email_templates, resolved from the same builder:
$merge = buildTicketMergeData($conn, $ticketId); // includes/template_email.php
An 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.
On not sanitising the body
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.
5. ๐ The endpoints
| 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.
6. ๐ฅ๏ธ The front end
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.
7. โ How this was verified
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.
8. Extending it
- 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.
See also
- 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