Telling the Analyst a Ticket Is Theirs Developer Guide - edmozley/freeitsm GitHub Wiki
User-facing page: Telling the analyst a ticket is theirs.
#148 reported that ticket_assigned reaches the requester rather than the analyst, and proposed changing the recipient.
The data said what that would have cost. Measured against the live ticket table before changing anything: on 38 of 45 assignments the ticket's owner and its assignee are different people, so the proposed recipient = $me would have emailed the wrong analyst in the large majority of cases โ and, separately, would have stopped telling the requester anything at all.
Measuring the proposal against real rows, rather than reasoning about it, is what turned "fix the recipient" into "add an event". Worth doing before accepting any issue that proposes a one-line change to who gets something.
So ticket_assigned is untouched and keeps its selection keyed on the requester, and analyst_assigned is a new event beside it.
In includes/services/tickets.php, at the point assignment is committed:
sendTemplateEmail($conn, $ticketId, 'ticket_assigned');
if ($newAnalystId !== $actorId) {
sendTemplateEmail($conn, $ticketId, 'analyst_assigned');
}The guard is the "assigning to yourself" rule. It compares the new assignee against the actor, not against the previous assignee โ self-assignment is the case that produces a pointless email, and it is the actor who would receive it.
includes/template_email.php gained assigned_analyst_name, assigned_analyst_email and ticket_url_analyst.
๐ด
[analyst_name]is the ticket's OWNER, not its assignee, and on most desks those differ. It predates this work and is not being changed โ renaming a placeholder breaks every template that uses it. The editor now says which is which at the point of choosing, because the names alone do not.
ticket_url_analyst points into the inbox; the existing ticket_url points at the requester's portal view. A template addressed to an analyst wants the former, and getting that wrong sends staff to a customer-facing page.
One function answers "who is this template written for", and the settings screen renders it as the Sends to column. Keeping it a function rather than a column in the template row means an event added later cannot forget to declare an audience โ there is one place that knows, and it is the same place the dispatcher reads.
Kept out of the conversation (4.9.0, #244)
Changelog #2729, commit 3e384caa6. The bug: after somebody else assigned a ticket, Reply was addressed to the analyst it had been assigned to, not to the requester. Assigning a ticket to yourself never showed it, because the dispatch guard above sends no analyst_assigned email in that case - which is why the report described two cases that looked contradictory.
sendTemplateEmail() in includes/template_email.php ends every successful send the same way: log it, then save a copy into the ticket's conversation. The conversation is the emails table, and the save is templateSaveSentEmail():
$sql = "INSERT INTO emails (
subject, from_address, from_name, to_recipients,
received_datetime, body_content, body_type, ticket_id, is_initial, direction, mailbox_id
) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(), ?, 'html', ?, 0, 'Outbound', ?)";Until 4.9.0 that ran for every event, including analyst_assigned (#148, 2.5.0). So assigning a ticket to Sam added an Outbound row with to_recipients = Sam's address and received_datetime = now - the newest message on the ticket. It also bumped tickets.updated_datetime.
Reply then did exactly what it was built to do with that row.
openReplyModal() in assets/js/inbox.js fills the To box from one function:
const replyTo = correspondentAddress(currentEmail);
document.getElementById('emailTo').value = replyTo;currentEmail is whatever message displayEmail() last put in the reading pane. Opening a ticket from the list opens its newest message: the list row's id is the latest email (LEFT JOIN LatestEmails le ON le.ticket_id = t.id AND le.rn = 1 in includes/ticket_list.php), and the click fetches get_email_detail.php?id= for it.
correspondentAddress() decides which end of that message the human is on, by its direction. Its docblock carries the table:
* Inbound from = them, to = us -> from
* Portal from = them, to = them -> from
* Manual from = them, to = them -> from
* Outbound from = US, to = them -> toFor a message somebody sent in, the person is the sender. For a message the desk sent out - an auto-reply, a workflow reply, an analyst's own reply - the sender is the desk's own mailbox, and the person is the recipient. That outbound rule exists because Reply once took from_address unconditionally and replied to support@... itself, which came straight back in as new mail.
The rule is right for anything the desk said to the requester. The assignment email broke its assumption: it is Outbound, but "them" is a colleague. So the newest message was the assignment email, its to_recipients was Sam, and Reply addressed Sam. The same function also drives the shared-note hint (const noMailbox = currentEmail && !correspondentAddress(currentEmail);), so that hint was answering about the wrong person too.
A second, quieter effect: a portal set to show third-party messages could show the requester the internal "this ticket is now yours" email, because it was in the conversation.
The save at the end of sendTemplateEmail() now asks who the template was written for, using the same templateEventAudience() that chose the recipient a few lines earlier:
// Save to the conversation - only what went to the REQUESTER (GH #244).
// TRAP: an email to an analyst ("this ticket is now yours") is internal. Stored
// in the conversation it became the newest message, so Reply addressed the
// reply to the analyst, and a portal that shows third-party messages showed it
// to the requester. It is still in the email send log above, and the ticket's
// history says it was sent.
if (templateEventAudience($eventTrigger) === 'analyst') {
templateNoteAnalystEmail($conn, $ticketId, (string)($mergeData['assigned_analyst_name'] ?? ''), $recipientEmail);
} else {
templateSaveSentEmail($conn, $ticketId, $mailbox, $recipientEmail, $fullSubject, $body);
}Keying on the audience rather than on the event name means a future analyst-addressed event is kept out of the conversation without anyone remembering this line - templateEventAudience() is still the one place that knows.
The email is not made invisible. It still goes through emailLogSent() just above, so it is in the email send log, and templateNoteAnalystEmail() writes one line into the ticket's history:
function templateNoteAnalystEmail(PDO $conn, int $ticketId, string $name, string $to): void {
try {
$conn->prepare("INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
VALUES (?, NULL, 'Assignment email', NULL, ?, UTC_TIMESTAMP())")
->execute([$ticketId, 'Sent to ' . ($name !== '' ? "$name <$to>" : $to)]);
} catch (Exception $e) {
error_log('Template email: failed to note the analyst email on ticket ' . $ticketId . ': ' . $e->getMessage());
}
}-
analyst_idis NULL: the system sent it, not whoever made the assignment.api/tickets/get_ticket_audit.phpalready resolves a NULL author as system (the case written for the workflow engine, GH #120), so the History tab needed no change to show it. -
It never throws, like the rest of
sendTemplateEmail(). The email has already gone; failing to note it must not turn a successful assignment into an error. - Unlike
templateSaveSentEmail()it does not touchtickets.updated_datetime. An internal notification is not activity on the requester's ticket.
Part 1 stops new rows. It does nothing for the rows already in emails on every install that has had the Assigned to an analyst template switched on since 2.5.0, and those tickets would keep replying to the analyst. So correspondentAddress() gained one early return:
function correspondentAddress(email) {
if (!email) return '';
const isOutbound = String(email.direction || '').toLowerCase() === 'outbound';
if (isOutbound && outboundToStaffOnly(email.to_recipients)) {
// GH #244: an outbound message to our OWN analysts - the "this ticket is now
// yours" email, which earlier releases stored in the conversation - is not
// the conversation. Replying goes to the requester, never to a colleague.
return email.requester_email || '';
}
return (isOutbound
? (email.to_recipients || email.requester_email)
: (email.from_address || email.requester_email)
) || '';
}
/** Is every address in a recipient list one of our analysts (GH #244)? Empty is no. */
function outboundToStaffOnly(recipients) {
const list = String(recipients || '').split(/[;,]/).map(s => {
const m = s.match(/<([^>]+)>/);
return (m ? m[1] : s).trim().toLowerCase();
}).filter(Boolean);
return list.length > 0 && list.every(a => analystEmails.has(a));
}outboundToStaffOnly() splits to_recipients on ; or ,, takes the address out of a Name <address> form when there is one, lower-cases it, and says yes only when every address is an analyst's:
to_recipients on an Outbound message |
outboundToStaffOnly() |
Reply goes to |
|---|---|---|
[email protected] (an analyst) |
yes | the requester |
Sam <[email protected]>; [email protected] (both analysts) |
yes | the requester |
[email protected] |
no |
[email protected], as before |
[email protected]; [email protected] |
no - a real person is on it | the whole list, as before |
| empty | no (list.length > 0) |
falls through to the long-standing rule, which uses requester_email
|
"Every", not "any". An analyst who replies to a requester and puts a colleague in To has written to the requester; that message is the conversation and keeps its recipients. Only a message that reached nobody but staff is treated as internal.
The set it checks against is built once, in loadAnalysts():
analysts = data.analysts.filter(a => a.is_active);
analystsMultiCompany = !!data.multi_company;
// GH #244: every analyst's address, leavers included - see correspondentAddress().
analystEmails = new Set(data.analysts.map(a => String(a.email || '').trim().toLowerCase()).filter(Boolean));analysts - the assign dropdown - is filtered to active analysts, but analystEmails is built from the unfiltered list. api/tickets/get_analysts.php selects every row of analysts with no is_active condition, so a ticket assigned to somebody who has since left still replies to the requester. That is the "leavers included" in the comment.
TRAP: keep
analystEmailsbuilt fromdata.analysts, not from the filteredanalysts. Tidying the two lines into one would quietly bring the bug back for every ticket whose assignee has left.
A migration would have to pick the assignment emails out of the emails table and move or delete them. It was not done, for three reasons:
-
There is nothing reliable to select on. The rows carry no event marker: they are Outbound,
is_initial = 0, with a subject and body that came from an editable template. "Outbound, and every recipient is an analyst" is the only test - and that is exactly whatoutboundToStaffOnly()applies, at read time, without changing anything. - Deleting conversation rows cannot be undone. They are a record of what was sent from the desk's mailbox. Rewriting them would change history nobody asked to rewrite, on every install, during an upgrade.
- The read-time rule is safe on both sides. It only ever changes the suggested To address, and only to the requester. The To box can always be edited, and a message that included any non-analyst is untouched.
The cost is that old assignment emails still appear in the conversation of old tickets. They are now just inert for Reply.
There is no automated test of this path; check it on a test install, never on real tickets.
- Switch on an Assigned to an analyst template. As analyst A, assign a ticket to analyst B. The ticket's History shows Assignment email - Sent to B <b@...> with no analyst name; the email send log has the send; the conversation does not. Reply is addressed to the requester.
- Control: as A, assign a ticket to yourself. No email is sent and no history line is written - that is the dispatch guard, not this fix.
- Old rows: on a ticket whose newest message is an Outbound row in
emailswithto_recipientsset to an analyst's address (insert one on a test ticket to fake a pre-4.9.0 assignment email), Reply goes to the requester. - Control for "every, not any": an Outbound message to the requester and an analyst still replies to both.
- Leaver: deactivate analyst B on the test install and repeat step 3. Reply still goes to the requester.
After changing inbox.js, bump its ?v= in tickets/index.php (it went from 183 to 184 with this fix), or browsers keep the old file.