Skip to content

Ticket Address Suggestions Developer Guide

Ed Mozley edited this page Nov 13, 2026 · 1 revision

βœ‰οΈ Ticket address suggestions - Developer Guide

How the To and Cc boxes of a ticket's email suggest addresses as you type, and the supplier's Service desk address that comes first. Built for #307 (5.2.0), from a user who escalates tickets to outside providers. User docs: Tickets β†’ Addresses as you type and Contracts β†’ Suppliers.

The one-line version: a small reusable dropdown (assets/js/address-suggest.js) on a plain text box asks one endpoint (api/tickets/address_suggest.php), which asks one service (AddressSuggest::search()) four questions in order - supplier service desks, supplier contacts, the ticket company's people, and addresses recently emailed from that company's tickets - each scoped so nothing is offered that the analyst, or that company, should not see.


1. The files

File 🎨 What changed
includes/services/address_suggest.php βš™οΈ new: AddressSuggest::search(), recent()
api/tickets/address_suggest.php πŸ”Œ new: GET ?ticket_id=&q=
assets/js/address-suggest.js, assets/css/address-suggest.css πŸ–₯️ new: AddressSuggest.attach(input, {fetch})
assets/js/inbox.js πŸ–₯️ initAddressSuggest() attaches it to #emailTo and #emailCc
tickets/index.php πŸ–₯️ loads the two new files; inbox.js cache-buster
database/freeitsm.sql, includes/db_verify_schema.php πŸ—„οΈ suppliers.ticket_email VARCHAR(255) NULL
includes/services/contracts.php βš™οΈ saveSupplier() validates and saves ticket_email - only once the column exists
api/contracts/get_suppliers.php πŸ”Œ returns it (NULL AS ticket_email before Database Verification)
contracts/suppliers/index.php, contracts/suppliers/view/index.php πŸ–₯️ the field on both edit forms and the view
api/v1/resources/contracts.php, api/v1/lib/openapi_schemas.php πŸ”— ticket_email on the REST supplier
lang/en/tickets.php, lang/en/contracts.php, tickets/help.php, contracts/help.php πŸ“– labels and in-app help
includes/feature_bingo/cards/tickets-core.php 🎯 tickets.supplier_service_desk
tests/address-suggest.php πŸ§ͺ new: 21 checks, with a second company

2. The four sources

// includes/services/address_suggest.php
public static function search(PDO $conn, int $analystId, int $ticketId, string $q): array
# source from scoped by
1 service_desk suppliers.ticket_email, matched on legal name, trading name or the address analyst can use Contracts
2 contact contacts.email, matched on name, address or the supplier's name analyst can use Contracts
3 person users of the ticket's company, active tenantScopeSqlFor($conn, $tenantId, 'u.tenant_id')
4 recent emails To / Cc, outbound, last 180 days, on the ticket's company's tickets company + the analyst's departments, never confidential

Each result is {email, name, source, detail} (detail = the contact's supplier, the person's job title). A shared $add() keeps the first source to offer an address - so a supplier contact who is also a person shows as the contact - drops anything that is not a valid address, and the whole list stops at LIMIT (12).

Why suppliers are not company-scoped: in FreeITSM a supplier and its contacts belong to Contracts, not to a company - one supplier list for the install. So they are gated on the module instead: an analyst without Contracts is not shown its address book.

Typed % and _ are escaped before going into LIKE, so they are characters, not wildcards:

$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q) . '%';

3. Recent recipients, and the scope trap

