Skip to content

Canned Responses Developer Guide

Ed Mozley edited this page Jul 21, 2026 · 1 revision

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 by ticket_types, not the scoped-data meaning used by tickets (where NULL means "unrouted").

A global shared template is therefore both columns NULL, and a private one is analyst_id = me. The pair is never a wildcard: reads filter on both axes. See Multi-Tenancy Concepts for why this codebase has three meanings of NULL and 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.php before 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.js to tickets/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:

  1. The upgrade path. Ran db_verify on an install without the table and confirmed it reported "Table created with 9 columns" plus both restored indexes β€” not just that freeitsm.sql looked right.
  2. 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.
  3. Cross-analyst blindness, both ways. Confirmed the admin's picker does not list another analyst's private template, and vice versa.
  4. A real payload. users.display_name was set to <img src=x onerror=alert(1)>, the template rendered, and the output checked for &lt;img β€” then the name was put back.
  5. Headless Chrome parse-check with a negative control. Both pages were loaded in real Chrome and every new function's typeof reported, alongside a deliberately fake name that had to come back undefined. Grepping rendered HTML for function names would have proved nothing.
  6. 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 returns null, and the save fails with a confusing "Name is required". That was the harness, not the app β€” build such payloads with json_encode() into a file and curl --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 to replyTemplateMergeCodes() 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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally