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