// The ticket's company (below) and the analyst's departments. NOT ticketScopeFilter(): its company
// half is the analyst's ACTIVE company, which on another company's ticket would hide that
// company's own recipients. The analyst may open this ticket, so may see its company.
[$scopeSql, $scopeArgs] = ticketDepartmentScopeSql($conn, $analystId, 't');
[$tSql, $tArgs] = tenantScopeSqlFor($conn, $tenantId, 't.tenant_id');
$conf = tenancyColumnExists($conn, 'tickets', 'sensitivity') ? " AND (t.sensitivity IS NULL OR t.sensitivity <> 'confidential')" : '';
$st = $conn->prepare(
    "SELECT e.to_recipients, e.cc_recipients
       FROM emails e JOIN tickets t ON t.id = e.ticket_id
      WHERE e.direction = 'Outbound'
        AND e.received_datetime >= UTC_TIMESTAMP() - INTERVAL 180 DAY
        AND (e.to_recipients LIKE ? OR e.cc_recipients LIKE ?)
        AND t.deleted_datetime IS NULL$conf$tSql$scopeSql
      ORDER BY e.id DESC LIMIT 200");

TRAP: ticketScopeFilter() is the analyst's ACTIVE company. The first version used it, and the test caught it: on a ticket of the analyst's other company, that company's own recent recipients vanished. The question here is "this ticket's company", so the company comes from the ticket and only the department half of the usual filter is used.

Why the two other rules:

  • Per company. An address emailed on Globex's tickets is never offered on Acme's - the reason the issue gave, and a real leak on a desk serving several customers.
  • Never a confidential ticket's. Who a confidential ticket was sent to is part of what is confidential (Confidential tickets).

The addresses are pulled out of the To and Cc text with a regular expression (they may be a@b.com, c@d.com or Name <a@b.com>), and only those that themselves contain what was typed are kept - the message matched, but its other recipients may not.


4. The endpoint

// api/tickets/address_suggest.php
if ($ticketId <= 0 || !analystCanAccessTicket($conn, $analystId, $ticketId)) {
    echo json_encode(['success' => false, 'error' => 'Ticket not found']);
    exit;
}
echo json_encode(['success' => true, 'suggestions' => AddressSuggest::search($conn, $analystId, $ticketId, $q)]);

The ticket is what scopes everything, so an analyst who cannot open it gets nothing - the same answer as a ticket that does not exist.


5. The dropdown

AddressSuggest.attach(input, { fetch: q => Promise<[{email, name, source, detail}]> })

The box stays a plain text box - free typing, ; or , between addresses, the send code unchanged. Only the address being typed is looked up:

/** Where the address being typed starts: just after the last ; or , (and any spaces). */
function tokenStart(v) {
    var i = Math.max(v.lastIndexOf(';'), v.lastIndexOf(','));
    ...
}
  • 180 ms after the last keystroke, and only with the cursor at the end (editing the middle of the list is not typing a new address).
  • A sequence number drops answers to older keystrokes that arrive late.
  • Addresses already in the box are not offered again.
  • Arrow keys move, Enter or Tab pick, Escape closes (and stops the dialog closing too). Picking replaces the token with the address and ; .
  • mousedown, not click: a click arrives after the box has lost focus and the list has closed.
  • The list is placed under the input itself (input.offsetLeft / offsetWidth) - in the email dialog the label sits beside the box in the same wrapper.

In inbox.js it is attached once, and each lookup asks about the ticket open at that moment:

const lookup = q => {
    const id = currentEmail && currentEmail.ticket_id;
    if (!id) return Promise.resolve([]);
    return fetch(API_BASE + 'address_suggest.php?ticket_id=' + encodeURIComponent(id) + '&q=' + encodeURIComponent(q), { credentials: 'same-origin' })
        .then(r => r.json()).then(j => (j && j.success ? j.suggestions : []));
};
['emailTo', 'emailCc'].forEach(id => window.AddressSuggest.attach(document.getElementById(id), { fetch: lookup }));

Any other address box can use it the same way with its own fetch.


6. The supplier field

suppliers.ticket_email, labelled Service desk address. saveSupplier() (the UI and the REST API both use it) checks it is an address, and writes it only once Database Verification has added the column, so a supplier saved before that does not fail:

$ticketEmail = $str('ticket_email');
if ($ticketEmail !== null && !filter_var($ticketEmail, FILTER_VALIDATE_EMAIL)) {
    throw new ServiceError('validation', 'invalid_value', "'ticket_email' is not a valid email address.");
}
$hasTicketEmail = tenancyColumnExists($conn, 'suppliers', 'ticket_email');
...
if ($hasTicketEmail) $fields[] = $ticketEmail;

get_suppliers.php returns NULL AS ticket_email until then, for the same reason.


7. Testing

FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_TEST_URL=http://localhost/<that install> php tests/address-suggest.php

21 checks. It adds a ZZ second company first - TRAP: before anything asks tenancy a question, because isMultiTenant() is cached per process - then a supplier with a service desk address and a contact, a person in each company, and outbound emails from a ticket in each company and from a confidential one.

  • the four sources, in order; a contact found by its supplier's name; a display name stripped from Name <address>;
  • a person, and a recent recipient, of the other company are not offered - and are on that company's ticket (the check that caught the active-company trap);
  • a confidential ticket's recipient is never offered; suppliers are offered on either company's ticket;
  • fewer than 2 characters asks nothing; % and _ are not wildcards; no address twice, at most 12;
  • the supplier field refuses a non-address; emptied, it is no longer offered;
  • over HTTP: suggestions for a ticket, nothing for an unknown one, and the supplier list carries the field.

A headless Chrome harness on the throwaway install drove the dropdown itself: the rows and their tags, the position under the box, ArrowDown + Enter, a click, , as a separator, an already-picked address not offered again, free typing left alone - and the real tickets page loads with both boxes attached and no errors.


8. Traps

  • ticketScopeFilter() is the ACTIVE company - for "this ticket's company", take the company from the ticket and use ticketDepartmentScopeSql() for departments.
  • fn() captures by value - in the test, the helper that made tickets was an arrow function, so the ids never reached the clean-up. A closure with &$made.
  • A new column must not break a save before Database Verification - check it exists (tenancyColumnExists()), as saveSupplier() and get_suppliers.php do.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally