@dxv-systems/helpdesk 0.0.0-stage → 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.
Files changed (46) hide show
  1. package/README.md +272 -2
  2. package/dist/bounce.d.ts +13 -0
  3. package/dist/bounce.js +79 -0
  4. package/dist/controls.d.ts +13 -0
  5. package/dist/controls.js +82 -0
  6. package/dist/draft-body.d.ts +4 -0
  7. package/dist/draft-body.js +38 -0
  8. package/dist/drafting.d.ts +18 -0
  9. package/dist/drafting.js +243 -0
  10. package/dist/errors.d.ts +60 -0
  11. package/dist/errors.js +133 -0
  12. package/dist/inbox/handlers.d.ts +36 -0
  13. package/dist/inbox/handlers.js +523 -0
  14. package/dist/inbox/messages.d.ts +34 -0
  15. package/dist/inbox/messages.js +66 -0
  16. package/dist/inbox/plain-text.d.ts +1 -0
  17. package/dist/inbox/plain-text.js +58 -0
  18. package/dist/inbox/types.d.ts +116 -0
  19. package/dist/inbox/types.js +2 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.js +66 -0
  22. package/dist/ingest.d.ts +25 -0
  23. package/dist/ingest.js +335 -0
  24. package/dist/internal.d.ts +8 -0
  25. package/dist/internal.js +44 -0
  26. package/dist/noise.d.ts +17 -0
  27. package/dist/noise.js +77 -0
  28. package/dist/react.d.ts +97 -0
  29. package/dist/react.js +576 -0
  30. package/dist/reply-attachments.d.ts +28 -0
  31. package/dist/reply-attachments.js +119 -0
  32. package/dist/sending.d.ts +34 -0
  33. package/dist/sending.js +268 -0
  34. package/dist/server.d.ts +6 -0
  35. package/dist/server.js +10 -0
  36. package/dist/states.d.ts +13 -0
  37. package/dist/states.js +62 -0
  38. package/dist/subject.d.ts +2 -0
  39. package/dist/subject.js +30 -0
  40. package/dist/testing/index.d.ts +97 -0
  41. package/dist/testing/index.js +472 -0
  42. package/dist/threading.d.ts +18 -0
  43. package/dist/threading.js +66 -0
  44. package/dist/types.d.ts +339 -0
  45. package/dist/types.js +2 -0
  46. package/package.json +71 -4
