-
Notifications
You must be signed in to change notification settings - Fork 16
Ticket Notes Developer Guide
How a note's visibility works, how a shared note's files reach the portal, and how the note_shared email trigger fires β with the rules that keep an internal note internal.
See Ticket notes: internal or shared for what the feature does.
ticket_notes.is_internal β 1 by default, and every consumer keys off it.
// includes/services/tickets.php β createNote()
$isInternal = array_key_exists('is_internal', $in) ? (bool)$in['is_internal'] : true;Default to internal, always. A note whose visibility is unstated must be private: the failure of guessing wrong in that direction is a private remark reaching a customer.
The same rule that bit issue #88 applies here. Ask the flag, never a label:
// β
const isShared = note.is_internal === false;
// β there is no such field, and if there were it would be a display string
const isShared = note.visibility === 'Shared';get_notes.php already returns is_internal, cast to a real bool:
$note['is_internal'] = (bool)$note['is_internal'];// assets/js/inbox.js β renderNotes()
const isShared = note.is_internal === false;
// A tracker-imported note is NEITHER β it is not ours to describe as internal
// or shared β so it gets no visibility label at all.
const visClass = note.source ? '' : (isShared ? ' note-item-shared' : ' note-item-internal');Label both states. If only shared notes were marked, "no label" would have to mean internal by implication β and any note kind added later would silently inherit that meaning.
.note-item already says what kind of note something is through its left border colour β that is how a tracker-imported note is distinguished. Visibility extends the same idiom rather than introducing a second visual language:
.note-item { border-left: 3px solid var(--accent); } /* internal β unchanged */
.note-item-external { border-left-color: var(--text-muted); } /* from a tracker */
.note-item-shared { border-left-color: var(--success-accent); }Use the real semantic token sets (--success-bg / --success-text / --success-border), which are defined for both themes. Check any token you reach for actually exists in theme.css before using it.
api/self-service/get_document.php is the portal twin of api/documents/download.php, exactly as get_attachment.php is the twin of the analyst-side attachment route.
The whole security model is one join:
"SELECT d.id, d.kind, d.title, d.storage_key, d.original_name, d.external_url
FROM documents d
JOIN document_links dl ON dl.document_id = d.id
AND dl.parent_type = 'ticket_note'
JOIN ticket_notes n ON n.id = dl.parent_id
AND n.is_internal = 0
JOIN tickets t ON t.id = n.ticket_id
WHERE d.id = ?
AND d.deleted_datetime IS NULL
AND t.user_id = ?
AND t.deleted_datetime IS NULL
LIMIT 1"Four rules, none of them a separate step:
| Condition | Stops |
|---|---|
dl.parent_type = 'ticket_note' |
a document on an asset or contract becoming portal-reachable |
n.is_internal = 0 |
an internal note's files being served |
t.user_id = ? |
another customer's files |
d.deleted_datetime IS NULL |
a deleted document |
Put the visibility condition in the ownership query, not beside it. A second if is something a later change can reorder, short-circuit or forget. A join condition is not.
Every miss is a 404, whatever the reason, so a wrong id and someone else's id are indistinguishable from outside and ids cannot be enumerated.
get_ticket_detail.php lists a shared note's documents, but the download endpoint re-checks everything anyway:
Hiding a link while the URL still works is decoration, and the file is the sensitive part.
A DMS entry's external_url is typed by an analyst and now reaches a customer's browser. It must never become an href on trust:
// self-service/tickets.php
function safeExternalUrl(url) {
return /^https?:\/\//i.test(String(url || '')) ? String(url) : '';
}Without that, javascript: in that box runs for every requester who opens the ticket. The analyst side has the same guard in two places; the portal had none until note files arrived there.
Sent from the choke point every note already passes through:
// includes/services/tickets.php β createNote(), after the insert
if (!$isInternal) {
require_once __DIR__ . '/../template_email.php';
sendTemplateEmail($conn, $ticketId, 'note_shared', [
'note_text' => $text,
]);
}Four things worth copying if you add a trigger of your own:
Guard first. if (!$isInternal) is the outermost condition, not a check somewhere inside.
Name the trigger for what fires it. note_shared, not note_added. An administrator reading the dropdown should not need to know a hidden rule to predict its behaviour β and the misnaming is precisely what caused the original report.
Off until configured. getActiveTemplate() returns null when no active template carries the trigger, and sendTemplateEmail() then returns silently. Adding a trigger therefore changes nothing on an existing install. Make that your first test.
Non-fatal. Wrap it. The note is saved whether or not the mail goes, and it is already visible in the portal:
} catch (Exception $mailEx) {
error_log('note_shared template email failed for ticket ' . $ticketId . ': ' . $mailEx->getMessage());
}| Where | What |
|---|---|
api/tickets/save_email_template.php |
add to $validEvents β it fails closed, so a missing entry rejects the save |
tickets/settings/index.php |
three places: the template modal, the simulator dropdown, and EVENT_LABELS
|
lang/*/tickets.php |
event_<name> label |
| the sending code | one guarded sendTemplateEmail() call |
$extraMergeData is how a trigger contributes placeholders the ticket cannot supply β CSAT passes csat_link the same way:
sendTemplateEmail($conn, $ticketId, 'note_shared', ['note_text' => $text]);Letting the template decide how much it says is what keeps this from duplicating Reply: include [note_text] and the email carries the note; leave it out and it points at [ticket_url].
This one caught all four pre-existing triggers, and is the trap to understand before touching template_email.php.
Merge codes are substituted before the body is assembled. So sniffing the assembled body to decide whether it is HTML means a data value decides it:
template : "Hi,\n[note_text]" β plain text
note : "use the <table> in room 2"
merged : "Hi,\nuse the <table> in room 2" β now contains a tag
verdict : "this is HTML" β nothing is escaped
A note mentioning a tag turned an otherwise plain-text template into an HTML one, and everything else in it silently stopped being escaped.
Whether a template is HTML is a property of what the administrator wrote, not of today's data. So decide it first, from the template:
$bodyIsHtml = strip_tags($template['body_template']) !== $template['body_template'];Then escape accordingly, and pass the verdict along rather than letting it be re-sniffed:
if ($bodyIsHtml) {
foreach ($extraMergeData as $k => $v) {
$mergeData[$k] = nl2br(htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'));
}
}
β¦
$fullBody = buildTemplateEmailBody($body, $ticketNumber, $bodyIsHtml);Escape only for HTML templates. For a plain-text template buildTemplateEmailBody() escapes the whole body afterwards β escaping first as well shows the customer literal <br> and &lt;.
buildTemplateEmailBody() keeps the old sniff as a null default, for the one caller that has no template to inspect.
Prove it is off before proving it is on. The first assertion should be that no template configured sends nothing β otherwise a passing "it sends!" test tells you nothing about the default.
Pair every refusal with a positive control. An endpoint that always 404s passes every security test ever written. The portal document tests are:
| a shared note on my own ticket | 200, bytes returned β the control |
| an internal note on my own ticket | 404 |
| a shared note on another customer's ticket | 404 |
| a document hanging off an asset | 404 |
| a deleted document on a shared note | 404 |
| no portal session at all | 401 |
Test the escaping with a value that fights back. "Use the <table> in room 2\nLine two & \"quoted\"" exercises a tag, an ampersand, a quote and a newline in one string β and check it is escaped exactly once, not twice.
Drive the real page for anything in inbox.js. It is a large file where a stray backtick inside a template literal stops the whole thing parsing while the page still renders perfectly, because the HTML is server-side. Ask window whether the functions exist.
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
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ 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: Techniques & Tricks
-
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
- β³ π Ticket notes: internal or shared
- β³ ποΈ 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
- β³ π Scheduled work in your own calendar
- 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)