@dxv-systems/helpdesk 0.1.0 → 0.3.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,299 @@
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 (with their `direction`, `inbound` or `outbound`), sync state (one link per folder), 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`. `getLatestMessageForThread` returns the newest inbound message only. |
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`, `ingestSentPage`, `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 Inbox delta page and processes it; `ingestSentPage(deps, mailbox)` does the same for Sent Items (see Sent Items). `syncMailbox` runs Inbox pages until the round is complete, then Sent Items pages, each folder with its own `maxPages` budget, and returns the results of both in one array (`IngestResult.folder` is `inbox` or `sentitems`). An Inbox round that is not complete yet skips Sent Items for that run.
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
+ ### Sent Items
79
+
80
+ The ticket shows every reply the mailbox sent to the customer, whether it went out through the helpdesk or from Outlook. `ingestSentPage` reads the mailbox's Sent Items folder (`folderId: "sentitems"`) with the same rules as the Inbox: items first, link stored last, an expired link restarts from `max(connectedAt, sentLastReceivedAt - 48 h)`, and the kill switch returns `{ status: "paused" }`. The folder has its own link and newest time on the sync state (`sentLink`, `sentLastReceivedAt`).
81
+
82
+ - **Display only.** A sent mail is stored as a message with `direction: "outbound"`, `fromAddress` the mailbox, and `receivedAt` the send time. It never changes a ticket: no state change, no new `latestInboundId`, no draft, and it never opens a ticket.
83
+ - **Matching.** Always inside the same mailbox. An `In-Reply-To` that names a stored mail picks that ticket. Otherwise the ticket is found through the `References` chain and the `conversationId`; a ticket found only by conversation also needs a header hit, or a To or Cc recipient that is one of its participants. The sent mail is stored only when exactly one ticket is left. No match, or more than one: it is skipped and counted in `IngestResult.unmatched`, and never stored.
84
+ - **Replies sent by the helpdesk.** With immutable ids a draft keeps its id after it is sent, so the Sent Items copy of an approved reply has the id the send intent recorded. The inbox uses that to show it once (see Inbox UI).
85
+ - **Skipped:** drafts, mail sent before `connectedAt`, and mail already stored (a re-read changes nothing).
86
+ - **Removed items** set `removedAt`: gone from Sent Items.
87
+ - **Customer replies to our mail.** A customer reply's `In-Reply-To` usually names our sent mail. Once that mail is synced, the reply threads through its headers like any other.
88
+
89
+ Mail the mailbox sent to itself or that lands in its own Inbox is still skipped by the Inbox sync.
90
+
91
+ ## Threading
92
+
93
+ `matchThread` in this order, always inside one mailbox (also checked on the rows a store returns):
94
+
95
+ 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.
96
+ 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 is stored only once Sent Items is synced (and never when it matched no ticket); 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.
97
+ 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.
98
+
99
+ ## Ticket states
100
+
101
+ `New`, `Ignored`, `Drafted`, `Review`, `Sent`, `Waiting`, `Closed`.
102
+
103
+ | From | Event | To |
104
+ |---|---|---|
105
+ | new | `ignore` | ignored |
106
+ | new | `draft` | drafted |
107
+ | drafted | `needs_review` | review |
108
+ | review | `approve` | sent |
109
+ | review | `redraft` | drafted |
110
+ | sent | `settle` | waiting |
111
+ | waiting | `customer_reply` | new |
112
+ | waiting | `close` | closed |
113
+ | ignored | `unignore` | new |
114
+ | new, drafted, review | `dismiss` | ignored |
115
+ | sent, waiting | `bounce` | review |
116
+
117
+ `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 inbound message (never our own sent mail), `dismissedBy` cleared, and a draft starts.
118
+
119
+ **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).
120
+
121
+ **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`.
122
+
123
+ **A customer mail (one that is not noise) arrives for a ticket in:**
124
+
125
+ | State | What happens |
126
+ |---|---|
127
+ | new | Appended. No second draft run starts while one is in flight; its result is stale and goes to review. |
128
+ | drafted | Appended, state kept. The draft is stale. |
129
+ | review | Appended, state kept, `reviewReason` becomes `stale_draft`. Staff regenerates (review to drafted) or approves with `acceptStale`. |
130
+ | sent, waiting | Same ticket goes to `new` (via `settle` then `customer_reply`) and a draft starts. |
131
+ | closed, ignored | Final. The mail opens a new ticket with `relatedThreadId` pointing at the old one. |
132
+
133
+ 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.
134
+
135
+ **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.
136
+
137
+ ## Noise detection
138
+
139
+ `classifyNoise` returns `{ action: "ignore" | "process", reason }`. Every rule needs a positive signal; unclear means process.
140
+
141
+ - 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.
142
+ - `Auto-Submitted` present and not `no`
143
+ - `Precedence: bulk | junk | list | auto_reply`
144
+ - 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)
145
+ - `List-Id` or `List-Unsubscribe`
146
+ - 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.
147
+ - the app's own addresses and every connected mailbox
148
+
149
+ `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.
150
+
151
+ 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.
152
+
153
+ ## Drafting
154
+
155
+ `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.
156
+
157
+ `completeDraft` stores the text, confidence, reason and category on the run and moves the ticket to review. It writes nothing to the mailbox.
158
+
159
+ **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.
160
+
161
+ **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.
162
+
163
+ **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.
164
+
165
+ ## Outlook drafts
166
+
167
+ 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.
168
+
169
+ 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.
170
+
171
+ ## Sending
172
+
173
+ `approveAndSend(deps, { threadId, approvedBy })`:
174
+
175
+ 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`.
176
+ 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.
177
+ 3. Success marks the intent `sent` and the ticket `sent`. A second approval returns `already_sent`.
178
+ 4. An HTTP 400, 401, 403 or 429 means Graph refused it: `failed_not_sent`, and the same flow can be retried.
179
+ 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`.
180
+
181
+ Concurrent callers get `in_progress` or `already_sent`.
182
+
183
+ ## Inbox UI
184
+
185
+ 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.
186
+
187
+ **Route** `app/api/helpdesk/[...path]/route.ts`:
188
+
189
+ ```ts
190
+ import { createHelpdeskHandlers } from "@dxv-systems/helpdesk/server";
191
+ import { helpdeskDeps } from "@/lib/helpdesk/deps"; // the same HelpdeskDeps the cron uses
192
+ import { helpdeskInbox } from "@/lib/helpdesk/inbox"; // HelpdeskInboxQueries over the app's tables
193
+ import { getSession } from "@/lib/auth";
194
+
195
+ const { handle } = createHelpdeskHandlers({
196
+ deps: helpdeskDeps,
197
+ inbox: helpdeskInbox,
198
+ getStaff: async (request) => {
199
+ const user = await getSession(request);
200
+ return user?.isStaff ? { id: user.id, name: user.name, email: user.email } : null;
201
+ },
202
+ isProduction: process.env.VERCEL_ENV === "production",
203
+ });
204
+
205
+ export { handle as GET, handle as POST, handle as PUT, handle as DELETE };
206
+ ```
207
+
208
+ **Page** `app/(staff)/helpdesk/page.tsx`:
209
+
210
+ ```tsx
211
+ import { HelpdeskInbox } from "@dxv-systems/helpdesk/react";
212
+
213
+ export default function HelpdeskPage() {
214
+ return <HelpdeskInbox renderCustomerContext={(thread) => <CustomerCard email={thread.customerAddress} />} />;
215
+ }
216
+ ```
217
+
218
+ | `createHelpdeskHandlers` option | |
219
+ |---|---|
220
+ | `deps` | The `HelpdeskDeps` (store, `gatewayFor`, optional drafter, notifier, `controls`, clock, config). The handlers call the domain functions with it and nothing else. |
221
+ | `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)` (every message of both directions, oldest first), `getAssignee(threadId)`, `setAssignee(threadId, { id, name } \| null)`. Assignment is app data; the domain never reads it. |
222
+ | `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. |
223
+ | `isProduction` | Outside production every route answers 403 `not_production`. |
224
+ | `basePath` | Default `/api/helpdesk`. |
225
+ | `appOrigin` | Writes with an `Origin` header from elsewhere answer 403. Default: the request URL's origin. |
226
+ | `onError` | Unexpected errors (default `console.error`); the response is a generic 500 without details. |
227
+
228
+ **Routes** (under `basePath`). Every write answers `{ detail, message, outcome? }` with the thread as it is after the action.
229
+
230
+ | Route | Calls |
231
+ |---|---|
232
+ | `GET threads?state=review,new&mailbox=&assignee=me\|none\|<id>&cursor=&limit=` | `inbox.listThreads`; also returns the pilot switches per mailbox |
233
+ | `GET threads/:id` | thread, messages (metadata; `outbound: true` for mail synced from Sent Items), latest draft, send status (`send.messageId`: the sent mail's id once the status is `sent`), and `actions` the state machine and pilot controls allow now |
234
+ | `GET threads/:id/messages/:messageId` | `gateway.getMessage` through the scope check; body as plain text, plus its attachments (`gateway.listAttachments`, inline images left out). A sent mail's body and attachments load the same way. For a delivery report (`bounce: true` in the detail's messages, inbound only) 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.") |
235
+ | `GET threads/:id/messages/:messageId/attachments/:attachmentId` | download of one file attachment of that message (`gateway.downloadAttachment`) |
236
+ | `PUT threads/:id/draft` `{ text }` | `editDraft` |
237
+ | `POST threads/:id/regenerate` | `regenerateDraft` |
238
+ | `POST threads/:id/send` `{ acceptStale? }` | `approveAndSend` |
239
+ | `POST`/`DELETE threads/:id/assign` | `inbox.setAssignee` (to the signed-in staff member / nobody) |
240
+ | `POST threads/:id/waiting` | `markWaiting` |
241
+ | `POST threads/:id/close` | `closeThread` |
242
+ | `POST threads/:id/unignore` | `unignoreThread` |
243
+ | `POST threads/:id/replied-externally` | `markRepliedExternally` |
244
+ | `POST threads/:id/dismiss` | `dismissThread`, with the signed-in staff id as `dismissedBy` |
245
+ | `POST threads/:id/resolve-send` `{ outcome: "sent" \| "not_sent" }` | `resolveUnknownSend` |
246
+
247
+ 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. The one exception is `send.messageId` of a sent reply: once sent, the draft id is the id of the mail in Sent Items.
248
+
249
+ **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.
250
+
251
+ **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`.
252
+
253
+ **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.
254
+
255
+ | `HelpdeskInbox` prop | |
256
+ |---|---|
257
+ | `apiBasePath` | Default `/api/helpdesk`. |
258
+ | `classNames` | Partial `HelpdeskInboxClassNames`; defaults (`DEFAULT_HELPDESK_INBOX_CLASSNAMES`) use the shadcn tokens of the company app template. The only way to style it. |
259
+ | `pollIntervalMs` | List and open thread refresh, default 30000, at least 5000. A draft being edited is never overwritten. |
260
+ | `fetch` | Custom fetch (tests, a wrapper that adds headers). |
261
+ | `renderCustomerContext` | `(thread) => ReactNode`, shown above the messages: the app's own data for the customer or project. |
262
+ | `initialThreadId`, `onThreadChange` | Open a thread first (a link from a `review_needed` notification) and keep the open thread in the URL. |
263
+
264
+ 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.
265
+
266
+ - **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.
267
+ - **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").
268
+ - **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".
269
+ - **Conversation.** One chronological list: customer mails on the left with initials, every reply the mailbox sent on the right ("Jouw antwoord · vanaf <mailbox>", the body and files loaded like customer mail, "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. A reply just sent through the helpdesk shows its approved text until its Sent Items copy is synced (up to one sync interval); from then on only the copy is shown.
270
+ - **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".
271
+ - **Bounced ticket.** Instead of the reply card, "Wat nu?" with "Opnieuw verstuurd vanuit Outlook" (`replied-externally`) and "Geen antwoord meer nodig" (`dismiss`).
272
+
273
+ 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.
274
+
275
+ ## Upgrading from 0.2
276
+
277
+ 0.3 syncs Sent Items. The app's store and inbox queries need these changes before the new version runs:
278
+
279
+ 1. **Messages: a `direction` column**, `'inbound'` or `'outbound'`, not null, default `'inbound'`. Every existing row is inbound mail, so the default backfills it. Store it on insert (`createThread`, `appendMessage`) and return it from every read of a message.
280
+ 2. **Sync state: `sent_link` and `sent_last_received_at`**, both nullable text (or timestamp for the second). `saveSyncState` must store `sentLink` and `sentLastReceivedAt` and `getSyncState` must return them. If they are not stored, Sent Items is read again from `connectedAt` on every run: nothing is stored twice, but every run costs more.
281
+ 3. **`getLatestMessageForThread` filters on `direction = 'inbound'`.** Without the filter, `unignoreThread` could make our own sent mail the message to answer.
282
+ 4. **`listMessages` returns both directions**, oldest first. The inbox shows outbound messages as replies. `findMessageThreads` and `findThreadsByConversationId` also look at both directions; no change is needed there if they do not filter on it.
283
+ 5. **`syncMailbox` results** now hold Inbox and Sent Items pages in one array. Code that reads them can use `IngestResult.folder` (`inbox` or `sentitems`) and the new `unmatched` count (always 0 for the Inbox).
284
+ 6. **`InboxThreadDetail`**: messages have `outbound`, and `send` has `messageId`. Only relevant to apps that read the detail themselves; the React page handles both.
285
+
286
+ No new permissions: Sent Items is part of the same mailbox access. A mailbox that is already connected starts syncing its Sent Items from `connectedAt` on the first run after the upgrade, so earlier replies are picked up too.
287
+
288
+ ## Testing
289
+
290
+ `@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. `deliver({ ..., folder: "sentitems", to: [...] })` puts a mail in the fake mailbox's Sent Items.
291
+
292
+ ## Development
293
+
294
+ ```bash
295
+ npm run build --workspace @dxv-systems/helpdesk
296
+ npm run test --workspace @dxv-systems/helpdesk
297
+ ```
298
+
299
+ Output is CommonJS, like the other packages.