Shared-inbox helpdesk (ticketing-lite)
A helpdesk turns an existing shared mailbox (support@, info@, sales@) into a
ticket queue: inbound mail becomes a Ticket the team assigns, prioritizes, tags and answers,
with SLA clocks that run in business hours and live collision detection so two agents don’t answer
the same customer.
The design goal is ticketing-lite: a thin workflow layer over the mailbox, with no parallel system. Mail delivery is untouched. Every message still lands in the shared mailbox’s Inbox and stays visible over IMAP, ActiveSync, MAPI and JMAP. Turning a helpdesk off (or deleting it) leaves the mailbox and all of its mail exactly as they were.
┌───────────────────────────────┐
customer mail ──► │ deliverInbound (mail.service) │ ── Message row, as always
└──────────────┬────────────────┘
│ onHelpdeskMail (best-effort, never blocks delivery)
┌──────────────▼────────────────┐
│ Ticket (status, assignee, │
│ priority, tags, SLA clocks) │
└───────────────────────────────┘
Data model
| Model | What it is |
|---|---|
Helpdesk |
One per shared mailbox (mailboxUserId is unique). Holds the SLA targets, business hours, auto-close policy, auto-acknowledgement text and AI flags. nextNumber is the per-queue ticket counter. |
Ticket |
Workflow state over a conversation. threadId is the Message.threadId in the shared mailbox, so the ticket’s mail is a plain indexed lookup. Public key is <keyPrefix>-<number> (e.g. TCK-42). |
TicketEvent |
Append-only audit trail: created / assigned / status / priority / tags / note / reply / received / sla_breach / ai_triage. actorName is denormalized so a deleted user’s history still reads correctly. |
TicketMessage |
Links a ticket to the Message rows of its conversation, with direction (INBOUND/OUTBOUND). The Message stays the source of truth for content. |
CannedReply |
A reusable template, scoped to one queue or org-wide (helpdeskId: null). |
Access uses no new permission system: a helpdesk’s agents are the shared mailbox’s
MailboxShare grantees, and canManage on that share is the write right. Someone with read-only
access can open tickets but cannot assign, change status or reply.
Mail → ticket
deliverInbound calls onHelpdeskMail after it has filed a message (see
modules/helpdesk/helpdesk.ingest.ts). It is best-effort: a throw there can
never fail mail delivery.
Re-attachment (reply to an existing ticket, or a new one?) uses two explicit signals:
- the
[PREFIX-42]key in the subject: every outbound reply carries it, so it survives clients that drop theReferenceschain; Message.threadId.
Anything else opens a new ticket. There is deliberately no “same sender, still open, so probably the same issue” heuristic: silently merging unrelated problems from a customer who happens to have one ticket open is worse than an extra ticket a human can close.
Never ticketed: mail filed to Junk, the queue’s own address (an MTA loopback), and RFC 3834
automated mail (Auto-Submitted, bounces, vacation replies), since otherwise a vacation responder
answering our acknowledgement would open a ticket per round trip.
SLA clocks
Targets are expressed in minutes of business time, not wall clock: a 4-hour first-response
target on a Friday 16:30 ticket is due Monday morning. The calendar is the helpdesk’s
businessHours JSON:
{ "timezone": "Europe/Amsterdam",
"mon": [{ "start": "09:00", "end": "17:00" }],
"tue": [{ "start": "09:00", "end": "17:00" }],
"holidays": ["2026-12-25"] }
An empty object means 24/7. The arithmetic lives in lib/helpdeskSla.ts (Intl-based, so DST is
handled without a date library) and is pure. The sweeper, the ingest hook and the stats endpoint
all share it.
PENDING(waiting on the customer) pauses both clocks: the business ms consumed so far is banked on the ticket and the due dates are recomputed from the remainder when it resumes.- A breach flag latches: it is a historical fact, not a state, so answering a late ticket does not erase that it was late.
- A ticket may carry explicit
firstResponseDueAt/resolutionDueAtoverriding the queue targets (a promised callback time, or a call logged after the fact). A past due date is legitimate, and the sweeper flags it as already breached.
The sweeper (helpdesk.sweeper.ts, every HELPDESK_SLA_INTERVAL_MS, default 60s) latches breaches,
pushes a notification to the assignee, and auto-closes RESOLVED tickets after the queue’s
autoCloseDays. It is guarded by a per-tenant Redis tick-lock, so running on every replica is safe.
POST /api/v1/admin/helpdesks/sweep runs it on demand (for catching up after downtime).
Collision detection
Two people answering the same customer is the classic shared-mailbox failure, and it is invisible in
a plain mail client. /ws/helpdesk (helpdesk.gateway.ts) mirrors the /ws/chat design: a
per-instance socket registry plus Redis for the cross-replica parts.
helpdesk:presence:<ticketId>: a Redis hashuserId → {state, name, at}, refreshed by the client heartbeat (20s) andEXPIREd at 50s, so a killed tab or a dead replica evaporates on its own.helpdesk:broadcast: pub/sub carrying presence + ticket-change events to everyone watching that queue on any replica.
State is viewing or replying; the UI switches to replying as soon as there is text in the
composer, so the warning reaches the other agent while you type. Presence is advisory: it
never blocks a send. The same data is available over REST (POST/GET/DELETE /helpdesk/tickets/:id/presence) so the SPA can fall back to polling behind a proxy that blocks
WebSockets.
Client gotcha.
announce()is idempotent: re-announcing the same (ticket, state) sends nothing. Every announce makes the server broadcast presence, which re-renders the caller. Without that guard an unstable effect dependency becomes a write loop. The heartbeat keeps the TTL alive, not the call.
AI: auto-triage and draft replies
Per queue (aiTriage, aiDraftReply, both off by default; helpdesk.ai.ts):
- triage classifies a new ticket (priority, up to 5 tags, a one-line summary) via a forced tool call, so the output is validated rather than parsed out of prose;
- draft writes a suggested reply into
Ticket.aiSuggestedReply.
The draft is never sent. A human opens the ticket, edits and presses send, so a bad draft costs
a wasted edit and never reaches the customer. A queue may pin aiAgentId, and that
agent’s role prompt plus the org’s aiProfile become the persona (“the sales agent answers
sales@”). Everything goes through resolveLLM('triage'), so per-org provider/residency config, the
daily token budget and LlmUsage accounting apply unchanged. The ticket body is fenced and labelled
as untrusted input, since a support inbox is where prompt injection arrives.
KCC
The citizen inbox (klantcontactcentrum): a gemeente’s front desk runs on shared mailboxes such as gemeente@ and burgerzaken@. A helpdesk fits that work through categories, public holidays and templates:
Categories (TicketCategory, per queue). Each has keywords and, optionally, its own SLA targets
that replace the queue’s. New mail goes to the first category, top to bottom, with a keyword in its
subject or text (whole words or phrases; case and accents ignored); a ticket logged by hand is
categorised from its subject and note unless the agent picks one. An agent can move a ticket to
another category: running clocks keep the time they used, and the due date moves by the difference
between the old and the new target (moving back restores it). A paused ticket picks the new targets
up when it resumes. Deleting a category leaves its tickets uncategorised with their due dates.
Public holidays. businessHours.holidayCountries: ["NL"] closes the clocks on every Dutch public
holiday (lib/holidays.ts), every year, next to the fixed holidays dates.
Templates (Admin → Helpdesks → a queue → the Citizen inbox tab): one click sets office hours Monday-Friday 08:30-17:00 with Dutch holidays, targets in working days (a working day is 510 business minutes: first response 1 day, resolution 5), a plain-language (B1) acknowledgement, and:
| Template | Categories (own target) | Canned replies |
|---|---|---|
gemeente |
Woo-verzoek (decision in 20 working days), Klacht (30 working days: Awb 9:11’s six weeks), Zorg en ondersteuning, Belastingen en WOZ (2 / 10 days), Vergunningen (2 / 10 days), Melding openbare ruimte | doorverwijzen, info, klacht, woo, opgelost |
burgerzaken |
Paspoort en ID-kaart, Rijbewijs, Verhuizing en inschrijving, Uittreksel en VOG, Geboorte/huwelijk/overlijden, Afspraak | afspraak, verhuizing, uittreksel, info |
Applying a template again adds nothing: categories and canned replies whose name exists are kept. The legal terms are calendar weeks; working days are the helpdesk’s approximation of them. A Woo request belongs in the Woo register too, which counts the statutory term exactly.
Response-time report (Helpdesk → Response times, or GET /helpdesk/{id}/report?from&to,
&format=csv&lang=nl|en): per category and in total, the tickets received in the period, the median
and average first response and the median resolution in business time, the share on time (answered
or resolved without a breach), and how many are still open and overdue. The CSV opens in Dutch Excel.
Surfaces
REST at /api/v1/helpdesk (queues, tickets, notes, reply, presence, AI, canned replies) and
/api/v1/admin/helpdesks (configuration, SYSADMIN/ORGADMIN, org-scoped). Replies are sent through
the normal sendMessage(..., { onBehalfOf: mailboxUserId }) path, so the customer sees
From: support@… while Sender: names the agent, and DLP / S/MIME / relay behave exactly as for
any other send.
Web UI: the Helpdesk module (apps/web/src/pages/helpdesk/) has a queue rail, ticket list with SLA
badges and collision dots, a ticket detail with the merged mail+audit timeline, and a composer with
canned replies and the AI draft button. Configuration is in Admin → Helpdesks.
A queue’s settings (and New helpdesk) open in one dialog with tabs, so a long form is never one long scroll:
| Tab | Holds |
|---|---|
| General | the shared mailbox (chosen when the queue is created, fixed afterwards), the queue name, the ticket prefix, the on/off switch (an existing queue) and the default assignee |
| Service levels | the first-response, resolution and auto-close targets, and the business hours the SLA clocks run in |
| Automation | the acknowledgement mail to a new sender and the two AI helpers |
| Citizen inbox | the gemeente templates and the categories; only for an existing queue, so a new queue has the first three tabs |
One Save stores everything on the first three tabs, whichever tab is showing, and a tab you never opened is saved as it was. Citizen inbox works on its own: a category is stored when you leave its field, and applying a template replaces the business hours, targets and acknowledgement and closes the dialog, so save your own changes to the other tabs first.
Tests
apps/api/test/smoke-helpdesk.mjs(registered inrun-all.mjs): 90 assertions over the whole backend: queue creation, access gates, mail→ticket, re-attachment, assignment/status/priority/tags, canned-reply expansion, reply-as-the-mailbox, SLA pause/resume and the breach latch, filters, presence, stats, auto-ack, business-hours math, read-only members, closed tickets.apps/web/e2e/helpdesk.spec.ts: the UI golden path, the two-agent collision banner (including its release), the SLA-breach badge + filter, and the KCC path (template, keyword routing, category filter and move, report).apps/api/test/smoke-kcc.mjs: templates, categories, keyword routing, category SLA and moves, the report and its CSV;apps/api/test/helpdesk-kcc.test.ts: keyword matching, Dutch holidays in the SLA calendar, template sanity, the CSV.