@dxv-systems/helpdesk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,279 +1,273 @@
1
- # @dxv-systems/helpdesk
2
-
3
- Domain logic for a mailbox helpdesk on Microsoft Graph: idempotent delta ingestion, conservative threading, a ticket state machine, noise detection, draft placement that keeps the quoted history, retry-safe sending, and an auto-send policy evaluator.
4
-
5
- Pure logic behind interfaces, plus the staff inbox (see Inbox UI). No database, no environment reading, and zero runtime dependencies. The app implements four small interfaces and calls the functions below. All time comes from an injected `Clock`.
6
-
7
- ```bash
8
- npm i @dxv-systems/helpdesk
9
- ```
10
-
11
- ## Integrating into an app
12
-
13
- The app owns the schema and the cron, and mounts the inbox (see Inbox UI). It passes one `HelpdeskDeps` object to every function.
14
-
15
- | Interface | What the app implements |
16
- |---|---|
17
- | `HelpdeskStore` | Threads (including the nullable `dismissedBy` staff id), messages, sync state, draft runs, send intents. Methods marked atomic in `types.ts` must be one transaction. Unique keys: message on `(mailbox, id)`, draft run on `(mailbox, inboundMessageId, generation)`, send intent on `id`. |
18
- | `MailboxGateway` | One mailbox: `delta`, `getMessage`, `createReply`, `updateDraftBody`, `sendDraft`, `deleteDraft`. Declared structurally against `@dxv-systems/mail-graph`'s `MailboxClient`, so `createMailboxClient(http, address)` can be passed as is. Use immutable ids (`Prefer: IdType="ImmutableId"`). An expired delta link (410, `syncStateNotFound`, `resyncRequired`) may throw `DeltaLinkExpiredError` or mail-graph's `GraphDeltaExpiredError`; both are recognised by name or signal. |
19
- | `Drafter` (optional) | Without one, no draft is generated and staff write every reply (see Staff-written replies). `start(request)` starts a run and returns its id. It must treat the same `idempotencyKey` as the same run. The result comes back later through `completeDraft`. |
20
- | `Notifier` (optional) | `review_needed` and `send_outcome_unknown`. A throwing notifier never breaks the flow. |
21
-
22
- ```ts
23
- const deps: HelpdeskDeps = {
24
- store,
25
- gatewayFor: (mailbox) => createMailboxClient(http, mailbox),
26
- drafter,
27
- clock: { now: () => new Date() },
28
- config: { ownAddresses: ["noreply@app.example"], connectedMailboxes: ["support@client.example"] },
29
- // Read again at the start of every call; a function lets a flipped switch apply at once.
30
- controls: async () => ({ paused: await killSwitchIsOn(), writeMode: "drafts_in_app" }),
31
- };
32
-
33
- await connectMailbox(deps, "support@client.example"); // once, when the mailbox is connected
34
- await syncMailbox(deps, "support@client.example"); // cron, one run per mailbox at a time
35
- await completeDraft(deps, { mailbox, inboundMessageId, idempotencyKey, result }); // when the drafter reports back
36
- await sweepStuckDrafts(deps, mailbox); // cron: fails runs with no report after 15 minutes
37
- ```
38
-
39
- **What the app must call:** `connectMailbox` once per mailbox; `syncMailbox` on a cron; `completeDraft` whenever the drafter reports (pass the run's `idempotencyKey`; a report for anything but the latest run is ignored); `sweepStuckDrafts` on a cron (a run still in flight after `config.draftTimeoutMs`, default 15 minutes, fails so the ticket reaches review as `drafter_failed`; a report that arrives later is a no-op); and the staff actions below.
40
-
41
- ## Pilot controls
42
-
43
- Three switches the app passes in `deps.controls` (a value, or a function that is called at the start of every entry point, so a change applies on the next call without a restart). The package reads no environment and keeps no state of its own.
44
-
45
- | Control | Values | Default |
46
- |---|---|---|
47
- | `paused` | `true` stops everything (kill switch) | `false` |
48
- | `writeMode` | `read_only`, `drafts_in_app`, `drafts_in_outlook` | `read_only` |
49
- | `writeModes` | per-mailbox override, `{ "support@client.example": "drafts_in_app" }` (case-insensitive) | none |
50
-
51
- **Write modes.**
52
-
53
- - `read_only`: sync and drafting work, and the drafter's text is stored on the run (`commentText`), but the package makes no mailbox write at all. `approveAndSend` and `retryPlacement` answer `{ status: "write_blocked", mode }`; draft cleanup is skipped.
54
- - `drafts_in_app`: the drafter's text lives only in app state, and `editDraft` changes only the stored text. Nothing reaches Outlook until a person approves: `approveAndSend` then calls `createReply(inboundId)` without a comment, puts the approved text in front of the body Exchange returned (`prependToDraftBody`, right after `<body>`, so the quoted history stays), writes it with `updateDraftBody` and calls `sendDraft`. The send intent is keyed per draft run (`send:<mailbox>:<run key>`) and records the draft id, so a retry reuses that draft and builds the same body again instead of prepending twice. Drafts are never deleted in this mode.
55
- - `drafts_in_outlook`: drafts are placed in Outlook as soon as the drafter reports (the behaviour described under Drafting below).
56
-
57
- The package default is `read_only`, so a mailbox nobody configured is never written to. A pilot typically passes `drafts_in_app`.
58
-
59
- **Kill switch.** With `paused: true`, `ingestPage`, `syncMailbox`, `ensureDraft`, `completeDraft`, `regenerateDraft`, `editDraft`, `retryPlacement`, `sweepStuckDrafts`, `unignoreThread`, `approveAndSend`, `markRepliedExternally` and `dismissThread` return `{ status: "paused" }` (`ensureDraft`: `"paused"`) and make no Graph call, no drafter call and no store write. `isPaused(result)` narrows it. A sync that is paused between pages returns the pages already done. A drafter report that arrives while paused is dropped; the run stays in flight and, after the pause, `sweepStuckDrafts` moves it to review as `drafter_failed`. Bookkeeping without mailbox access (`connectMailbox`, `resolveUnknownSend`, `markWaiting`, `closeThread`) is not paused. Use it as the emergency stop: removing the Exchange role can take up to 2 hours to apply.
60
-
61
- **Mailbox scope.** `config.connectedMailboxes` is the helpdesk's scope. Any call for another mailbox (a `mailbox` argument, a thread or a drafter report of another mailbox, or a gateway whose `address` differs from the mailbox asked for) throws `MailboxNotInScopeError` before any Graph call. The same Entra app may reach other mailboxes for other features; this helpdesk never touches them.
62
-
63
- **Auto-send** stays impossible unless the policy is enabled, the mailbox is in `drafts_in_outlook` and the helpdesk is not paused: `AutoSendContext` requires `paused` and `writeMode` (use `writeModeFor(controls, mailbox)`), and the evaluator denies with `paused` or `write_mode` before any other rule. `drafts_in_app` denies too, because it promises that nothing reaches Outlook without a person.
64
-
65
- Every gateway write also passes a guard that throws `MailboxWriteBlockedError` for a write the mode does not allow, as a last line of defence behind the checks above.
66
-
67
- Run `syncMailbox` for a mailbox from one worker at a time (a lock or a queue). Overlapping runs are safe against duplicates, but they can move the stored link backwards, which only costs a re-read.
68
-
69
- ## Ingestion
70
-
71
- `ingestPage(deps, mailbox)` fetches one delta page and processes it; `syncMailbox` loops until the round is complete.
72
-
73
- - **Idempotent on the immutable message id.** A message already stored is never inserted again. A re-read of stored mail only finishes work a crash left open (a draft run that was never created, a draft ready to be routed to review).
74
- - **The link is stored last.** `nextLink` or `deltaLink` is saved after every item of the page is processed. A crash re-reads the page.
75
- - **Expired link.** `DeltaLinkExpiredError` (or any error named so) clears the link and restarts a first-page delta from `max(connectedAt, lastReceivedAt - 48 h)`. Stored mail is skipped by id.
76
- - **Skipped without a trace:** drafts, mail the mailbox sent itself, and mail received before `connectedAt`.
77
- - **Changes to existing mail** (read state, categories) are reported by delta as items we already store. They change nothing and never reopen a ticket.
78
- - **Removed items** set `removedAt` on the stored message. The ticket keeps its state. `removedAt` means "gone from the Inbox", nothing more: Graph reports a move to another folder and a delete alike, both as `@removed` with reason `deleted` (seen in a real tenant).
79
- - Items are processed oldest first, so a reply in the same page finds its parent.
80
-
81
- ## Threading
82
-
83
- `matchThread` in this order, always inside one mailbox (also checked on the rows a store returns):
84
-
85
- 1. **`conversationId`.** Accepted when the sender already wrote in that thread, or the mail's In-Reply-To / References point into it. An unknown sender with only a matching conversation id starts a new thread: Exchange groups mail without thread headers by subject, so a bare match can join strangers. If the headers point at a different thread than the conversation id, it also starts a new thread.
86
- 2. **`In-Reply-To` and the whole `References` chain**, against stored `internetMessageId`s (lowercase, no angle brackets). The whole chain matters because the reply a customer answers is our own sent mail, which we never stored; the earlier ids in the chain still hit. Several open threads in one chain: no match. One open thread next to closed or ignored ones: the open one. A forward (`FW:`, `Fwd:`, `WG:`) from someone outside the thread is new business even though it carries old headers.
87
- 3. **Normalised subject plus the same sender**, within 14 days (`subjectWindowMs`). Prefixes `Re`, `Antw`, `AW`, `SV`, `FW`, `Fwd`, `WG`, `TR`, `VS`, `Re[2]`, and gateway tags like `[EXTERN]` are stripped. An empty subject never matches. More than one open candidate: no match.
88
-
89
- ## Ticket states
90
-
91
- `New`, `Ignored`, `Drafted`, `Review`, `Sent`, `Waiting`, `Closed`.
92
-
93
- | From | Event | To |
94
- |---|---|---|
95
- | new | `ignore` | ignored |
96
- | new | `draft` | drafted |
97
- | drafted | `needs_review` | review |
98
- | drafted | `auto_send` | sent |
99
- | review | `approve` | sent |
100
- | review | `redraft` | drafted |
101
- | sent | `settle` | waiting |
102
- | waiting | `customer_reply` | new |
103
- | waiting | `close` | closed |
104
- | ignored | `unignore` | new |
105
- | new, drafted, review | `dismiss` | ignored |
106
- | sent, waiting | `bounce` | review |
107
-
108
- `transition(state, event)` is the pure function; anything else throws `InvalidTransitionError` (with `from` and `event`). `ignore` belongs to the noise filter and `dismiss` to staff, so the filter can never set aside a ticket that already has a draft. `unignoreThread` is the staff action for mail that was wrongly ignored: ignored to new, `latestInboundId` set to the newest message, `dismissedBy` cleared, and a draft starts.
109
-
110
- **No reply needed.** `dismissThread(deps, { threadId, dismissedBy })` is the staff action for mail that needs no answer (a newsletter the filter missed, "thanks, got it", spam): new, drafted or review to ignored, with `Thread.dismissedBy` set to the staff id (null on a ticket the noise filter ignored, so analytics can tell the two apart). It returns `{ status: "paused" }` under the kill switch and refuses a mailbox outside the scope. It throws `SendPendingError` (code `send_in_progress` or `send_outcome_unknown`) while a send is under way or unclear; a `sending` intent older than 2 minutes is turned into `unknown` first, so staff get the resolve buttons. After the state change it stops an in-flight draft run (marked failed, so a late report is a no-op) and, only in `drafts_in_outlook`, deletes the latest run's draft and, during a regeneration, the previous generation's (see Draft cleanup). In `drafts_in_app` and `read_only` it touches no mailbox. `approveAndSend` checks the state again after it claims the send, so a dismissal that lands just before the claim stops the send (`StaleThreadError`, the intent becomes `failed`). Ignored stays final: a later reply opens a new ticket. Only the app decides when to call `markWaiting` (sent to waiting) and `closeThread` (waiting to closed, for example after N quiet days).
111
-
112
- **Where a state is set in practice.** A noise mail opens its ticket directly as `ignored` (the New to Ignored step is collapsed into creation, so a crash cannot leave a noise ticket in `new`). `completeDraft` goes new to drafted to review in one write. Until auto-send is wired, every draft ends in review, with `reviewReason` `low_confidence`, `policy`, `stale_draft`, `drafter_failed`, `placement_failed`, `staff_edited` or (no drafter) `no_draft`. A bounce of a sent reply brings a ticket back to review with `delivery_failed`.
113
-
114
- **A customer mail (one that is not noise) arrives for a ticket in:**
115
-
116
- | State | What happens |
117
- |---|---|
118
- | new | Appended. No second draft run starts while one is in flight; its result is stale and goes to review. |
119
- | drafted | Appended, state kept. The draft is stale. |
120
- | review | Appended, state kept, `reviewReason` becomes `stale_draft`. Staff regenerates (review to drafted) or approves with `acceptStale`. |
121
- | sent, waiting | Same ticket goes to `new` (via `settle` then `customer_reply`) and a draft starts. |
122
- | closed, ignored | Final. The mail opens a new ticket with `relatedThreadId` pointing at the old one. |
123
-
124
- A noise mail (out-of-office, bounce) that threads onto a ticket is stored in it and changes nothing, so an auto-reply to our answer does not wake a waiting ticket.
125
-
126
- **Bounces of a sent reply.** A bounce (NDR) is matched to its ticket on its own terms, because its sender is never a participant. The NDR body quotes the headers of the bounced reply; their `In-Reply-To` and `References` point at the customer mail we answered, which is stored. When those name no single ticket, the conversation id is used, but only if exactly one ticket in that conversation is `sent` or `waiting`. A matched ticket in `sent` or `waiting` gets the NDR as a message and goes to review with `reviewReason: "delivery_failed"` (label "Niet afgeleverd"), and the notifier gets `review_needed` with that reason. In any other state the NDR is only stored. No match: the NDR opens its own ignored ticket as before. The inbox then offers only "Al beantwoord in Outlook" and "Geen antwoord nodig" for that ticket: the reply was already sent, so staff resend it from Outlook (for example to a corrected address) and say so, or set the ticket aside. `IngestResult.deliveryFailures` counts these.
127
-
128
- ## Noise detection
129
-
130
- `classifyNoise` returns `{ action: "ignore" | "process", reason }`. Every rule needs a positive signal; unclear means process.
131
-
132
- - bounces: sender `postmaster` / `mailer-daemon` or Exchange Online's system sender (`microsoftexchange` plus 32 hex characters), a `delivery-status` content type, or Exchange's NDR header. A subject alone never counts. Checked before `Auto-Submitted`, which NDRs usually carry as well.
133
- - `Auto-Submitted` present and not `no`
134
- - `Precedence: bulk | junk | list | auto_reply`
135
- - out-of-office: `X-Autoreply`, `X-Autorespond`, or a subject that starts with the Outlook prefix (`Automatic reply:`, `Automatisch antwoord:`, `Out of Office:` and similar)
136
- - `List-Id` or `List-Unsubscribe`
137
- - sender local part `noreply`, `no-reply`, `do-not-reply`, `donotreply`, `bounce(s)`, unless `Reply-To` holds a different address that is not machine-like or ours (a website form). Then the mail is processed, `Reply-To` becomes the ticket's `customerAddress`, and both addresses are participants so threading keeps working. `Auto-Submitted`, bounces and lists still ignore such mail.
138
- - the app's own addresses and every connected mailbox
139
-
140
- `X-Auto-Response-Suppress` does not ignore mail: it asks auto-responders to stay quiet, and business systems set it on mail a person may need to answer. The mail is processed and `StoredMessage.suppressAutoResponse` is set; pass it to `evaluateAutoSend`, which denies with `auto_response_suppressed`.
141
-
142
- Not noise on purpose: acknowledgements ("bedankt"), `info@`, `notifications@`, `newsletter@` and the client's own domain. Wrongly ignoring a customer is worse than a needless draft.
143
-
144
- ## Drafting
145
-
146
- `ensureDraft` runs from ingestion for a new ticket. It creates the run with `createDraftRunIfAbsent` (one per inbound message) and calls `drafter.start` with `draft:<mailbox>:<messageId>:<generation>` as the idempotency key. A run that was claimed but never started is resumed after 2 minutes, with the same key.
147
-
148
- `completeDraft` stores the text, confidence, reason and category, then (in `drafts_in_outlook`) places the draft with `createReply(messageId, { comment: text })`. The `comment` goes above the quoted history; PATCHing the body instead would replace it.
149
-
150
- **Staff edits keep the quote.** Never call `updateDraftBody` yourself. `editDraft(deps, { threadId, text })` takes the full HTML body Exchange returned at placement (stored as `placedBodyHtml`), swaps everything between `<body>` and the start of the quote for the new text, keeps everything from the quote on, writes the result, and sets the ticket back to review (`staff_edited`). The quote start is found by the Outlook/OWA markers (`appendonsend`, `divRplyFwdMsg`, `OutlookMessageHeader`), an `<hr>` followed by `From:`/`Van:`, or a `<blockquote>`. If none is found it throws `QuoteNotFoundError` before calling Graph; it never writes a body without the history. `replaceDraftComment` is exported for apps that need the pure step.
151
-
152
- **Failures never strand a ticket.** A drafter that cannot start, a failure result, or a failed placement moves the ticket to review with `drafter_failed` or `placement_failed`, the error stored on the run, and the page still completes. `regenerateDraft` (review to drafted, next generation) and `retryPlacement` are the staff actions; `markRepliedExternally` closes a review when staff answered from Outlook.
153
-
154
- **Staff-written replies.** Staff can always write the reply themselves when there is no usable draft: no run, a failed run, or (without a drafter) only a run for older mail. Without `deps.drafter`, `ensureDraft` moves a new ticket straight to review with `reviewReason: "no_draft"` (and notifies `review_needed`); `regenerateDraft` throws `DraftNotReadyError`. `editDraft` then creates the next generation of the run for the newest inbound message as a staff run: status `succeeded`, `text: null` (no drafter text), `reason: "staff"`, the staff text in `commentText`. The ticket goes review to drafted to review (`staff_edited`). In `drafts_in_outlook` the staff run is placed like a drafter's draft (`createReply` with the text as comment; a failure gives `placement_failed`). From there it is an ordinary run: further edits, `approveAndSend`, cleanup and send intents work unchanged. One internal rule decides which run staff work on, for the domain functions and the inbox alike.
155
-
156
- ## Draft cleanup
157
-
158
- The helpdesk deletes only Outlook drafts it created itself, through `MailboxGateway.deleteDraft` (which refuses anything that is not a draft and answers `deleted`, `gone` or `not_draft`). It only ever passes an id stored on a draft run of that thread and mailbox.
159
-
160
- - **Regeneration:** once the new generation's draft is placed, the previous generation's draft is deleted. A failed or unplaced regeneration leaves the old draft where it is.
161
- - **Replied from Outlook:** `markRepliedExternally` deletes the latest run's draft. If staff sent that very draft, the answer is `not_draft` or `gone`, which is fine.
162
- - **No reply needed:** `dismissThread` deletes the latest run's draft, and the previous generation's while a regeneration is still running.
163
- - **Never:** a draft whose send intent is `intended`, `sending`, `unknown` or `sent` is never deleted, because it may be on its way to a customer. An `unknown` outcome stays for staff to resolve.
164
- - **Failures:** a failed delete never fails the action or the ticket state. The error is stored as `draftCleanupError` on the run and the id stays; on `deleted`, `gone` and `not_draft` the id is cleared so it is not tried twice.
165
-
166
- Known limit: if the process dies between `createReply` and storing the draft id, that Outlook draft is a duplicate nobody knows about. Its id was never stored, so it stays until someone deletes it in Outlook.
167
-
168
- ## Sending
169
-
170
- `approveAndSend(deps, { threadId, approvedBy })` (in `drafts_in_app` the draft is created at step 2, see Pilot controls):
171
-
172
- 1. The ticket must be in review and have a placed draft (`drafts_in_app`: stored draft text); a draft older than the newest customer mail needs `acceptStale: true`. A reply whose own text (`commentText`, else `text`) is empty or whitespace throws `EmptyReplyError` before any intent or Graph call: a quoted history alone is never sent. `editDraft` refuses blank text the same way.
173
- 2. A send intent (`send:<mailbox>:<draftId>`) is stored first, then compare-and-set from `intended` to `sending`, then `sendDraft` is called. Only one caller wins that compare-and-set.
174
- 3. Success marks the intent `sent` and the ticket `sent`. A second approval returns `already_sent`.
175
- 4. An HTTP 400, 401, 403 or 429 means Graph refused it: `failed_not_sent`, and the same flow can be retried.
176
- 5. Anything else (network error, timeout, 5xx, 404) may have sent. The result is `unknown_outcome`, the intent becomes `unknown`, `send_outcome_unknown` is notified, and the package never resends. A `sending` intent left behind by a dead worker turns into `unknown` after 2 minutes. Staff checks Sent Items and calls `resolveUnknownSend` with `sent` or `not_sent`.
177
-
178
- Concurrent callers get `in_progress` or `already_sent`.
179
-
180
- ## Auto-send policy (phase 3, not wired)
181
-
182
- `evaluateAutoSend(policy, context)` is a pure function returning `{ allow: true }` or `{ allow: false, reason, detail? }` for the audit trail. Rules, first deny wins: not paused, write mode `drafts_in_outlook`, policy enabled (default off), not ignored mail, not an internal sender (own addresses, connected mailboxes, `internalDomains`), ticket in `drafted`, draft not stale, a scope (senders and/or categories; none configured means deny), minimum confidence, office hours (IANA zone, ISO weekdays, start inclusive and end exclusive), and a maximum of auto replies per thread. A suppress-header mail is denied before any of these (`auto_response_suppressed`). Nothing in the package calls it.
183
-
184
- ## Inbox UI
185
-
186
- The staff inbox ships with the package: a route handler (`@dxv-systems/helpdesk/server`, framework-neutral, `handle(Request): Promise<Response>`) and a React page (`@dxv-systems/helpdesk/react`, a `"use client"` entry; React is an optional peer dependency). The app mounts both and implements one more interface for the reads the domain store does not have.
187
-
188
- **Route** `app/api/helpdesk/[...path]/route.ts`:
189
-
190
- ```ts
191
- import { createHelpdeskHandlers } from "@dxv-systems/helpdesk/server";
192
- import { helpdeskDeps } from "@/lib/helpdesk/deps"; // the same HelpdeskDeps the cron uses
193
- import { helpdeskInbox } from "@/lib/helpdesk/inbox"; // HelpdeskInboxQueries over the app's tables
194
- import { getSession } from "@/lib/auth";
195
-
196
- const { handle } = createHelpdeskHandlers({
197
- deps: helpdeskDeps,
198
- inbox: helpdeskInbox,
199
- getStaff: async (request) => {
200
- const user = await getSession(request);
201
- return user?.isStaff ? { id: user.id, name: user.name, email: user.email } : null;
202
- },
203
- isProduction: process.env.VERCEL_ENV === "production",
204
- });
205
-
206
- export { handle as GET, handle as POST, handle as PUT, handle as DELETE };
207
- ```
208
-
209
- **Page** `app/(staff)/helpdesk/page.tsx`:
210
-
211
- ```tsx
212
- import { HelpdeskInbox } from "@dxv-systems/helpdesk/react";
213
-
214
- export default function HelpdeskPage() {
215
- return <HelpdeskInbox renderCustomerContext={(thread) => <CustomerCard email={thread.customerAddress} />} />;
216
- }
217
- ```
218
-
219
- | `createHelpdeskHandlers` option | |
220
- |---|---|
221
- | `deps` | The `HelpdeskDeps` (store, `gatewayFor`, optional drafter, notifier, `controls`, clock, config). The handlers call the domain functions with it and nothing else. |
222
- | `inbox` | `HelpdeskInboxQueries`: `listThreads(query)` (filters `states`, `mailbox`, `assigneeId`: undefined is anyone, null is unassigned; `cursor` and `limit`; newest activity first; returns `{ rows: [{ thread, assignee }], nextCursor }`), `listMessages(threadId)` (oldest first), `getAssignee(threadId)`, `setAssignee(threadId, { id, name } \| null)`. Assignment is app data; the domain never reads it. |
223
- | `getStaff(request)` | `{ id, name, email }` for staff, null for anyone else (every route answers 401). `id` is stored as `approvedBy` on a send and as `dismissedBy` on a dismissal. |
224
- | `isProduction` | Outside production every route answers 403 `not_production`. |
225
- | `basePath` | Default `/api/helpdesk`. |
226
- | `appOrigin` | Writes with an `Origin` header from elsewhere answer 403. Default: the request URL's origin. |
227
- | `onError` | Unexpected errors (default `console.error`); the response is a generic 500 without details. |
228
-
229
- **Routes** (under `basePath`). Every write answers `{ detail, message, outcome? }` with the thread as it is after the action.
230
-
231
- | Route | Calls |
232
- |---|---|
233
- | `GET threads?state=review,new&mailbox=&assignee=me\|none\|<id>&cursor=&limit=` | `inbox.listThreads`; also returns the pilot switches per mailbox |
234
- | `GET threads/:id` | thread, messages (metadata), latest draft, send status, and `actions` the state machine and pilot controls allow now |
235
- | `GET threads/:id/messages/:messageId` | `gateway.getMessage` through the scope check; body as plain text, plus its attachments (`gateway.listAttachments`, inline images left out) |
236
- | `GET threads/:id/messages/:messageId/attachments/:attachmentId` | download of one file attachment of that message (`gateway.downloadAttachment`) |
237
- | `PUT threads/:id/draft` `{ text }` | `editDraft` |
238
- | `POST threads/:id/regenerate` | `regenerateDraft` |
239
- | `POST threads/:id/send` `{ acceptStale? }` | `approveAndSend` |
240
- | `POST threads/:id/retry-placement` | `retryPlacement` |
241
- | `POST`/`DELETE threads/:id/assign` | `inbox.setAssignee` (to the signed-in staff member / nobody) |
242
- | `POST threads/:id/waiting` | `markWaiting` |
243
- | `POST threads/:id/close` | `closeThread` |
244
- | `POST threads/:id/unignore` | `unignoreThread` |
245
- | `POST threads/:id/replied-externally` | `markRepliedExternally` |
246
- | `POST threads/:id/dismiss` | `dismissThread`, with the signed-in staff id as `dismissedBy` |
247
- | `POST threads/:id/resolve-send` `{ outcome: "sent" \| "not_sent" }` | `resolveUnknownSend` |
248
-
249
- Errors are `{ error, message }` with a Dutch `message` (`INBOX_ERROR_MESSAGES`): `paused` (423), `write_blocked` (409, with `mode`), `mailbox_not_in_scope` (403), `stale_draft`, `draft_not_ready`, `invalid_transition`, `stale_thread`, `send_in_progress`, `send_outcome_unknown` (409), `quote_not_found`, `empty_reply` (422), `send_failed` (502, Graph refused, nothing sent), `thread_not_found` (404). A send whose outcome is unclear answers 200 with `outcome: "unknown_outcome"`. Responses never carry draft bodies from Outlook, idempotency keys, draft ids or raw error text.
250
-
251
- **Attachments.** The message body route lists the attachments of a customer mail; inline images (signature logos) are left out. A file up to 4 MB gets a download link on the app itself; a larger file, an attached mail (`item`) or a link to a shared file (`reference`) is listed with a note to open it in Outlook, because a Vercel function answers at most 4.5 MB. The download is checked like the body (the message must belong to the ticket, scope check, paused refuses) and the attachment id must be one Graph lists for that message. It is always served as `application/octet-stream` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` and a sandbox CSP, so customer files never render on the app's origin. Both gateway methods are optional: a gateway without them shows no attachments. mail-graph's `MailboxClient` has them. Attachments are never stored by the helpdesk.
252
-
253
- **Files on a reply.** With `deps.attachments` (`ReplyAttachmentsDeps`: a `ReplyAttachmentStore` over the app's table and `ReplyFiles` over its file storage) and a gateway with `addAttachment`, staff can add files to the reply they are writing. The file never passes the app server: `startReplyUpload` checks the ticket and the limits and lets the app presign a direct upload, the browser PUTs the file to storage, and `addReplyAttachment` has the app verify the stored object before the file joins the reply. Limits: 10 files and 20 MB together per reply, checked before the upload and again on the real object. Files can be added while the reply can still be approved (review, not a bounced ticket) and removed until it is sent (`removeReplyAttachment`). Outlook is touched only at approval: `approveAndSend` adds the files to the draft (made at that moment in `drafts_in_app`, the existing one in `drafts_in_outlook`) right before `sendDraft`; a file that cannot be added fails the send as `failed_not_sent`, so nothing goes out without the approved files, and a retry on the same draft skips files already on it. After the send the files are marked as sent with that intent, also when staff confirm an unclear send. Dismissing a ticket or "Al beantwoord in Outlook" drops the unsent files. Routes: `POST threads/:id/attachments/upload` (`{ name, contentType, size }`, answers the upload target), `POST threads/:id/attachments` (`{ uploadToken }`), `DELETE threads/:id/attachments/:attachmentId`. The detail carries `replyAttachments` (unsent ones, and those of the latest sent reply) and the action `attach`.
254
-
255
- **Message bodies are text only.** The package has no HTML sanitiser, so it never sends mail HTML to the page: the body is asked from Graph as text, and any HTML that still comes back goes through `htmlToPlainText` (tags, scripts, styles and comments dropped, entities decoded). The page renders it as a text node with `white-space: pre-wrap`. Bodies are fetched per message, once per open thread; while the helpdesk is paused they are not fetched (no Graph call), the rest of the thread still shows.
256
-
257
- | `HelpdeskInbox` prop | |
258
- |---|---|
259
- | `apiBasePath` | Default `/api/helpdesk`. |
260
- | `classNames` | Partial `HelpdeskInboxClassNames`; defaults (`DEFAULT_HELPDESK_INBOX_CLASSNAMES`) use the shadcn tokens of the company app template. The only way to style it. |
261
- | `pollIntervalMs` | List and open thread refresh, default 30000, at least 5000. A draft being edited is never overwritten. |
262
- | `fetch` | Custom fetch (tests, a wrapper that adds headers). |
263
- | `renderCustomerContext` | `(thread) => ReactNode`, shown above the messages: the app's own data for the customer or project. |
264
- | `initialThreadId`, `onThreadChange` | Open a thread first (a link from a `review_needed` notification) and keep the open thread in the URL. |
265
-
266
- What staff sees: one page with the list (filters by status, assignee and mailbox; "Meer laden") and the open thread; on a phone the two are shown one at a time with "Terug naar tickets". A paused helpdesk shows a banner. In `read_only` the send button is disabled with the reason next to it; in `drafts_in_app` the draft says it goes to Outlook only on approval. Without a usable draft the reply box is empty ("Jouw antwoord"), and sending saves the typed reply first; a blank reply cannot be sent. Sending always asks for confirmation inline (recipient and mailbox named; edits are saved first), a stale draft asks again with "Toch versturen", and an unclear send shows "Het staat in Verzonden items" / "Het is niet verstuurd". "Geen antwoord nodig" (offered in new, drafted and review, not while a send is under way or unclear) asks inline first and says when the Outlook draft will be deleted; the ticket then leaves the list (a ticket whose new state falls outside the chosen view always does) and stays open with "Toch behandelen" to undo it.
267
-
268
- ## Testing
269
-
270
- `@dxv-systems/helpdesk/testing` has in-memory fakes (`createMemoryStore`, `createFakeMailbox`, `createFakeDrafter`, `createManualClock`, and `createTestWorld` to wire them), with the uniqueness and compare-and-set rules a real store needs. Apps can run their own store against the same scenarios. `createTestWorld({ controls })` sets the pilot controls (without them the package defaults apply), and each fake mailbox records every gateway call in `calls` and the mailbox writes in `writeCalls`, so a test can assert that read-only or paused made none.
271
-
272
- ## Development
273
-
274
- ```bash
275
- npm run build --workspace @dxv-systems/helpdesk
276
- npm run test --workspace @dxv-systems/helpdesk
277
- ```
278
-
279
- Output is CommonJS, like the other packages.
1
+ # @dxv-systems/helpdesk
2
+
3
+ Domain logic for a mailbox helpdesk on Microsoft Graph: idempotent delta ingestion, conservative threading, a ticket state machine, noise detection, and one way of answering: a reply (written by staff or by an AI drafter) is approved by a person in the helpdesk and only then sent from the mailbox, with the quoted history kept and no double sends.
4
+
5
+ Pure logic behind interfaces, plus the staff inbox (see Inbox UI). No database, no environment reading, and zero runtime dependencies. The app implements four small interfaces and calls the functions below. All time comes from an injected `Clock`.
6
+
7
+ ```bash
8
+ npm i @dxv-systems/helpdesk
9
+ ```
10
+
11
+ ## Integrating into an app
12
+
13
+ The app owns the schema and the cron, and mounts the inbox (see Inbox UI). It passes one `HelpdeskDeps` object to every function.
14
+
15
+ | Interface | What the app implements |
16
+ |---|---|
17
+ | `HelpdeskStore` | Threads (including the nullable `dismissedBy` staff id), messages, sync state, draft runs, send intents. Methods marked atomic in `types.ts` must be one transaction. Unique keys: message on `(mailbox, id)`, draft run on `(mailbox, inboundMessageId, generation)`, send intent on `id`. |
18
+ | `MailboxGateway` | One mailbox: `delta`, `getMessage`, `createReply`, `updateDraftBody`, `sendDraft` (and optionally `listAttachments`, `downloadAttachment`, `addAttachment`). Declared structurally against `@dxv-systems/mail-graph`'s `MailboxClient`, so `createMailboxClient(http, address)` can be passed as is. Use immutable ids (`Prefer: IdType="ImmutableId"`). An expired delta link (410, `syncStateNotFound`, `resyncRequired`) may throw `DeltaLinkExpiredError` or mail-graph's `GraphDeltaExpiredError`; both are recognised by name or signal. |
19
+ | `Drafter` (optional) | Without one, no draft is generated and staff write every reply (see Staff-written replies). `start(request)` starts a run and returns its id. It must treat the same `idempotencyKey` as the same run. The result comes back later through `completeDraft`. |
20
+ | `Notifier` (optional) | `review_needed` and `send_outcome_unknown`. A throwing notifier never breaks the flow. |
21
+
22
+ ```ts
23
+ const deps: HelpdeskDeps = {
24
+ store,
25
+ gatewayFor: (mailbox) => createMailboxClient(http, mailbox),
26
+ drafter,
27
+ clock: { now: () => new Date() },
28
+ config: { ownAddresses: ["noreply@app.example"], connectedMailboxes: ["support@client.example"] },
29
+ // Read again at the start of every call; a function lets a flipped switch apply at once.
30
+ controls: async () => ({ paused: await killSwitchIsOn(), writeMode: "approve_and_send" }),
31
+ };
32
+
33
+ await connectMailbox(deps, "support@client.example"); // once, when the mailbox is connected
34
+ await syncMailbox(deps, "support@client.example"); // cron, one run per mailbox at a time
35
+ await completeDraft(deps, { mailbox, inboundMessageId, idempotencyKey, result }); // when the drafter reports back
36
+ await sweepStuckDrafts(deps, mailbox); // cron: fails runs with no report after 15 minutes
37
+ ```
38
+
39
+ **What the app must call:** `connectMailbox` once per mailbox; `syncMailbox` on a cron; `completeDraft` whenever the drafter reports (pass the run's `idempotencyKey`; a report for anything but the latest run is ignored); `sweepStuckDrafts` on a cron (a run still in flight after `config.draftTimeoutMs`, default 15 minutes, fails so the ticket reaches review as `drafter_failed`; a report that arrives later is a no-op); and the staff actions below.
40
+
41
+ ## Pilot controls
42
+
43
+ Three switches the app passes in `deps.controls` (a value, or a function that is called at the start of every entry point, so a change applies on the next call without a restart). The package reads no environment and keeps no state of its own.
44
+
45
+ | Control | Values | Default |
46
+ |---|---|---|
47
+ | `paused` | `true` stops everything (kill switch) | `false` |
48
+ | `writeMode` | `read_only`, `approve_and_send` | `read_only` |
49
+ | `writeModes` | per-mailbox override, `{ "support@client.example": "approve_and_send" }` (case-insensitive), so a mailbox that is still being set up can stay `read_only` | none |
50
+
51
+ **Write modes.** The helpdesk has one way of answering: a reply is written in the helpdesk (by staff, or proposed by the drafter), a person approves it there, and the helpdesk sends it from the mailbox. Nothing is placed in Outlook before that approval.
52
+
53
+ - `read_only`: sync and drafting work, and the reply text is stored on the run (`commentText`), but the package makes no mailbox write at all. `approveAndSend` answers `{ status: "write_blocked", mode }`. This is the safe state while a client is being set up.
54
+ - `approve_and_send`: the reply lives only in app state, and `editDraft` changes only the stored text. Nothing reaches Outlook until a person approves: `approveAndSend` then calls `createReply(inboundId)` without a comment, puts the approved text in front of the body Exchange returned (`prependToDraftBody`, right after `<body>`, so the quoted history stays), writes it with `updateDraftBody` and calls `sendDraft`. The send intent is keyed per draft run (`send:<mailbox>:<run key>`) and records the draft id, so a retry after a failed send reuses that draft and builds the same body again instead of prepending twice.
55
+
56
+ The package default is `read_only`, so a mailbox nobody configured is never written to.
57
+
58
+ **Kill switch.** With `paused: true`, `ingestPage`, `syncMailbox`, `ensureDraft`, `completeDraft`, `regenerateDraft`, `editDraft`, `sweepStuckDrafts`, `unignoreThread`, `approveAndSend`, `markRepliedExternally` and `dismissThread` return `{ status: "paused" }` (`ensureDraft`: `"paused"`) and make no Graph call, no drafter call and no store write. `isPaused(result)` narrows it. A sync that is paused between pages returns the pages already done. A drafter report that arrives while paused is dropped; the run stays in flight and, after the pause, `sweepStuckDrafts` moves it to review as `drafter_failed`. Bookkeeping without mailbox access (`connectMailbox`, `resolveUnknownSend`, `markWaiting`, `closeThread`) is not paused. Use it as the emergency stop: removing the Exchange role can take up to 2 hours to apply.
59
+
60
+ **Mailbox scope.** `config.connectedMailboxes` is the helpdesk's scope. Any call for another mailbox (a `mailbox` argument, a thread or a drafter report of another mailbox, or a gateway whose `address` differs from the mailbox asked for) throws `MailboxNotInScopeError` before any Graph call. The same Entra app may reach other mailboxes for other features; this helpdesk never touches them.
61
+
62
+ Every gateway write also passes a guard that throws `MailboxWriteBlockedError` for a write the mode does not allow (any write in `read_only`), as a last line of defence behind the checks above.
63
+
64
+ Run `syncMailbox` for a mailbox from one worker at a time (a lock or a queue). Overlapping runs are safe against duplicates, but they can move the stored link backwards, which only costs a re-read.
65
+
66
+ ## Ingestion
67
+
68
+ `ingestPage(deps, mailbox)` fetches one delta page and processes it; `syncMailbox` loops until the round is complete.
69
+
70
+ - **Idempotent on the immutable message id.** A message already stored is never inserted again. A re-read of stored mail only finishes work a crash left open (a draft run that was never created, a draft ready to be routed to review).
71
+ - **The link is stored last.** `nextLink` or `deltaLink` is saved after every item of the page is processed. A crash re-reads the page.
72
+ - **Expired link.** `DeltaLinkExpiredError` (or any error named so) clears the link and restarts a first-page delta from `max(connectedAt, lastReceivedAt - 48 h)`. Stored mail is skipped by id.
73
+ - **Skipped without a trace:** drafts, mail the mailbox sent itself, and mail received before `connectedAt`.
74
+ - **Changes to existing mail** (read state, categories) are reported by delta as items we already store. They change nothing and never reopen a ticket.
75
+ - **Removed items** set `removedAt` on the stored message. The ticket keeps its state. `removedAt` means "gone from the Inbox", nothing more: Graph reports a move to another folder and a delete alike, both as `@removed` with reason `deleted` (seen in a real tenant).
76
+ - Items are processed oldest first, so a reply in the same page finds its parent.
77
+
78
+ ## Threading
79
+
80
+ `matchThread` in this order, always inside one mailbox (also checked on the rows a store returns):
81
+
82
+ 1. **`conversationId`.** Accepted when the sender already wrote in that thread, or the mail's In-Reply-To / References point into it. An unknown sender with only a matching conversation id starts a new thread: Exchange groups mail without thread headers by subject, so a bare match can join strangers. If the headers point at a different thread than the conversation id, it also starts a new thread.
83
+ 2. **`In-Reply-To` and the whole `References` chain**, against stored `internetMessageId`s (lowercase, no angle brackets). The whole chain matters because the reply a customer answers is our own sent mail, which we never stored; the earlier ids in the chain still hit. Several open threads in one chain: no match. One open thread next to closed or ignored ones: the open one. A forward (`FW:`, `Fwd:`, `WG:`) from someone outside the thread is new business even though it carries old headers.
84
+ 3. **Normalised subject plus the same sender**, within 14 days (`subjectWindowMs`). Prefixes `Re`, `Antw`, `AW`, `SV`, `FW`, `Fwd`, `WG`, `TR`, `VS`, `Re[2]`, and gateway tags like `[EXTERN]` are stripped. An empty subject never matches. More than one open candidate: no match.
85
+
86
+ ## Ticket states
87
+
88
+ `New`, `Ignored`, `Drafted`, `Review`, `Sent`, `Waiting`, `Closed`.
89
+
90
+ | From | Event | To |
91
+ |---|---|---|
92
+ | new | `ignore` | ignored |
93
+ | new | `draft` | drafted |
94
+ | drafted | `needs_review` | review |
95
+ | review | `approve` | sent |
96
+ | review | `redraft` | drafted |
97
+ | sent | `settle` | waiting |
98
+ | waiting | `customer_reply` | new |
99
+ | waiting | `close` | closed |
100
+ | ignored | `unignore` | new |
101
+ | new, drafted, review | `dismiss` | ignored |
102
+ | sent, waiting | `bounce` | review |
103
+
104
+ `transition(state, event)` is the pure function; anything else throws `InvalidTransitionError` (with `from` and `event`). `ignore` belongs to the noise filter and `dismiss` to staff, so the filter can never set aside a ticket that already has a draft. `unignoreThread` is the staff action for mail that was wrongly ignored: ignored to new, `latestInboundId` set to the newest message, `dismissedBy` cleared, and a draft starts.
105
+
106
+ **No reply needed.** `dismissThread(deps, { threadId, dismissedBy })` is the staff action for mail that needs no answer (a newsletter the filter missed, "thanks, got it", spam): new, drafted or review to ignored, with `Thread.dismissedBy` set to the staff id (null on a ticket the noise filter ignored, so analytics can tell the two apart). It returns `{ status: "paused" }` under the kill switch and refuses a mailbox outside the scope. It throws `SendPendingError` (code `send_in_progress` or `send_outcome_unknown`) while a send is under way or unclear; a `sending` intent older than 2 minutes is turned into `unknown` first, so staff get the resolve buttons. After the state change it stops an in-flight draft run (marked failed, so a late report is a no-op). It touches no mailbox. `approveAndSend` checks the state again after it claims the send, so a dismissal that lands just before the claim stops the send (`StaleThreadError`, the intent becomes `failed`). Ignored stays final: a later reply opens a new ticket. Only the app decides when to call `markWaiting` (sent to waiting) and `closeThread` (waiting to closed, for example after N quiet days).
107
+
108
+ **Where a state is set in practice.** A noise mail opens its ticket directly as `ignored` (the New to Ignored step is collapsed into creation, so a crash cannot leave a noise ticket in `new`). `completeDraft` goes new to drafted to review in one write. Every reply ends in review, because a person always approves it, with `reviewReason` `low_confidence`, `policy`, `stale_draft`, `drafter_failed`, `staff_edited` or (no drafter) `no_draft`. A bounce of a sent reply brings a ticket back to review with `delivery_failed`.
109
+
110
+ **A customer mail (one that is not noise) arrives for a ticket in:**
111
+
112
+ | State | What happens |
113
+ |---|---|
114
+ | new | Appended. No second draft run starts while one is in flight; its result is stale and goes to review. |
115
+ | drafted | Appended, state kept. The draft is stale. |
116
+ | review | Appended, state kept, `reviewReason` becomes `stale_draft`. Staff regenerates (review to drafted) or approves with `acceptStale`. |
117
+ | sent, waiting | Same ticket goes to `new` (via `settle` then `customer_reply`) and a draft starts. |
118
+ | closed, ignored | Final. The mail opens a new ticket with `relatedThreadId` pointing at the old one. |
119
+
120
+ A noise mail (out-of-office, bounce) that threads onto a ticket is stored in it and changes nothing, so an auto-reply to our answer does not wake a waiting ticket.
121
+
122
+ **Bounces of a sent reply.** A bounce (NDR) is matched to its ticket on its own terms, because its sender is never a participant. The NDR body quotes the headers of the bounced reply; their `In-Reply-To` and `References` point at the customer mail we answered, which is stored. When those name no single ticket, the conversation id is used, but only if exactly one ticket in that conversation is `sent` or `waiting`. A matched ticket in `sent` or `waiting` gets the NDR as a message and goes to review with `reviewReason: "delivery_failed"` (label "Niet afgeleverd"), and the notifier gets `review_needed` with that reason. In any other state the NDR is only stored. No match: the NDR opens its own ignored ticket as before. The inbox then offers only "Opnieuw verstuurd vanuit Outlook" (`replied_externally`) and "Geen antwoord meer nodig" (`dismiss`) for that ticket: the reply was already sent, so staff resend it from Outlook (for example to a corrected address) and say so, or set the ticket aside. `IngestResult.deliveryFailures` counts these.
123
+
124
+ ## Noise detection
125
+
126
+ `classifyNoise` returns `{ action: "ignore" | "process", reason }`. Every rule needs a positive signal; unclear means process.
127
+
128
+ - bounces: sender `postmaster` / `mailer-daemon` or Exchange Online's system sender (`microsoftexchange` plus 32 hex characters), a `delivery-status` content type, or Exchange's NDR header. A subject alone never counts. Checked before `Auto-Submitted`, which NDRs usually carry as well.
129
+ - `Auto-Submitted` present and not `no`
130
+ - `Precedence: bulk | junk | list | auto_reply`
131
+ - out-of-office: `X-Autoreply`, `X-Autorespond`, or a subject that starts with the Outlook prefix (`Automatic reply:`, `Automatisch antwoord:`, `Out of Office:` and similar)
132
+ - `List-Id` or `List-Unsubscribe`
133
+ - sender local part `noreply`, `no-reply`, `do-not-reply`, `donotreply`, `bounce(s)`, unless `Reply-To` holds a different address that is not machine-like or ours (a website form). Then the mail is processed, `Reply-To` becomes the ticket's `customerAddress`, and both addresses are participants so threading keeps working. `Auto-Submitted`, bounces and lists still ignore such mail.
134
+ - the app's own addresses and every connected mailbox
135
+
136
+ `X-Auto-Response-Suppress` does not ignore mail: it asks auto-responders to stay quiet, and business systems set it on mail a person may need to answer. The mail is processed and `StoredMessage.suppressAutoResponse` is set, so the app can see that no automatic answer was wanted.
137
+
138
+ Not noise on purpose: acknowledgements ("bedankt"), `info@`, `notifications@`, `newsletter@` and the client's own domain. Wrongly ignoring a customer is worse than a needless draft.
139
+
140
+ ## Drafting
141
+
142
+ `ensureDraft` runs from ingestion for a new ticket. It creates the run with `createDraftRunIfAbsent` (one per inbound message) and calls `drafter.start` with `draft:<mailbox>:<messageId>:<generation>` as the idempotency key. A run that was claimed but never started is resumed after 2 minutes, with the same key.
143
+
144
+ `completeDraft` stores the text, confidence, reason and category on the run and moves the ticket to review. It writes nothing to the mailbox.
145
+
146
+ **Staff edits.** `editDraft(deps, { threadId, text })` stores the new text (`commentText`) and sets the ticket back to review (`staff_edited`). It writes nothing to the mailbox; the approved text is put in front of the quoted history only when the reply is sent (see Sending). Never call `updateDraftBody` yourself.
147
+
148
+ **Failures never strand a ticket.** A drafter that cannot start or a failure result moves the ticket to review with `drafter_failed`, the error stored on the run, and the page still completes. `regenerateDraft` (review to drafted, next generation) is the staff action; `markRepliedExternally` closes a review when staff answered from Outlook.
149
+
150
+ **Staff-written replies.** Staff can always write the reply themselves when there is no usable draft: no run, a failed run, or (without a drafter) only a run for older mail. Without `deps.drafter`, `ensureDraft` moves a new ticket straight to review with `reviewReason: "no_draft"` (and notifies `review_needed`); `regenerateDraft` throws `DraftNotReadyError`. `editDraft` then creates the next generation of the run for the newest inbound message as a staff run: status `succeeded`, `text: null` (no drafter text), `reason: "staff"`, the staff text in `commentText`. The ticket goes review to drafted to review (`staff_edited`). From there it is an ordinary run: further edits, `approveAndSend` and send intents work unchanged. One internal rule decides which run staff work on, for the domain functions and the inbox alike.
151
+
152
+ ## Outlook drafts
153
+
154
+ The only Outlook draft the helpdesk makes is the one `approveAndSend` creates at the moment a person approves, and it is sent straight away. The helpdesk never deletes a draft. A send that Graph refused (`failed_not_sent`) leaves that draft in Outlook's Drafts; the next approval reuses it. If staff then answer from Outlook or mark the ticket as needing no reply, the leftover draft stays in Drafts until someone removes it there.
155
+
156
+ Known limit: if the process dies between `createReply` and storing the draft id, that Outlook draft is a duplicate nobody knows about. Its id was never stored, so it stays until someone deletes it in Outlook.
157
+
158
+ ## Sending
159
+
160
+ `approveAndSend(deps, { threadId, approvedBy })`:
161
+
162
+ 1. The ticket must be in review and have reply text (a finished draft run, or a staff reply saved with `editDraft`); a reply older than the newest customer mail needs `acceptStale: true`. A reply whose own text (`commentText`, else `text`) is empty or whitespace throws `EmptyReplyError` before any intent or Graph call: a quoted history alone is never sent. `editDraft` refuses blank text the same way. In `read_only` the answer is `write_blocked`.
163
+ 2. A send intent (`send:<mailbox>:<run key>`) is stored first, then compare-and-set from `intended` to `sending`. Only one caller wins that compare-and-set. Then the Outlook draft is created with `createReply(inboundId)`, the text goes in front of the quoted body, any files are added, and `sendDraft` is called.
164
+ 3. Success marks the intent `sent` and the ticket `sent`. A second approval returns `already_sent`.
165
+ 4. An HTTP 400, 401, 403 or 429 means Graph refused it: `failed_not_sent`, and the same flow can be retried.
166
+ 5. Anything else (network error, timeout, 5xx, 404) may have sent. The result is `unknown_outcome`, the intent becomes `unknown`, `send_outcome_unknown` is notified, and the package never resends. A `sending` intent left behind by a dead worker turns into `unknown` after 2 minutes. Staff checks Sent Items and calls `resolveUnknownSend` with `sent` or `not_sent`.
167
+
168
+ Concurrent callers get `in_progress` or `already_sent`.
169
+
170
+ ## Inbox UI
171
+
172
+ The staff inbox ships with the package: a route handler (`@dxv-systems/helpdesk/server`, framework-neutral, `handle(Request): Promise<Response>`) and a React page (`@dxv-systems/helpdesk/react`, a `"use client"` entry; React is an optional peer dependency). The app mounts both and implements one more interface for the reads the domain store does not have.
173
+
174
+ **Route** `app/api/helpdesk/[...path]/route.ts`:
175
+
176
+ ```ts
177
+ import { createHelpdeskHandlers } from "@dxv-systems/helpdesk/server";
178
+ import { helpdeskDeps } from "@/lib/helpdesk/deps"; // the same HelpdeskDeps the cron uses
179
+ import { helpdeskInbox } from "@/lib/helpdesk/inbox"; // HelpdeskInboxQueries over the app's tables
180
+ import { getSession } from "@/lib/auth";
181
+
182
+ const { handle } = createHelpdeskHandlers({
183
+ deps: helpdeskDeps,
184
+ inbox: helpdeskInbox,
185
+ getStaff: async (request) => {
186
+ const user = await getSession(request);
187
+ return user?.isStaff ? { id: user.id, name: user.name, email: user.email } : null;
188
+ },
189
+ isProduction: process.env.VERCEL_ENV === "production",
190
+ });
191
+
192
+ export { handle as GET, handle as POST, handle as PUT, handle as DELETE };
193
+ ```
194
+
195
+ **Page** `app/(staff)/helpdesk/page.tsx`:
196
+
197
+ ```tsx
198
+ import { HelpdeskInbox } from "@dxv-systems/helpdesk/react";
199
+
200
+ export default function HelpdeskPage() {
201
+ return <HelpdeskInbox renderCustomerContext={(thread) => <CustomerCard email={thread.customerAddress} />} />;
202
+ }
203
+ ```
204
+
205
+ | `createHelpdeskHandlers` option | |
206
+ |---|---|
207
+ | `deps` | The `HelpdeskDeps` (store, `gatewayFor`, optional drafter, notifier, `controls`, clock, config). The handlers call the domain functions with it and nothing else. |
208
+ | `inbox` | `HelpdeskInboxQueries`: `listThreads(query)` (filters `states`, `mailbox`, `assigneeId`: undefined is anyone, null is unassigned; `cursor` and `limit`; newest activity first; returns `{ rows: [{ thread, assignee }], nextCursor }`), `listMessages(threadId)` (oldest first), `getAssignee(threadId)`, `setAssignee(threadId, { id, name } \| null)`. Assignment is app data; the domain never reads it. |
209
+ | `getStaff(request)` | `{ id, name, email }` for staff, null for anyone else (every route answers 401). `id` is stored as `approvedBy` on a send and as `dismissedBy` on a dismissal. |
210
+ | `isProduction` | Outside production every route answers 403 `not_production`. |
211
+ | `basePath` | Default `/api/helpdesk`. |
212
+ | `appOrigin` | Writes with an `Origin` header from elsewhere answer 403. Default: the request URL's origin. |
213
+ | `onError` | Unexpected errors (default `console.error`); the response is a generic 500 without details. |
214
+
215
+ **Routes** (under `basePath`). Every write answers `{ detail, message, outcome? }` with the thread as it is after the action.
216
+
217
+ | Route | Calls |
218
+ |---|---|
219
+ | `GET threads?state=review,new&mailbox=&assignee=me\|none\|<id>&cursor=&limit=` | `inbox.listThreads`; also returns the pilot switches per mailbox |
220
+ | `GET threads/:id` | thread, messages (metadata), latest draft, send status, and `actions` the state machine and pilot controls allow now |
221
+ | `GET threads/:id/messages/:messageId` | `gateway.getMessage` through the scope check; body as plain text, plus its attachments (`gateway.listAttachments`, inline images left out). For a delivery report (`bounce: true` in the detail's messages) also `bounce`: `{ recipient, statusCode, reason, diagnostic }`, read on the server, with `reason` one plain Dutch sentence (e.g. 5.1.1 "Dit adres bestaat niet.") |
222
+ | `GET threads/:id/messages/:messageId/attachments/:attachmentId` | download of one file attachment of that message (`gateway.downloadAttachment`) |
223
+ | `PUT threads/:id/draft` `{ text }` | `editDraft` |
224
+ | `POST threads/:id/regenerate` | `regenerateDraft` |
225
+ | `POST threads/:id/send` `{ acceptStale? }` | `approveAndSend` |
226
+ | `POST`/`DELETE threads/:id/assign` | `inbox.setAssignee` (to the signed-in staff member / nobody) |
227
+ | `POST threads/:id/waiting` | `markWaiting` |
228
+ | `POST threads/:id/close` | `closeThread` |
229
+ | `POST threads/:id/unignore` | `unignoreThread` |
230
+ | `POST threads/:id/replied-externally` | `markRepliedExternally` |
231
+ | `POST threads/:id/dismiss` | `dismissThread`, with the signed-in staff id as `dismissedBy` |
232
+ | `POST threads/:id/resolve-send` `{ outcome: "sent" \| "not_sent" }` | `resolveUnknownSend` |
233
+
234
+ Errors are `{ error, message }` with a Dutch `message` (`INBOX_ERROR_MESSAGES`): `paused` (423), `write_blocked` (409, with `mode`), `mailbox_not_in_scope` (403), `stale_draft`, `draft_not_ready`, `invalid_transition`, `stale_thread`, `send_in_progress`, `send_outcome_unknown` (409), `empty_reply` (422), `send_failed` (502, Graph refused, nothing sent), `thread_not_found` (404). A send whose outcome is unclear answers 200 with `outcome: "unknown_outcome"`. Responses never carry draft bodies from Outlook, idempotency keys, draft ids or raw error text.
235
+
236
+ **Attachments.** The message body route lists the attachments of a customer mail; inline images (signature logos) are left out. A file up to 4 MB gets a download link on the app itself; a larger file, an attached mail (`item`) or a link to a shared file (`reference`) is listed with a note to open it in Outlook, because a Vercel function answers at most 4.5 MB. The download is checked like the body (the message must belong to the ticket, scope check, paused refuses) and the attachment id must be one Graph lists for that message. It is always served as `application/octet-stream` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` and a sandbox CSP, so customer files never render on the app's origin. Both gateway methods are optional: a gateway without them shows no attachments. mail-graph's `MailboxClient` has them. Attachments are never stored by the helpdesk.
237
+
238
+ **Files on a reply.** With `deps.attachments` (`ReplyAttachmentsDeps`: a `ReplyAttachmentStore` over the app's table and `ReplyFiles` over its file storage) and a gateway with `addAttachment`, staff can add files to the reply they are writing. The file never passes the app server: `startReplyUpload` checks the ticket and the limits and lets the app presign a direct upload, the browser PUTs the file to storage, and `addReplyAttachment` has the app verify the stored object before the file joins the reply. Limits: 10 files and 20 MB together per reply, checked before the upload and again on the real object. Files can be added while the reply can still be approved (review, not a bounced ticket) and removed until it is sent (`removeReplyAttachment`). Outlook is touched only at approval: `approveAndSend` adds the files to the draft it creates at that moment, right before `sendDraft`; a file that cannot be added fails the send as `failed_not_sent`, so nothing goes out without the approved files, and a retry on the same draft skips files already on it. After the send the files are marked as sent with that intent, also when staff confirm an unclear send. Dismissing a ticket or "Al beantwoord in Outlook" drops the unsent files. Routes: `POST threads/:id/attachments/upload` (`{ name, contentType, size }`, answers the upload target), `POST threads/:id/attachments` (`{ uploadToken }`), `DELETE threads/:id/attachments/:attachmentId`. The detail carries `replyAttachments` (unsent ones, and those of the latest sent reply) and the action `attach`.
239
+
240
+ **Message bodies are text only.** The package has no HTML sanitiser, so it never sends mail HTML to the page: the body is asked from Graph as text, and any HTML that still comes back goes through `htmlToPlainText` (tags, scripts, styles and comments dropped, entities decoded). The page renders it as a text node with `white-space: pre-wrap`. Bodies are fetched per message, once per open thread; while the helpdesk is paused they are not fetched (no Graph call), the rest of the thread still shows.
241
+
242
+ | `HelpdeskInbox` prop | |
243
+ |---|---|
244
+ | `apiBasePath` | Default `/api/helpdesk`. |
245
+ | `classNames` | Partial `HelpdeskInboxClassNames`; defaults (`DEFAULT_HELPDESK_INBOX_CLASSNAMES`) use the shadcn tokens of the company app template. The only way to style it. |
246
+ | `pollIntervalMs` | List and open thread refresh, default 30000, at least 5000. A draft being edited is never overwritten. |
247
+ | `fetch` | Custom fetch (tests, a wrapper that adds headers). |
248
+ | `renderCustomerContext` | `(thread) => ReactNode`, shown above the messages: the app's own data for the customer or project. |
249
+ | `initialThreadId`, `onThreadChange` | Open a thread first (a link from a `review_needed` notification) and keep the open thread in the URL. |
250
+
251
+ What staff sees: one page with the list and the open ticket side by side; on a phone the two are shown one at a time with "Terug naar tickets" (hidden from `md` up, where the list stays visible). A paused helpdesk shows a banner.
252
+
253
+ - **List.** Status and assignee filters side by side (native, labelled selects with the app's input style; the chosen status shows the count the list already has), a mailbox filter below them when there are several mailboxes, and "Meer laden". Each ticket shows its subject and time, the customer, and one status badge.
254
+ - **One status badge.** State and review reason make one label: a bounced reply shows "Niet afgeleverd" instead of "Te beoordelen"; other reasons appear as small text next to the badge ("Voorstel klaar", "Zelf antwoorden").
255
+ - **Header.** Subject, the status badge, `customer · naar <mailbox>`, the assignment ("Niet toegewezen · Aan mij", "Toegewezen aan jou", "Toegewezen aan <naam> · Aan mij") and a "Meer acties" menu with "Geen antwoord nodig", "Al beantwoord in Outlook" (both ask inline first) and "Toewijzing weghalen".
256
+ - **Conversation.** One chronological list: customer mails on the left with initials, the sent reply on the right ("Jouw antwoord · vanaf <mailbox>", its files, "Verstuurd, staat in Verzonden items"). Attachments are file chips with type and size. A delivery report is an event ("Jouw antwoord is niet afgeleverd", the Dutch reason, and the raw text under "Technische details van Microsoft"), never the raw mail. Only the latest sent reply is shown; the helpdesk keeps no copy of earlier ones.
257
+ - **Reply.** A "Jouw antwoord" card: "Voorstel van de AI · pas aan waar nodig" while the text is the drafter's, the reply box, files ("Bijlage toevoegen", max. 10 files, 20 MB together), and a footer with the sending mailbox and "Nieuw voorstel", "Opslaan" and "Goedkeuren en versturen". In `read_only` the ticket carries an "Alleen lezen" badge and the send button is disabled with the reason next to it. Without a usable draft the box is empty, and sending saves the typed reply first; a blank reply cannot be sent. Sending always asks for confirmation inline (recipient and mailbox named; edits are saved first), a stale reply asks again with "Toch versturen", and an unclear send shows "Het staat in Verzonden items" / "Het is niet verstuurd".
258
+ - **Bounced ticket.** Instead of the reply card, "Wat nu?" with "Opnieuw verstuurd vanuit Outlook" (`replied-externally`) and "Geen antwoord meer nodig" (`dismiss`).
259
+
260
+ A dismissed ticket leaves the list (a ticket whose new state falls outside the chosen view always does) and stays open with "Toch behandelen" to undo it.
261
+
262
+ ## Testing
263
+
264
+ `@dxv-systems/helpdesk/testing` has in-memory fakes (`createMemoryStore`, `createFakeMailbox`, `createFakeDrafter`, `createManualClock`, and `createTestWorld` to wire them), with the uniqueness and compare-and-set rules a real store needs. Apps can run their own store against the same scenarios. `createTestWorld({ controls })` sets the pilot controls (without them the package defaults apply), and each fake mailbox records every gateway call in `calls` and the mailbox writes in `writeCalls`, so a test can assert that read-only or paused made none.
265
+
266
+ ## Development
267
+
268
+ ```bash
269
+ npm run build --workspace @dxv-systems/helpdesk
270
+ npm run test --workspace @dxv-systems/helpdesk
271
+ ```
272
+
273
+ Output is CommonJS, like the other packages.