package/README.md CHANGED
@@ -1,3 +1,273 @@
1
- # Temporary Holding Version
1
+ # @dxv-systems/helpdesk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -0,0 +1,13 @@
1
+ import type { HelpdeskStore, Thread } from "./types";
2
+ export declare function bouncedReplyRefs(bodyText: string): string[];
3
+ export interface BounceReport {
4
+ recipient: string | null;
5
+ statusCode: string | null;
6
+ diagnostic: string;
7
+ }
8
+ export declare function readBounceReport(text: string): BounceReport;
9
+ export declare function matchBounce(store: HelpdeskStore, input: {
10
+ mailbox: string;
11
+ refs: string[];
12
+ conversationId: string | null;
13
+ }): Promise<Thread | null>;
package/dist/bounce.js ADDED
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bouncedReplyRefs = bouncedReplyRefs;
4
+ exports.readBounceReport = readBounceReport;
5
+ exports.matchBounce = matchBounce;
6
+ const threading_1 = require("./threading");
7
+ const QUOTED_HEADER = /^(?:in-reply-to|references)[ \t]*:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/gim;
8
+ function bouncedReplyRefs(bodyText) {
9
+ const ids = new Set();
10
+ for (const match of bodyText.matchAll(QUOTED_HEADER)) {
11
+ for (const raw of match[1]?.match(/<[^<>\s]+>/g) ?? []) {
12
+ const id = (0, threading_1.normalizeMessageId)(raw);
13
+ if (id)
14
+ ids.add(id);
15
+ }
16
+ }
17
+ return [...ids];
18
+ }
19
+ const MAX_DIAGNOSTIC_CHARS = 2000;
20
+ const ADDRESS = String.raw `<?([^\s<>;:()"',]+@[^\s<>;:()"',]+)>?`;
21
+ const RECIPIENT_PATTERNS = [
22
+ new RegExp(String.raw `^(?:final|original)-recipient:[ \t]*(?:rfc822;)?[ \t]*${ADDRESS}[ \t]*$`, "im"),
23
+ new RegExp(String.raw `^recipient:[ \t]*${ADDRESS}[ \t]*$`, "im"),
24
+ new RegExp(String.raw `(?:your message to|je bericht aan|uw bericht aan)[ \t]+${ADDRESS}(?=[\s.,]|$)`, "i"),
25
+ new RegExp(String.raw `(?:delivery has failed to these recipients or groups|bezorging is mislukt voor deze ontvangers of groepen):\s*${ADDRESS}(?=[\s.,]|$)`, "i"),
26
+ ];
27
+ const CODE_PATTERNS = [/\b[245]\d\d[ -]#?([245]\.\d{1,3}\.\d{1,3})\b/, /(?:status:|#)[ \t]*([245]\.\d{1,3}\.\d{1,3})\b/i, /\b([45]\.\d{1,3}\.\d{1,3})\b/];
28
+ const DIAGNOSTIC_START = /(?:diagnostic information for administrators|diagnostische gegevens voor beheerders)\s*:/i;
29
+ const DIAGNOSTIC_END = /(?:original message headers|oorspronkelijke berichtkoppen)\s*:/i;
30
+ function readBounceReport(text) {
31
+ const body = text.replace(/\r\n?/g, "\n");
32
+ let recipient = null;
33
+ for (const re of RECIPIENT_PATTERNS) {
34
+ const found = re.exec(body)?.[1];
35
+ if (found) {
36
+ recipient = found.replace(/[.]+$/, "").toLowerCase();
37
+ break;
38
+ }
39
+ }
40
+ let statusCode = null;
41
+ for (const re of CODE_PATTERNS) {
42
+ const found = re.exec(body)?.[1];
43
+ if (found) {
44
+ statusCode = found;
45
+ break;
46
+ }
47
+ }
48
+ const start = DIAGNOSTIC_START.exec(body);
49
+ const rest = start ? body.slice(start.index + start[0].length) : body;
50
+ const end = DIAGNOSTIC_END.exec(rest);
51
+ let diagnostic = (end ? rest.slice(0, end.index) : rest).replace(/\n{3,}/g, "\n\n").trim();
52
+ if (diagnostic.length > MAX_DIAGNOSTIC_CHARS)
53
+ diagnostic = `${diagnostic.slice(0, MAX_DIAGNOSTIC_CHARS).trimEnd()}…`;
54
+ return { recipient, statusCode, diagnostic };
55
+ }
56
+ const awaitsDelivery = (t) => t.state === "sent" || t.state === "waiting";
57
+ async function matchBounce(store, input) {
58
+ const { mailbox, refs, conversationId } = input;
59
+ if (refs.length > 0) {
60
+ const ids = [...new Set((await store.findMessageThreads(mailbox, refs)).map((h) => h.threadId))];
61
+ const threads = [];
62
+ for (const id of ids) {
63
+ const t = await store.getThread(id);
64
+ if (t && t.mailbox === mailbox)
65
+ threads.push(t);
66
+ }
67
+ if (threads.length === 1)
68
+ return threads[0] ?? null;
69
+ const answered = threads.filter(awaitsDelivery);
70
+ if (answered.length === 1)
71
+ return answered[0] ?? null;
72
+ if (threads.length > 1)
73
+ return null;
74
+ }
75
+ if (!conversationId)
76
+ return null;
77
+ const answered = (await store.findThreadsByConversationId(mailbox, conversationId)).filter((t) => t.mailbox === mailbox && awaitsDelivery(t));
78
+ return answered.length === 1 ? (answered[0] ?? null) : null;
79
+ }
@@ -0,0 +1,13 @@
1
+ import type { HelpdeskConfig, HelpdeskControls, HelpdeskDeps, MailboxGateway, MailboxWriteMode, Paused } from "./types";
2
+ export declare const DEFAULT_WRITE_MODE: MailboxWriteMode;
3
+ export declare const paused: () => Paused;
4
+ export declare function isPaused(value: unknown): value is Paused;
5
+ export declare function loadControls(deps: HelpdeskDeps): Promise<HelpdeskControls>;
6
+ export declare function writeModeFor(controls: HelpdeskControls, mailbox: string): MailboxWriteMode;
7
+ export declare function assertInScope(config: HelpdeskConfig, mailbox: string): void;
8
+ export interface MailboxAccess {
9
+ mailbox: string;
10
+ mode: MailboxWriteMode;
11
+ gateway(): MailboxGateway;
12
+ }
13
+ export declare function mailboxAccess(deps: HelpdeskDeps, controls: HelpdeskControls, mailbox: string): MailboxAccess;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.paused = exports.DEFAULT_WRITE_MODE = void 0;
4
+ exports.isPaused = isPaused;
5
+ exports.loadControls = loadControls;
6
+ exports.writeModeFor = writeModeFor;
7
+ exports.assertInScope = assertInScope;
8
+ exports.mailboxAccess = mailboxAccess;
9
+ const errors_1 = require("./errors");
10
+ exports.DEFAULT_WRITE_MODE = "read_only";
11
+ const ALLOWED_WRITES = {
12
+ read_only: [],
13
+ approve_and_send: ["createReply", "updateDraftBody", "addAttachment", "sendDraft"],
14
+ };
15
+ const norm = (address) => address.trim().toLowerCase();
16
+ const paused = () => ({ status: "paused" });
17
+ exports.paused = paused;
18
+ function isPaused(value) {
19
+ return typeof value === "object" && value !== null && value.status === "paused";
20
+ }
21
+ async function loadControls(deps) {
22
+ const controls = typeof deps.controls === "function" ? await deps.controls() : deps.controls;
23
+ return controls ?? { paused: false };
24
+ }
25
+ function writeModeFor(controls, mailbox) {
26
+ const key = norm(mailbox);
27
+ for (const [address, mode] of Object.entries(controls.writeModes ?? {})) {
28
+ if (norm(address) === key)
29
+ return mode;
30
+ }
31
+ return controls.writeMode ?? exports.DEFAULT_WRITE_MODE;
32
+ }
33
+ function assertInScope(config, mailbox) {
34
+ if (!config.connectedMailboxes.some((a) => norm(a) === norm(mailbox))) {
35
+ throw new errors_1.MailboxNotInScopeError(mailbox);
36
+ }
37
+ }
38
+ function mailboxAccess(deps, controls, mailbox) {
39
+ assertInScope(deps.config, mailbox);
40
+ const mode = writeModeFor(controls, mailbox);
41
+ return { mailbox, mode, gateway: () => guardedGateway(deps, mailbox, mode) };
42
+ }
43
+ function guardedGateway(deps, mailbox, mode) {
44
+ const gw = deps.gatewayFor(mailbox);
45
+ if (norm(gw.address) !== norm(mailbox))
46
+ throw new errors_1.MailboxNotInScopeError(gw.address);
47
+ const allow = (op) => {
48
+ if (!ALLOWED_WRITES[mode].includes(op))
49
+ throw new errors_1.MailboxWriteBlockedError(mailbox, mode, op);
50
+ };
51
+ return {
52
+ address: gw.address,
53
+ delta: (options) => gw.delta(options),
54
+ getMessage: (id, options) => gw.getMessage(id, options),
55
+ ...(gw.listAttachments && gw.downloadAttachment
56
+ ? {
57
+ listAttachments: (id) => gw.listAttachments(id),
58
+ downloadAttachment: (id, attachmentId) => gw.downloadAttachment(id, attachmentId),
59
+ }
60
+ : {}),
61
+ ...(gw.addAttachment
62
+ ? {
63
+ async addAttachment(draftId, attachment) {
64
+ allow("addAttachment");
65
+ return gw.addAttachment(draftId, attachment);
66
+ },
67
+ }
68
+ : {}),
69
+ async createReply(id, options) {
70
+ allow("createReply");
71
+ return gw.createReply(id, options);
72
+ },
73
+ async updateDraftBody(id, body) {
74
+ allow("updateDraftBody");
75
+ return gw.updateDraftBody(id, body);
76
+ },
77
+ async sendDraft(id) {
78
+ allow("sendDraft");
79
+ return gw.sendDraft(id);
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,4 @@
1
+ export declare function renderCommentHtml(text: string): string;
2
+ export declare function findQuoteStart(html: string): number;
3
+ export declare function hasQuotedHistory(html: string): boolean;
4
+ export declare function prependToDraftBody(bodyHtml: string, text: string): string;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderCommentHtml = renderCommentHtml;
4
+ exports.findQuoteStart = findQuoteStart;
5
+ exports.hasQuotedHistory = hasQuotedHistory;
6
+ exports.prependToDraftBody = prependToDraftBody;
7
+ const QUOTE_MARKERS = [
8
+ /<div[^>]*\bid=["']appendonsend["']/i,
9
+ /<div[^>]*\bid=["']divRplyFwdMsg["']/i,
10
+ /<div[^>]*\bid=["']OutlookMessageHeader["']/i,
11
+ /<hr\b[^>]*>(?=[\s\S]{0,400}?(?:From|Van|Von)\s*:)/i,
12
+ /<blockquote\b/i,
13
+ ];
14
+ function escapeHtml(text) {
15
+ return text
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;");
20
+ }
21
+ function renderCommentHtml(text) {
22
+ const paragraphs = text.replace(/\r\n?/g, "\n").trim().split(/\n{2,}/);
23
+ return paragraphs
24
+ .map((p) => `<div>${escapeHtml(p).replace(/\n/g, "<br>")}</div>`)
25
+ .join("<div><br></div>");
26
+ }
27
+ function findQuoteStart(html) {
28
+ const found = QUOTE_MARKERS.map((re) => html.search(re)).filter((i) => i >= 0);
29
+ return found.length ? Math.min(...found) : -1;
30
+ }
31
+ function hasQuotedHistory(html) {
32
+ return findQuoteStart(html) >= 0;
33
+ }
34
+ function prependToDraftBody(bodyHtml, text) {
35
+ const bodyTag = /<body\b[^>]*>/i.exec(bodyHtml);
36
+ const at = bodyTag ? bodyTag.index + bodyTag[0].length : 0;
37
+ return bodyHtml.slice(0, at) + renderCommentHtml(text) + bodyHtml.slice(at);
38
+ }
@@ -0,0 +1,18 @@
1
+ import type { DraftResult, DraftRun, HelpdeskDeps, Paused, StoredMessage, Thread } from "./types";
2
+ export declare function currentReplyRun(deps: HelpdeskDeps, thread: Thread, run: DraftRun | null): DraftRun | null;
3
+ export type EnsureDraftOutcome = "started" | "exists" | "busy" | "not_needed" | "no_drafter" | "paused";
4
+ export declare function ensureDraft(deps: HelpdeskDeps, threadId: string, message: StoredMessage): Promise<EnsureDraftOutcome>;
5
+ export declare function startDraftFor(deps: HelpdeskDeps, threadId: string, message: StoredMessage): Promise<Exclude<EnsureDraftOutcome, "paused">>;
6
+ export declare function completeDraft(deps: HelpdeskDeps, input: {
7
+ mailbox: string;
8
+ inboundMessageId: string;
9
+ idempotencyKey: string;
10
+ result: DraftResult;
11
+ }): Promise<DraftRun | Paused>;
12
+ export declare function regenerateDraft(deps: HelpdeskDeps, threadId: string): Promise<DraftRun | Paused>;
13
+ export declare function editDraft(deps: HelpdeskDeps, input: {
14
+ threadId: string;
15
+ text: string;
16
+ }): Promise<DraftRun | Paused>;
17
+ export declare function sweepStuckDrafts(deps: HelpdeskDeps, mailbox: string): Promise<number | Paused>;
18
+ export declare function unignoreThread(deps: HelpdeskDeps, threadId: string): Promise<Thread | Paused>;