-
Notifications
You must be signed in to change notification settings - Fork 15
Email Signatures Developer Guide
Per-analyst signatures: the merge vocabulary that had to be separate, the scoping that keeps them private, and two TinyMCE behaviours that would have broken it silently.
The user-facing page is Email signatures.
| File | Role | |
|---|---|---|
| βοΈ | includes/signatures.php |
renderSignature(), signatureMergeData(), signatureMergeCodes(), signaturesForAnalyst(), defaultSignatureForAnalyst(), setDefaultSignature()
|
| π | api/myaccount/get_signatures.php |
Signatures + profile + merge codes |
| π | api/myaccount/save_signature.php |
Create / update |
| π | api/myaccount/delete_signature.php |
Delete |
| π | api/myaccount/save_profile.php |
Job title, department, phone, mobile |
| π₯οΈ | system/preferences/index.php |
The manager, beside the other personal settings |
| π₯οΈ | assets/js/inbox.js |
applyDefaultSignature(), setSignatureInEditor(), pickSignature()
|
| ποΈ | analyst_signatures |
analyst_id, name, body, is_default, display_order
|
| ποΈ | analysts |
gains job_title, department, phone, mobile
|
| π | lang/en/system.php |
preferences.sig_* / preferences.details_*
|
| π | lang/en/tickets.php |
reply_modal.signature* |
assets/js/inbox.js means bumping ?v= on the inbox.js tag in tickets/index.php (currently v91), or nobody gets the change.
buildTicketMergeData() resolves [analyst_name] as:
COALESCE(o.full_name, a.full_name) -- o = ticket owner, a = assigned analystThat is the ticket's analyst, not the person typing. Correct for an automatic email about a ticket; wrong for a signature. Sam answering a ticket owned by Jo would sign it Jo β to the customer, every time, and neither would notice.
So signatures have their own vocabulary keyed on the signed-in analyst: [my_name], [my_email], [my_job_title], [my_department], [my_phone], [my_mobile]. The my_ prefix is the whole distinction and reads correctly beside the My Account screen. [analyst_name] keeps its existing meaning.
If you add a signature merge code, take the value from the session analyst, never from the ticket.
return preg_replace('/\[my_[a-z_]+\]/', '', $body);This is the opposite of renderReplyTemplate(), deliberately. An unresolved [requester_first_name] sits in an editor where an analyst reads it before sending; an unfilled [my_mobile] would appear at the foot of every email that person ever sends.
Surrounding punctuation is left alone β [my_phone] | [my_mobile] with no mobile leaves a trailing |. The preview shows exactly that. Guessing which separators to strip would be less predictable than showing the truth.
Every endpoint takes the analyst from $_SESSION and none accepts an analyst id. Where a signature id is accepted it is always paired:
UPDATE analyst_signatures SET ... WHERE id = ? AND analyst_id = ?
DELETE FROM analyst_signatures WHERE id = ? AND analyst_id = ?There is deliberately no capability check: this is not administration, it is your own account, and every analyst is entitled to exactly their own rows. Adding a capability here would be the wrong axis β the protection is ownership, not permission.
Verified with a second session:
| Attempt | Result |
|---|---|
| Read another analyst's signatures | Own rows only |
save_signature.php with their id |
"That signature does not exist." |
delete_signature.php with their id |
deleted: false |
That last one reports honestly rather than returning success for a no-op.
save_signature.php re-checks ownership when rowCount() === 0, because a genuine no-op edit and a foreign id both produce zero affected rows and must not be reported the same way.
UPDATE analyst_signatures SET is_default = 0 WHERE analyst_id = ?;
UPDATE analyst_signatures SET is_default = 1 WHERE id = ? AND analyst_id = ?;Two statements, so "exactly one" stays true even if an earlier write left two set. The analyst_id clause on both is what stops it touching anyone else's.
Two rules that exist to stop the feature reading as broken:
- The first signature becomes the default whether or not the box was ticked. Otherwise an analyst creates one, nothing is flagged, nothing is ever inserted.
-
defaultSignatureForAnalyst()falls back to the first row when none is flagged. Deleting the default therefore promotes a replacement at read time. There is deliberately no second rule doing the same job at delete time β two rules for one invariant eventually disagree.
Both were found by reading the editor's actual content in a browser, not from the code.
extended_valid_elements: 'div[style|data-reply-marker|data-signature]',TinyMCE drops attributes its schema does not know, without a word. The signature is wrapped in <div data-signature="ID"> so the picker can find and replace it. Without that declaration the attribute vanishes, querySelector('[data-signature]') finds nothing, and switching signature appends a second one instead of replacing.
let current = emailEditor.getContent();
if (current.replace(/<[^>]*>| |\s/g, '') === '') {
current = '<p><br></p>';
}
emailEditor.setContent(current + signatureWrap(sig));getContent() does not hand back the <p><br></p> sitting in an empty editor. Concatenating onto '' made the signature the first node in the body, so the cursor landed inside it and the analyst started typing in the middle of their own sign-off.
- Inserted into the editor, never at send time. The analyst reads, edits or deletes it before sending; what they see is what the customer gets. Appending on send is how somebody ends up with two sign-offs and never finds out.
-
mySignatureCacheisnullon failure, never[]. "You have none" and "we could not find out" must stay distinguishable, or a failed request silently composes a signature-less reply. -
applyDefaultSignature()re-checks before writing: the modal may have been closed while the fetch was in flight, and it never overwrites a[data-signature]block that is already there. - Canned responses use
insertContent()(at the cursor), so they compose with a signature rather than replacing it.
The composer was driven headless with window.fetch stubbed to real payloads, and the editor's content read back:
DEFAULT >>> <p> </p> <div data-signature="7"> β¦ </div>
SWITCHED >>> <p> </p> <div data-signature="8"> β¦ </div>
One signature block after switching, and the writing area intact. The first run of that test produced no leading paragraph at all, which is how Β§5's second point was found.
Note the probe must be injected after <!DOCTYPE> β a script before it puts the document in quirks mode, and TinyMCE refuses to initialise with "the document is not in standards mode".
-
A new profile field: column on
analysts, both schema homes,signatureMergeData(),signatureMergeCodes(),save_profile.php, the details grid, and the browser-side preview map inrefreshSignaturePreview(). -
Signatures for portal users would be a different table β
analyst_signaturesis keyed onanalysts, and self-service users live inusers. - A shared or team signature would break the model on purpose: nothing here has an owner other than an analyst, and the scoping above is what keeps that simple.
- Email signatures β the user page
- Canned Responses β Developer Guide β the other editor-inserted text, and the merge-code escaping rule
- Email template sender rules β Developer Guide β from the same discussion
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)