← Para municipios neerlandeses

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:

  1. the [PREFIX-42] key in the subject: every outbound reply carries it, so it survives clients that drop the References chain;
  2. 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 / resolutionDueAt overriding 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 hash userId → {state, name, at}, refreshed by the client heartbeat (20s) and EXPIREd 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 in run-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.