Skip to content

Email Signatures Developer Guide

Ed Mozley edited this page Aug 19, 2026 · 1 revision

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.


1. πŸ“ The files involved

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*

⚠️ Editing assets/js/inbox.js means bumping ?v= on the inbox.js tag in tickets/index.php (currently v91), or nobody gets the change.


2. πŸ”‘ Why the merge codes are not the ones that already exist

buildTicketMergeData() resolves [analyst_name] as:

COALESCE(o.full_name, a.full_name)     -- o = ticket owner, a = assigned analyst

That 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.

An unresolved code is stripped, not left showing

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.


3. πŸ” Scoping β€” there is no id-based access, anywhere

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.


4. πŸ—„οΈ Exactly one default

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.

5. ⚠️ Two TinyMCE behaviours that break this silently

Both were found by reading the editor's actual content in a browser, not from the code.

extended_valid_elements must declare the marker

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.

An empty editor returns '', not the paragraph inside it

let current = emailEditor.getContent();
if (current.replace(/<[^>]*>|&nbsp;|\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.


6. πŸ–₯️ Composer behaviour

  • 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.
  • mySignatureCache is null on 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.

7. βœ… How this was verified

The composer was driven headless with window.fetch stubbed to real payloads, and the editor's content read back:

DEFAULT   >>> <p>&nbsp;</p> <div data-signature="7"> … </div>
SWITCHED  >>> <p>&nbsp;</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".


8. Extending it

  • A new profile field: column on analysts, both schema homes, signatureMergeData(), signatureMergeCodes(), save_profile.php, the details grid, and the browser-side preview map in refreshSignaturePreview().
  • Signatures for portal users would be a different table β€” analyst_signatures is keyed on analysts, and self-service users live in users.
  • 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.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally