@dxv-systems/helpdesk 0.2.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 +37 -11
- package/dist/inbox/handlers.js +6 -3
- package/dist/inbox/types.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/ingest.d.ts +4 -0
- package/dist/ingest.js +144 -22
- package/dist/react.js +13 -3
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +18 -11
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ The app owns the schema and the cron, and mounts the inbox (see Inbox UI). It pa
|
|
|
14
14
|
|
|
15
15
|
| Interface | What the app implements |
|
|
16
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`. |
|
|
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
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
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
20
|
| `Notifier` (optional) | `review_needed` and `send_outcome_unknown`. A throwing notifier never breaks the flow. |
|
|
@@ -55,7 +55,7 @@ Three switches the app passes in `deps.controls` (a value, or a function that is
|
|
|
55
55
|
|
|
56
56
|
The package default is `read_only`, so a mailbox nobody configured is never written to.
|
|
57
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.
|
|
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
59
|
|
|
60
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
61
|
|
|
@@ -65,7 +65,7 @@ Run `syncMailbox` for a mailbox from one worker at a time (a lock or a queue). O
|
|
|
65
65
|
|
|
66
66
|
## Ingestion
|
|
67
67
|
|
|
68
|
-
`ingestPage(deps, mailbox)` fetches one delta page and processes it; `syncMailbox`
|
|
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
69
|
|
|
70
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
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.
|
|
@@ -75,12 +75,25 @@ Run `syncMailbox` for a mailbox from one worker at a time (a lock or a queue). O
|
|
|
75
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
76
|
- Items are processed oldest first, so a reply in the same page finds its parent.
|
|
77
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
|
+
|
|
78
91
|
## Threading
|
|
79
92
|
|
|
80
93
|
`matchThread` in this order, always inside one mailbox (also checked on the rows a store returns):
|
|
81
94
|
|
|
82
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.
|
|
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
|
|
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.
|
|
84
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.
|
|
85
98
|
|
|
86
99
|
## Ticket states
|
|
@@ -101,7 +114,7 @@ Run `syncMailbox` for a mailbox from one worker at a time (a lock or a queue). O
|
|
|
101
114
|
| new, drafted, review | `dismiss` | ignored |
|
|
102
115
|
| sent, waiting | `bounce` | review |
|
|
103
116
|
|
|
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.
|
|
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.
|
|
105
118
|
|
|
106
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).
|
|
107
120
|
|
|
@@ -205,7 +218,7 @@ export default function HelpdeskPage() {
|
|
|
205
218
|
| `createHelpdeskHandlers` option | |
|
|
206
219
|
|---|---|
|
|
207
220
|
| `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. |
|
|
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. |
|
|
209
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. |
|
|
210
223
|
| `isProduction` | Outside production every route answers 403 `not_production`. |
|
|
211
224
|
| `basePath` | Default `/api/helpdesk`. |
|
|
@@ -217,8 +230,8 @@ export default function HelpdeskPage() {
|
|
|
217
230
|
| Route | Calls |
|
|
218
231
|
|---|---|
|
|
219
232
|
| `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.") |
|
|
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.") |
|
|
222
235
|
| `GET threads/:id/messages/:messageId/attachments/:attachmentId` | download of one file attachment of that message (`gateway.downloadAttachment`) |
|
|
223
236
|
| `PUT threads/:id/draft` `{ text }` | `editDraft` |
|
|
224
237
|
| `POST threads/:id/regenerate` | `regenerateDraft` |
|
|
@@ -231,7 +244,7 @@ export default function HelpdeskPage() {
|
|
|
231
244
|
| `POST threads/:id/dismiss` | `dismissThread`, with the signed-in staff id as `dismissedBy` |
|
|
232
245
|
| `POST threads/:id/resolve-send` `{ outcome: "sent" \| "not_sent" }` | `resolveUnknownSend` |
|
|
233
246
|
|
|
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.
|
|
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.
|
|
235
248
|
|
|
236
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.
|
|
237
250
|
|
|
@@ -253,15 +266,28 @@ What staff sees: one page with the list and the open ticket side by side; on a p
|
|
|
253
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.
|
|
254
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").
|
|
255
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".
|
|
256
|
-
- **Conversation.** One chronological list: customer mails on the left with initials, the sent
|
|
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.
|
|
257
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".
|
|
258
271
|
- **Bounced ticket.** Instead of the reply card, "Wat nu?" with "Opnieuw verstuurd vanuit Outlook" (`replied-externally`) and "Geen antwoord meer nodig" (`dismiss`).
|
|
259
272
|
|
|
260
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.
|
|
261
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
|
+
|
|
262
288
|
## Testing
|
|
263
289
|
|
|
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.
|
|
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.
|
|
265
291
|
|
|
266
292
|
## Development
|
|
267
293
|
|
package/dist/inbox/handlers.js
CHANGED
|
@@ -204,11 +204,14 @@ function createHelpdeskHandlers(config) {
|
|
|
204
204
|
subject: m.subject,
|
|
205
205
|
receivedAt: m.receivedAt,
|
|
206
206
|
noise: m.noise.action === "ignore",
|
|
207
|
-
bounce: m.noise.reason === "bounce",
|
|
207
|
+
bounce: m.direction === "inbound" && m.noise.reason === "bounce",
|
|
208
208
|
removed: m.removedAt !== null,
|
|
209
|
+
outbound: m.direction === "outbound",
|
|
209
210
|
})),
|
|
210
211
|
draft: draftView(thread, run ?? (latestRun?.status === "failed" ? latestRun : null)),
|
|
211
|
-
send: intent
|
|
212
|
+
send: intent
|
|
213
|
+
? { status: intent.status, at: intent.updatedAt, messageId: intent.status === "sent" ? intent.draftId : null }
|
|
214
|
+
: null,
|
|
212
215
|
replyAttachments: await replyAttachmentViews(thread.id, intent),
|
|
213
216
|
actions: allowedActions(thread, run, intent, controls.paused, mode, Boolean(deps.drafter), canAttach(thread.mailbox)),
|
|
214
217
|
};
|
|
@@ -303,7 +306,7 @@ function createHelpdeskHandlers(config) {
|
|
|
303
306
|
throw new RouteError(404, "message_unavailable");
|
|
304
307
|
const { contentType, content } = message.body;
|
|
305
308
|
const text = contentType === "html" ? (0, plain_text_1.htmlToPlainText)(content) : content.replace(/\r\n?/g, "\n").trim();
|
|
306
|
-
const isBounce = stored.noise.reason === "bounce";
|
|
309
|
+
const isBounce = stored.direction === "inbound" && stored.noise.reason === "bounce";
|
|
307
310
|
const listed = !isBounce && gateway.listAttachments && message.hasAttachments !== false ? await gateway.listAttachments(messageId) : [];
|
|
308
311
|
const attachments = listed.filter((a) => !a.isInline).map((a) => attachmentView(threadId, messageId, a));
|
|
309
312
|
const body = { id: messageId, text, attachments, bounce: isBounce ? bounceView(text) : null };
|
package/dist/inbox/types.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ export interface InboxMessageView {
|
|
|
45
45
|
noise: boolean;
|
|
46
46
|
bounce: boolean;
|
|
47
47
|
removed: boolean;
|
|
48
|
+
outbound: boolean;
|
|
48
49
|
}
|
|
49
50
|
export interface InboxBounceView {
|
|
50
51
|
recipient: string | null;
|
|
@@ -104,6 +105,7 @@ export interface InboxThreadDetail {
|
|
|
104
105
|
send: {
|
|
105
106
|
status: SendStatus;
|
|
106
107
|
at: string;
|
|
108
|
+
messageId: string | null;
|
|
107
109
|
} | null;
|
|
108
110
|
replyAttachments: InboxReplyAttachmentView[];
|
|
109
111
|
actions: InboxAction[];
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { DEFAULT_WRITE_MODE, isPaused, writeModeFor } from "./controls";
|
|
|
5
5
|
export { normalizeSubject, isForwardSubject } from "./subject";
|
|
6
6
|
export { classifyNoise, type NoiseInput, type NoiseVerdict } from "./noise";
|
|
7
7
|
export { matchThread, normalizeMessageId, type ThreadCandidate, type ThreadMatch } from "./threading";
|
|
8
|
-
export { connectMailbox, ingestPage, isSyncFailing, syncMailbox, SYNC_FAILING_AFTER, type IngestResult, } from "./ingest";
|
|
8
|
+
export { connectMailbox, ingestPage, ingestSentPage, isSyncFailing, syncMailbox, SYNC_FAILING_AFTER, type IngestResult, type SyncFolder, } from "./ingest";
|
|
9
9
|
export { ensureDraft, completeDraft, regenerateDraft, editDraft, sweepStuckDrafts, unignoreThread, type EnsureDraftOutcome, } from "./drafting";
|
|
10
10
|
export { renderCommentHtml, prependToDraftBody, hasQuotedHistory, findQuoteStart, } from "./draft-body";
|
|
11
11
|
export { approveAndSend, reconcileUnknownSends, resolveUnknownSend, markRepliedExternally, markWaiting, closeThread, dismissThread, classifySendError, type SendOutcome, } from "./sending";
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.startReplyUpload = exports.removeReplyAttachment = exports.pendingReplyAttachments = exports.addReplyAttachment = exports.MAX_REPLY_ATTACHMENTS = exports.MAX_REPLY_ATTACHMENT_BYTES = exports.classifySendError = exports.dismissThread = exports.closeThread = exports.markWaiting = exports.markRepliedExternally = exports.resolveUnknownSend = exports.reconcileUnknownSends = exports.approveAndSend = exports.findQuoteStart = exports.hasQuotedHistory = exports.prependToDraftBody = exports.renderCommentHtml = exports.unignoreThread = exports.sweepStuckDrafts = exports.editDraft = exports.regenerateDraft = exports.completeDraft = exports.ensureDraft = exports.SYNC_FAILING_AFTER = exports.syncMailbox = exports.isSyncFailing = exports.ingestPage = exports.connectMailbox = exports.normalizeMessageId = exports.matchThread = exports.classifyNoise = exports.isForwardSubject = exports.normalizeSubject = exports.writeModeFor = exports.isPaused = exports.DEFAULT_WRITE_MODE = void 0;
|
|
17
|
+
exports.startReplyUpload = exports.removeReplyAttachment = exports.pendingReplyAttachments = exports.addReplyAttachment = exports.MAX_REPLY_ATTACHMENTS = exports.MAX_REPLY_ATTACHMENT_BYTES = exports.classifySendError = exports.dismissThread = exports.closeThread = exports.markWaiting = exports.markRepliedExternally = exports.resolveUnknownSend = exports.reconcileUnknownSends = exports.approveAndSend = exports.findQuoteStart = exports.hasQuotedHistory = exports.prependToDraftBody = exports.renderCommentHtml = exports.unignoreThread = exports.sweepStuckDrafts = exports.editDraft = exports.regenerateDraft = exports.completeDraft = exports.ensureDraft = exports.SYNC_FAILING_AFTER = exports.syncMailbox = exports.isSyncFailing = exports.ingestSentPage = exports.ingestPage = exports.connectMailbox = exports.normalizeMessageId = exports.matchThread = exports.classifyNoise = exports.isForwardSubject = exports.normalizeSubject = exports.writeModeFor = exports.isPaused = exports.DEFAULT_WRITE_MODE = void 0;
|
|
18
18
|
__exportStar(require("./types"), exports);
|
|
19
19
|
__exportStar(require("./errors"), exports);
|
|
20
20
|
__exportStar(require("./states"), exports);
|
|
@@ -33,6 +33,7 @@ Object.defineProperty(exports, "normalizeMessageId", { enumerable: true, get: fu
|
|
|
33
33
|
var ingest_1 = require("./ingest");
|
|
34
34
|
Object.defineProperty(exports, "connectMailbox", { enumerable: true, get: function () { return ingest_1.connectMailbox; } });
|
|
35
35
|
Object.defineProperty(exports, "ingestPage", { enumerable: true, get: function () { return ingest_1.ingestPage; } });
|
|
36
|
+
Object.defineProperty(exports, "ingestSentPage", { enumerable: true, get: function () { return ingest_1.ingestSentPage; } });
|
|
36
37
|
Object.defineProperty(exports, "isSyncFailing", { enumerable: true, get: function () { return ingest_1.isSyncFailing; } });
|
|
37
38
|
Object.defineProperty(exports, "syncMailbox", { enumerable: true, get: function () { return ingest_1.syncMailbox; } });
|
|
38
39
|
Object.defineProperty(exports, "SYNC_FAILING_AFTER", { enumerable: true, get: function () { return ingest_1.SYNC_FAILING_AFTER; } });
|
package/dist/ingest.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { HelpdeskDeps, Paused, SyncFailing, SyncState } from "./types";
|
|
2
|
+
export type SyncFolder = "inbox" | "sentitems";
|
|
2
3
|
export interface IngestResult {
|
|
4
|
+
folder: SyncFolder;
|
|
3
5
|
fetched: number;
|
|
4
6
|
created: number;
|
|
5
7
|
appended: number;
|
|
@@ -13,11 +15,13 @@ export interface IngestResult {
|
|
|
13
15
|
gone: number;
|
|
14
16
|
draftsStarted: number;
|
|
15
17
|
deliveryFailures: number;
|
|
18
|
+
unmatched: number;
|
|
16
19
|
restarted: boolean;
|
|
17
20
|
hasMore: boolean;
|
|
18
21
|
}
|
|
19
22
|
export declare function connectMailbox(deps: HelpdeskDeps, mailbox: string): Promise<SyncState>;
|
|
20
23
|
export declare function ingestPage(deps: HelpdeskDeps, mailbox: string): Promise<IngestResult | Paused>;
|
|
24
|
+
export declare function ingestSentPage(deps: HelpdeskDeps, mailbox: string): Promise<IngestResult | Paused>;
|
|
21
25
|
export declare function syncMailbox(deps: HelpdeskDeps, mailbox: string, options?: {
|
|
22
26
|
maxPages?: number;
|
|
23
27
|
}): Promise<IngestResult[] | Paused | SyncFailing>;
|
package/dist/ingest.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.SYNC_FAILING_AFTER = void 0;
|
|
4
4
|
exports.connectMailbox = connectMailbox;
|
|
5
5
|
exports.ingestPage = ingestPage;
|
|
6
|
+
exports.ingestSentPage = ingestSentPage;
|
|
6
7
|
exports.syncMailbox = syncMailbox;
|
|
7
8
|
exports.isSyncFailing = isSyncFailing;
|
|
8
9
|
const controls_1 = require("./controls");
|
|
@@ -34,32 +35,44 @@ function threadingHeaders(msg) {
|
|
|
34
35
|
const references = msg.references ?? (raw ? (raw.match(/<[^>]+>/g) ?? []) : []);
|
|
35
36
|
return { inReplyTo, references };
|
|
36
37
|
}
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return new Date(Math.max(Date.parse(state.connectedAt), back)).toISOString();
|
|
38
|
+
function cursorOf(state, folder) {
|
|
39
|
+
return folder === "inbox"
|
|
40
|
+
? { link: state.link, lastReceivedAt: state.lastReceivedAt }
|
|
41
|
+
: { link: state.sentLink ?? null, lastReceivedAt: state.sentLastReceivedAt ?? null };
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
function withCursor(state, folder, cursor) {
|
|
44
|
+
return folder === "inbox"
|
|
45
|
+
? { ...state, link: cursor.link, lastReceivedAt: cursor.lastReceivedAt }
|
|
46
|
+
: { ...state, sentLink: cursor.link, sentLastReceivedAt: cursor.lastReceivedAt };
|
|
47
|
+
}
|
|
48
|
+
function restartFrom(connectedAt, lastReceivedAt, overlapMs) {
|
|
49
|
+
if (!lastReceivedAt)
|
|
50
|
+
return connectedAt;
|
|
51
|
+
const back = Date.parse(lastReceivedAt) - overlapMs;
|
|
52
|
+
return new Date(Math.max(Date.parse(connectedAt), back)).toISOString();
|
|
53
|
+
}
|
|
54
|
+
async function fetchPage(deps, gateway, state, folder) {
|
|
44
55
|
const pageSize = deps.config.pageSize;
|
|
45
|
-
const
|
|
46
|
-
|
|
56
|
+
const cursor = cursorOf(state, folder);
|
|
57
|
+
const first = (link) => gateway.delta(link
|
|
58
|
+
? { link, ...(pageSize ? { pageSize } : {}) }
|
|
47
59
|
: {
|
|
48
|
-
folderId:
|
|
49
|
-
since: restartFrom(
|
|
60
|
+
folderId: folder,
|
|
61
|
+
since: restartFrom(state.connectedAt, cursor.lastReceivedAt, deps.config.restartOverlapMs ?? DEFAULT_RESTART_OVERLAP_MS),
|
|
50
62
|
...(pageSize ? { pageSize } : {}),
|
|
51
63
|
});
|
|
52
64
|
try {
|
|
53
|
-
return { page: await first(
|
|
65
|
+
return { page: await first(cursor.link), restarted: false };
|
|
54
66
|
}
|
|
55
67
|
catch (err) {
|
|
56
|
-
if (!
|
|
68
|
+
if (!cursor.link || !(0, errors_1.isDeltaLinkExpired)(err))
|
|
57
69
|
throw err;
|
|
58
|
-
return { page: await first(
|
|
70
|
+
return { page: await first(null), restarted: true };
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
|
-
function emptyResult() {
|
|
73
|
+
function emptyResult(folder) {
|
|
62
74
|
return {
|
|
75
|
+
folder,
|
|
63
76
|
fetched: 0,
|
|
64
77
|
created: 0,
|
|
65
78
|
appended: 0,
|
|
@@ -73,12 +86,13 @@ function emptyResult() {
|
|
|
73
86
|
gone: 0,
|
|
74
87
|
draftsStarted: 0,
|
|
75
88
|
deliveryFailures: 0,
|
|
89
|
+
unmatched: 0,
|
|
76
90
|
restarted: false,
|
|
77
91
|
hasMore: false,
|
|
78
92
|
};
|
|
79
93
|
}
|
|
80
94
|
async function reconcileExisting(deps, existing, result) {
|
|
81
|
-
if (existing.removedAt)
|
|
95
|
+
if (existing.removedAt || existing.direction === "outbound")
|
|
82
96
|
return;
|
|
83
97
|
const thread = await deps.store.getThread(existing.threadId);
|
|
84
98
|
if (thread && (thread.state === "new" || thread.state === "drafted")) {
|
|
@@ -126,6 +140,7 @@ async function processNew(deps, gateway, mailbox, item, state, result) {
|
|
|
126
140
|
noise: { action: noise.action, reason: noise.reason },
|
|
127
141
|
suppressAutoResponse: noise.suppressAutoResponse === true,
|
|
128
142
|
removedAt: null,
|
|
143
|
+
direction: "inbound",
|
|
129
144
|
};
|
|
130
145
|
if (noise.reason === "bounce") {
|
|
131
146
|
const refs = [...new Set([...(0, bounce_1.bouncedReplyRefs)(msg.body?.content ?? ""), ...headerRefs(headers)])];
|
|
@@ -260,7 +275,7 @@ async function processItem(deps, gateway, mailbox, item, state, result) {
|
|
|
260
275
|
}
|
|
261
276
|
await processNew(deps, gateway, mailbox, item, state, result);
|
|
262
277
|
}
|
|
263
|
-
async function
|
|
278
|
+
async function ingestFolderPage(deps, mailbox, folder, process) {
|
|
264
279
|
const controls = await (0, controls_1.loadControls)(deps);
|
|
265
280
|
if (controls.paused)
|
|
266
281
|
return (0, controls_1.paused)();
|
|
@@ -268,35 +283,142 @@ async function ingestPage(deps, mailbox) {
|
|
|
268
283
|
const state = await deps.store.getSyncState(mailbox);
|
|
269
284
|
if (!state)
|
|
270
285
|
throw new errors_1.MailboxNotConnectedError(mailbox);
|
|
271
|
-
const { page, restarted } = await fetchPage(deps, gateway, state);
|
|
286
|
+
const { page, restarted } = await fetchPage(deps, gateway, state, folder);
|
|
272
287
|
const link = page.nextLink ?? page.deltaLink;
|
|
273
288
|
if (!link)
|
|
274
289
|
throw new errors_1.InvalidDeltaPageError();
|
|
275
|
-
const result = emptyResult();
|
|
290
|
+
const result = emptyResult(folder);
|
|
276
291
|
result.restarted = restarted;
|
|
277
292
|
result.fetched = page.items.length;
|
|
278
293
|
const stamp = (i) => i.receivedDateTime ?? "";
|
|
279
294
|
const live = page.items.filter((i) => !i.removed).sort((a, b) => stamp(a).localeCompare(stamp(b)));
|
|
280
295
|
const gone = page.items.filter((i) => i.removed);
|
|
281
|
-
let newest = state.lastReceivedAt;
|
|
296
|
+
let newest = cursorOf(state, folder).lastReceivedAt;
|
|
282
297
|
for (const item of [...live, ...gone]) {
|
|
283
|
-
await
|
|
298
|
+
await process(deps, gateway, mailbox, item, state, result);
|
|
284
299
|
if (item.receivedDateTime && (!newest || item.receivedDateTime > newest)) {
|
|
285
300
|
newest = item.receivedDateTime;
|
|
286
301
|
}
|
|
287
302
|
}
|
|
288
|
-
await deps.store.saveSyncState(mailbox,
|
|
303
|
+
await deps.store.saveSyncState(mailbox, withCursor(state, folder, { link, lastReceivedAt: newest }));
|
|
289
304
|
result.hasMore = page.nextLink !== null;
|
|
290
305
|
return result;
|
|
291
306
|
}
|
|
307
|
+
async function ingestPage(deps, mailbox) {
|
|
308
|
+
return ingestFolderPage(deps, mailbox, "inbox", processItem);
|
|
309
|
+
}
|
|
310
|
+
async function ingestSentPage(deps, mailbox) {
|
|
311
|
+
return ingestFolderPage(deps, mailbox, "sentitems", processSentItem);
|
|
312
|
+
}
|
|
313
|
+
async function processSentItem(deps, gateway, mailbox, item, state, result) {
|
|
314
|
+
if (item.removed) {
|
|
315
|
+
const stored = await deps.store.getMessage(mailbox, item.id);
|
|
316
|
+
if (stored)
|
|
317
|
+
await deps.store.markMessageRemoved(mailbox, item.id, (0, internal_1.nowIso)(deps));
|
|
318
|
+
result.removed++;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (item.isDraft) {
|
|
322
|
+
result.skippedDrafts++;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (await deps.store.getMessage(mailbox, item.id)) {
|
|
326
|
+
result.unchanged++;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (item.receivedDateTime && Date.parse(item.receivedDateTime) < Date.parse(state.connectedAt)) {
|
|
330
|
+
result.skippedPredates++;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const msg = await gateway.getMessage(item.id, { includeHeaders: true, bodyType: "text" });
|
|
334
|
+
if (!msg) {
|
|
335
|
+
result.gone++;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (msg.isDraft === true) {
|
|
339
|
+
result.skippedDrafts++;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const conversationId = msg.conversationId ?? item.conversationId ?? null;
|
|
343
|
+
const recipients = [...msg.toRecipients, ...msg.ccRecipients]
|
|
344
|
+
.map((a) => a.address.trim().toLowerCase())
|
|
345
|
+
.filter(Boolean);
|
|
346
|
+
const message = {
|
|
347
|
+
id: item.id,
|
|
348
|
+
mailbox,
|
|
349
|
+
internetMessageId: (0, threading_1.normalizeMessageId)(msg.internetMessageId ?? item.internetMessageId),
|
|
350
|
+
conversationId,
|
|
351
|
+
subject: msg.subject ?? item.subject ?? "",
|
|
352
|
+
fromAddress: mailbox.trim().toLowerCase(),
|
|
353
|
+
receivedAt: msg.sentDateTime ?? msg.receivedDateTime ?? item.receivedDateTime ?? (0, internal_1.nowIso)(deps),
|
|
354
|
+
noise: { action: "process", reason: "none" },
|
|
355
|
+
suppressAutoResponse: false,
|
|
356
|
+
removedAt: null,
|
|
357
|
+
direction: "outbound",
|
|
358
|
+
};
|
|
359
|
+
const headers = threadingHeaders(msg);
|
|
360
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
361
|
+
const thread = await matchSent(deps, { mailbox, headers, conversationId, recipients });
|
|
362
|
+
if (!thread) {
|
|
363
|
+
result.unmatched++;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const outcome = await deps.store.appendMessage({ ...message, threadId: thread.id }, { threadId: thread.id, expectedState: thread.state, patch: {} });
|
|
367
|
+
if (outcome === "stale")
|
|
368
|
+
continue;
|
|
369
|
+
if (outcome === "duplicate")
|
|
370
|
+
result.duplicates++;
|
|
371
|
+
else
|
|
372
|
+
result.appended++;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
throw new errors_1.StaleThreadError(item.id);
|
|
376
|
+
}
|
|
377
|
+
async function matchSent(deps, input) {
|
|
378
|
+
const { mailbox, headers, conversationId, recipients } = input;
|
|
379
|
+
const refs = [...new Set(headerRefs(headers))];
|
|
380
|
+
const hits = refs.length ? await deps.store.findMessageThreads(mailbox, refs) : [];
|
|
381
|
+
const parent = (0, threading_1.normalizeMessageId)(headers.inReplyTo);
|
|
382
|
+
const direct = parent ? hits.find((h) => h.internetMessageId === parent) : undefined;
|
|
383
|
+
const headerIds = new Set(direct ? [direct.threadId] : hits.map((h) => h.threadId));
|
|
384
|
+
const candidates = new Map();
|
|
385
|
+
for (const id of headerIds) {
|
|
386
|
+
const t = await deps.store.getThread(id);
|
|
387
|
+
if (t && t.mailbox === mailbox)
|
|
388
|
+
candidates.set(t.id, t);
|
|
389
|
+
}
|
|
390
|
+
if (!direct && conversationId) {
|
|
391
|
+
for (const t of await deps.store.findThreadsByConversationId(mailbox, conversationId)) {
|
|
392
|
+
if (t.mailbox !== mailbox)
|
|
393
|
+
continue;
|
|
394
|
+
if (headerIds.has(t.id) || recipients.some((r) => t.participants.includes(r)))
|
|
395
|
+
candidates.set(t.id, t);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (candidates.size !== 1)
|
|
399
|
+
return null;
|
|
400
|
+
return [...candidates.values()][0] ?? null;
|
|
401
|
+
}
|
|
292
402
|
async function syncMailbox(deps, mailbox, options = {}) {
|
|
293
403
|
const results = [];
|
|
404
|
+
const maxPages = options.maxPages ?? 20;
|
|
294
405
|
try {
|
|
295
|
-
|
|
406
|
+
let inboxDone = false;
|
|
407
|
+
for (let i = 0; i < maxPages; i++) {
|
|
296
408
|
const r = await ingestPage(deps, mailbox);
|
|
297
409
|
if ((0, controls_1.isPaused)(r))
|
|
298
410
|
return results.length ? results : r;
|
|
299
411
|
results.push(r);
|
|
412
|
+
if (!r.hasMore) {
|
|
413
|
+
inboxDone = true;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (let i = 0; inboxDone && i < maxPages; i++) {
|
|
418
|
+
const r = await ingestSentPage(deps, mailbox);
|
|
419
|
+
if ((0, controls_1.isPaused)(r))
|
|
420
|
+
return results;
|
|
421
|
+
results.push(r);
|
|
300
422
|
if (!r.hasMore)
|
|
301
423
|
break;
|
|
302
424
|
}
|
package/dist/react.js
CHANGED
|
@@ -414,6 +414,7 @@ function ThreadView({ cls, api, fetchImpl, threadId, pollIntervalMs, renderCusto
|
|
|
414
414
|
const can = (action) => detail.actions.includes(action);
|
|
415
415
|
const bounced = thread.state === "review" && thread.reviewReason === "delivery_failed";
|
|
416
416
|
const sentReply = send?.status === "sent" && draft?.text && !draft.stale ? { text: draft.text, at: send.at } : null;
|
|
417
|
+
const syncedCopy = send?.messageId ? detail.messages.some((m) => m.id === send.messageId && !m.removed) : false;
|
|
417
418
|
const showComposer = !bounced && !sentReply && (draft !== null || thread.state === "review");
|
|
418
419
|
const menuItems = [];
|
|
419
420
|
if (!bounced && can("dismiss"))
|
|
@@ -426,7 +427,7 @@ function ThreadView({ cls, api, fetchImpl, threadId, pollIntervalMs, renderCusto
|
|
|
426
427
|
return ((0, jsx_runtime_1.jsxs)("article", { "aria-labelledby": `${id}-subject`, className: cls.detailPane, "data-thread-state": thread.state, "data-write-mode": pilot.writeMode, children: [(0, jsx_runtime_1.jsxs)("header", { className: cls.header, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: cls.backLink, onClick: onBack, children: "Terug naar tickets" }), (0, jsx_runtime_1.jsxs)("div", { className: cls.headerTop, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)("h2", { id: `${id}-subject`, ref: headingRef, tabIndex: -1, className: cls.heading, children: thread.subject || "(geen onderwerp)" }), (0, jsx_runtime_1.jsxs)("div", { className: cls.meta, children: [(0, jsx_runtime_1.jsx)(StatusBadge, { cls: cls, state: thread.state, reason: thread.reviewReason, withNote: false }), pilot.writeMode === "read_only" && (0, jsx_runtime_1.jsx)("span", { className: cls.badge, children: "Alleen lezen" }), (0, jsx_runtime_1.jsx)("span", { children: thread.customerAddress }), (0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", children: "\u00B7" }), (0, jsx_runtime_1.jsxs)("span", { children: ["naar ", thread.mailbox] })] }), (thread.relatedThreadId || (thread.state === "ignored" && thread.dismissedBy)) && ((0, jsx_runtime_1.jsxs)("div", { className: cls.meta, children: [thread.relatedThreadId && (0, jsx_runtime_1.jsx)("span", { children: "Vervolg op een eerder ticket" }), thread.state === "ignored" && thread.dismissedBy && ((0, jsx_runtime_1.jsxs)("span", { children: ["Geen antwoord nodig, gemarkeerd door ", thread.dismissedBy === detail.viewer.id ? "jou" : "een collega"] }))] }))] }), (0, jsx_runtime_1.jsxs)("div", { className: cls.headerActions, children: [(0, jsx_runtime_1.jsx)(Assignment, { cls: cls, detail: detail, busy: busy, run: run }), menuItems.length > 0 && (0, jsx_runtime_1.jsx)(MoreActions, { cls: cls, busy: busy, items: menuItems })] })] }), confirming && ((0, jsx_runtime_1.jsx)(ConfirmAction, { cls: cls, kind: confirming, busy: busy, onCancel: () => setConfirming(null), onConfirm: () => {
|
|
427
428
|
setConfirming(null);
|
|
428
429
|
run(confirming === "dismiss" ? "dismiss" : "replied-externally");
|
|
429
|
-
} }))] }), (0, jsx_runtime_1.jsx)("div", { "aria-live": "polite", role: "status", children: notice && notice.kind !== "error" && (0, jsx_runtime_1.jsx)("p", { className: notice.kind === "success" ? cls.success : cls.warning, children: notice.text }) }), notice?.kind === "error" && ((0, jsx_runtime_1.jsx)("p", { role: "alert", className: cls.error, children: notice.text })), renderCustomerContext && (0, jsx_runtime_1.jsx)("section", { className: cls.context, children: renderCustomerContext(thread) }), (0, jsx_runtime_1.jsx)(Conversation, { cls: cls, detail: detail, bodies: bodies.current, sentReply: sentReply }), bounced && (can("replied_externally") || can("dismiss")) && (0, jsx_runtime_1.jsx)(BouncedNextSteps, { cls: cls, busy: busy, can: can, run: run }), showComposer && ((0, jsx_runtime_1.jsx)(Composer, { cls: cls, detail: detail, busy: busy, text: text, dirty: dirty, onText: (value) => {
|
|
430
|
+
} }))] }), (0, jsx_runtime_1.jsx)("div", { "aria-live": "polite", role: "status", children: notice && notice.kind !== "error" && (0, jsx_runtime_1.jsx)("p", { className: notice.kind === "success" ? cls.success : cls.warning, children: notice.text }) }), notice?.kind === "error" && ((0, jsx_runtime_1.jsx)("p", { role: "alert", className: cls.error, children: notice.text })), renderCustomerContext && (0, jsx_runtime_1.jsx)("section", { className: cls.context, children: renderCustomerContext(thread) }), (0, jsx_runtime_1.jsx)(Conversation, { cls: cls, detail: detail, bodies: bodies.current, sentReply: syncedCopy ? null : sentReply }), bounced && (can("replied_externally") || can("dismiss")) && (0, jsx_runtime_1.jsx)(BouncedNextSteps, { cls: cls, busy: busy, can: can, run: run }), showComposer && ((0, jsx_runtime_1.jsx)(Composer, { cls: cls, detail: detail, busy: busy, text: text, dirty: dirty, onText: (value) => {
|
|
430
431
|
setText(value);
|
|
431
432
|
setDirty(true);
|
|
432
433
|
}, act: act, run: run, can: can, uploadFile: uploadFile })), showComposer && thread.state === "review" && (can("dismiss") || can("replied_externally")) && ((0, jsx_runtime_1.jsxs)("p", { className: cls.muted, children: ["Geen antwoord nodig of al vanuit Outlook beantwoord? Kies het bij ", (0, jsx_runtime_1.jsx)("strong", { className: "font-medium text-foreground", children: "Meer acties" }), " ", "bovenaan."] })), (0, jsx_runtime_1.jsx)(OtherActions, { cls: cls, busy: busy, can: can, run: run })] }));
|
|
@@ -486,17 +487,26 @@ function FileChips({ cls, attachments }) {
|
|
|
486
487
|
}) }));
|
|
487
488
|
}
|
|
488
489
|
function Conversation({ cls, detail, bodies, sentReply, }) {
|
|
489
|
-
const
|
|
490
|
+
const copyId = sentReply ? detail.send?.messageId : null;
|
|
491
|
+
const entries = detail.messages.filter((m) => !(copyId && m.id === copyId)).map((m) => ({
|
|
492
|
+
kind: m.outbound ? "sent" : m.bounce ? "bounce" : "mail",
|
|
493
|
+
at: m.receivedAt,
|
|
494
|
+
message: m,
|
|
495
|
+
}));
|
|
490
496
|
if (sentReply)
|
|
491
497
|
entries.push({ kind: "reply", at: sentReply.at, text: sentReply.text });
|
|
492
498
|
entries.sort((a, b) => a.at.localeCompare(b.at));
|
|
493
499
|
const sentFiles = (detail.replyAttachments ?? []).filter((a) => a.sent);
|
|
494
|
-
return ((0, jsx_runtime_1.jsx)("ol", { "aria-label": "Gesprek", className: cls.conversation, children: entries.map((e) => e.kind === "reply" ? ((0, jsx_runtime_1.jsxs)("li", { className: cls.entryOurs, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatarOurs, children: (0, jsx_runtime_1.jsx)(Icon, { name: "send", className: cls.icon }) }), (0, jsx_runtime_1.jsxs)("article", { className: cls.messageOurs, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("strong", { className: "font-semibold", children: "Jouw antwoord" }), " ", (0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: ["\u00B7 vanaf ", detail.thread.mailbox] })] }), (0, jsx_runtime_1.jsx)("time", { dateTime: e.at, className: cls.muted, children: formatDate(e.at) })] }), (0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: e.text }), sentFiles.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { "aria-label": "Verstuurde bijlagen", className: cls.fileChips, children: sentFiles.map((a) => ((0, jsx_runtime_1.jsxs)("li", { className: cls.replyChip, children: [(0, jsx_runtime_1.jsx)("span", { className: "break-all", children: a.name }), " ", (0, jsx_runtime_1.jsx)("span", { className: cls.fileChipMeta, children: formatSize(a.size) })] }, a.id))) })), (0, jsx_runtime_1.jsxs)("span", { className: cls.sentNote, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "check", className: cls.icon }), "Verstuurd, staat in Verzonden items"] })] })] }, "reply")) : e.kind === "bounce" ? ((0, jsx_runtime_1.jsx)(BounceItem, { cls: cls, message: e.message, body: bodies.get(e.message.id) }, e.message.id)) : ((0, jsx_runtime_1.jsx)(MessageItem, { cls: cls, message: e.message, body: bodies.get(e.message.id) }, e.message.id))) }));
|
|
500
|
+
return ((0, jsx_runtime_1.jsx)("ol", { "aria-label": "Gesprek", className: cls.conversation, children: entries.map((e) => e.kind === "reply" ? ((0, jsx_runtime_1.jsxs)("li", { className: cls.entryOurs, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatarOurs, children: (0, jsx_runtime_1.jsx)(Icon, { name: "send", className: cls.icon }) }), (0, jsx_runtime_1.jsxs)("article", { className: cls.messageOurs, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("strong", { className: "font-semibold", children: "Jouw antwoord" }), " ", (0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: ["\u00B7 vanaf ", detail.thread.mailbox] })] }), (0, jsx_runtime_1.jsx)("time", { dateTime: e.at, className: cls.muted, children: formatDate(e.at) })] }), (0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: e.text }), sentFiles.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { "aria-label": "Verstuurde bijlagen", className: cls.fileChips, children: sentFiles.map((a) => ((0, jsx_runtime_1.jsxs)("li", { className: cls.replyChip, children: [(0, jsx_runtime_1.jsx)("span", { className: "break-all", children: a.name }), " ", (0, jsx_runtime_1.jsx)("span", { className: cls.fileChipMeta, children: formatSize(a.size) })] }, a.id))) })), (0, jsx_runtime_1.jsxs)("span", { className: cls.sentNote, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "check", className: cls.icon }), "Verstuurd, staat in Verzonden items"] })] })] }, "reply")) : e.kind === "bounce" ? ((0, jsx_runtime_1.jsx)(BounceItem, { cls: cls, message: e.message, body: bodies.get(e.message.id) }, e.message.id)) : e.kind === "sent" ? ((0, jsx_runtime_1.jsx)(SentItem, { cls: cls, message: e.message, mailbox: detail.thread.mailbox, body: bodies.get(e.message.id) }, e.message.id)) : ((0, jsx_runtime_1.jsx)(MessageItem, { cls: cls, message: e.message, body: bodies.get(e.message.id) }, e.message.id))) }));
|
|
495
501
|
}
|
|
496
502
|
function MessageItem({ cls, message, body }) {
|
|
497
503
|
return ((0, jsx_runtime_1.jsxs)("li", { className: cls.entry, children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatar, "aria-hidden": "true", children: initials(message.fromAddress) }), (0, jsx_runtime_1.jsxs)("article", { className: cls.message, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 break-words", children: [(0, jsx_runtime_1.jsx)("strong", { className: "font-semibold", children: message.fromAddress }), " ", (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: "\u00B7 klant" }), message.noise && (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: " \u00B7 automatisch bericht" }), message.removed && (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: " \u00B7 niet meer in de Inbox" })] }), (0, jsx_runtime_1.jsx)("time", { dateTime: message.receivedAt, className: cls.muted, children: formatDate(message.receivedAt) })] }), body === undefined && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: "Bericht laden\u2026" }), body && "error" in body && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: body.error }), body && "text" in body &&
|
|
498
504
|
(body.text ? ((0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: body.text })) : ((0, jsx_runtime_1.jsx)("p", { className: cls.emptyBody, children: body.attachments.length > 0 ? "Geen tekst, alleen een bijlage." : "Geen tekst." }))), body && "text" in body && (0, jsx_runtime_1.jsx)(FileChips, { cls: cls, attachments: body.attachments })] })] }));
|
|
499
505
|
}
|
|
506
|
+
function SentItem({ cls, message, mailbox, body }) {
|
|
507
|
+
return ((0, jsx_runtime_1.jsxs)("li", { className: cls.entryOurs, "data-outbound": "", children: [(0, jsx_runtime_1.jsx)("span", { className: cls.avatarOurs, children: (0, jsx_runtime_1.jsx)(Icon, { name: "send", className: cls.icon }) }), (0, jsx_runtime_1.jsxs)("article", { className: cls.messageOurs, children: [(0, jsx_runtime_1.jsxs)("div", { className: cls.messageHeader, children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 break-words", children: [(0, jsx_runtime_1.jsx)("strong", { className: "font-semibold", children: "Jouw antwoord" }), " ", (0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: ["\u00B7 vanaf ", message.fromAddress || mailbox] }), message.removed && (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: " \u00B7 niet meer in Verzonden items" })] }), (0, jsx_runtime_1.jsx)("time", { dateTime: message.receivedAt, className: cls.muted, children: formatDate(message.receivedAt) })] }), body === undefined && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: "Bericht laden\u2026" }), body && "error" in body && (0, jsx_runtime_1.jsx)("p", { className: cls.muted, children: body.error }), body && "text" in body &&
|
|
508
|
+
(body.text ? ((0, jsx_runtime_1.jsx)("div", { className: cls.messageBody, children: body.text })) : ((0, jsx_runtime_1.jsx)("p", { className: cls.emptyBody, children: body.attachments.length > 0 ? "Geen tekst, alleen een bijlage." : "Geen tekst." }))), body && "text" in body && (0, jsx_runtime_1.jsx)(FileChips, { cls: cls, attachments: body.attachments }), !message.removed && ((0, jsx_runtime_1.jsxs)("span", { className: cls.sentNote, children: [(0, jsx_runtime_1.jsx)(Icon, { name: "check", className: cls.icon }), "Verstuurd, staat in Verzonden items"] }))] })] }));
|
|
509
|
+
}
|
|
500
510
|
function BounceItem({ cls, message, body }) {
|
|
501
511
|
const report = body && "text" in body ? body.bounce : null;
|
|
502
512
|
const reason = report?.reason ?? "De ontvangende server weigerde het bericht.";
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export interface FakeMail {
|
|
|
26
26
|
isDraft?: boolean;
|
|
27
27
|
bodyText?: string;
|
|
28
28
|
attachments?: FakeAttachment[];
|
|
29
|
+
folder?: "inbox" | "sentitems";
|
|
30
|
+
to?: string[];
|
|
31
|
+
cc?: string[];
|
|
32
|
+
sentDateTime?: string;
|
|
29
33
|
}
|
|
30
34
|
export interface FakeAttachment {
|
|
31
35
|
id: string;
|
package/dist/testing/index.js
CHANGED
|
@@ -63,7 +63,7 @@ function createMemoryStore() {
|
|
|
63
63
|
async getLatestMessageForThread(threadId) {
|
|
64
64
|
await tick();
|
|
65
65
|
const last = [...messages.values()]
|
|
66
|
-
.filter((m) => m.threadId === threadId)
|
|
66
|
+
.filter((m) => m.threadId === threadId && m.direction === "inbound")
|
|
67
67
|
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt))
|
|
68
68
|
.at(-1);
|
|
69
69
|
return last ? clone(last) : null;
|
|
@@ -184,7 +184,8 @@ function createMemoryStore() {
|
|
|
184
184
|
const WRITE_METHODS = ["createReply", "updateDraftBody", "sendDraft"];
|
|
185
185
|
function createFakeMailbox(address) {
|
|
186
186
|
const mails = new Map();
|
|
187
|
-
const
|
|
187
|
+
const logs = { inbox: [], sentitems: [] };
|
|
188
|
+
const folderOf = (id) => (mails.get(id)?.folder ?? "inbox");
|
|
188
189
|
const drafts = new Map();
|
|
189
190
|
let expired = false;
|
|
190
191
|
let draftCounter = 0;
|
|
@@ -192,7 +193,7 @@ function createFakeMailbox(address) {
|
|
|
192
193
|
id: m.id,
|
|
193
194
|
removed: false,
|
|
194
195
|
...(m.subject !== undefined ? { subject: m.subject } : {}),
|
|
195
|
-
...(m.from ? { from: { address: m.from } } : {}),
|
|
196
|
+
...(m.from ? { from: { address: m.from } } : m.folder === "sentitems" ? { from: { address } } : {}),
|
|
196
197
|
...(m.receivedDateTime ? { receivedDateTime: m.receivedDateTime } : {}),
|
|
197
198
|
...(m.internetMessageId ? { internetMessageId: m.internetMessageId } : {}),
|
|
198
199
|
...(m.conversationId ? { conversationId: m.conversationId } : {}),
|
|
@@ -215,16 +216,17 @@ function createFakeMailbox(address) {
|
|
|
215
216
|
},
|
|
216
217
|
deliver(mail) {
|
|
217
218
|
mails.set(mail.id, mail);
|
|
218
|
-
|
|
219
|
+
logs[mail.folder ?? "inbox"].push(summary(mail));
|
|
219
220
|
},
|
|
220
221
|
touch(id) {
|
|
221
222
|
const m = mails.get(id);
|
|
222
223
|
if (m)
|
|
223
|
-
|
|
224
|
+
logs[m.folder ?? "inbox"].push({ ...summary(m), isRead: true, categories: ["seen"] });
|
|
224
225
|
},
|
|
225
226
|
remove(id, reason = "deleted") {
|
|
227
|
+
const folder = folderOf(id);
|
|
226
228
|
mails.delete(id);
|
|
227
|
-
|
|
229
|
+
logs[folder].push({ id, removed: true, removedReason: reason });
|
|
228
230
|
},
|
|
229
231
|
expireLinks() {
|
|
230
232
|
expired = true;
|
|
@@ -237,11 +239,15 @@ function createFakeMailbox(address) {
|
|
|
237
239
|
throw box.outage;
|
|
238
240
|
const size = options.pageSize ?? 50;
|
|
239
241
|
let start = 0;
|
|
242
|
+
let folder = options.folderId === "sentitems" ? "sentitems" : "inbox";
|
|
240
243
|
if (options.link) {
|
|
241
244
|
if (expired)
|
|
242
245
|
throw new errors_1.DeltaLinkExpiredError();
|
|
243
|
-
|
|
246
|
+
folder = options.link.startsWith("s:") ? "sentitems" : "inbox";
|
|
247
|
+
start = Number(options.link.split(":").at(-1));
|
|
244
248
|
}
|
|
249
|
+
const log = logs[folder];
|
|
250
|
+
const prefix = folder === "sentitems" ? "s:" : "";
|
|
245
251
|
const since = options.since ? Date.parse(String(options.since)) : null;
|
|
246
252
|
const visible = (i) => Boolean(options.link) ||
|
|
247
253
|
since === null ||
|
|
@@ -253,8 +259,8 @@ function createFakeMailbox(address) {
|
|
|
253
259
|
const more = end < log.length;
|
|
254
260
|
return {
|
|
255
261
|
items: slice.filter(visible),
|
|
256
|
-
nextLink: more ?
|
|
257
|
-
deltaLink: more ? null :
|
|
262
|
+
nextLink: more ? `${prefix}p:${end}` : null,
|
|
263
|
+
deltaLink: more ? null : `${prefix}d:${end}`,
|
|
258
264
|
};
|
|
259
265
|
},
|
|
260
266
|
async getMessage(id, options) {
|
|
@@ -272,8 +278,9 @@ function createFakeMailbox(address) {
|
|
|
272
278
|
return {
|
|
273
279
|
...summary(m),
|
|
274
280
|
body: { contentType: "text", content: m.bodyText ?? "hello" },
|
|
275
|
-
|
|
276
|
-
|
|
281
|
+
...(m.sentDateTime ? { sentDateTime: m.sentDateTime } : {}),
|
|
282
|
+
toRecipients: (m.to ?? (m.folder === "sentitems" ? [] : [address])).map((a) => ({ address: a })),
|
|
283
|
+
ccRecipients: (m.cc ?? []).map((a) => ({ address: a })),
|
|
277
284
|
replyTo: (m.replyTo ?? []).map((address) => ({ address })),
|
|
278
285
|
...(options?.includeHeaders
|
|
279
286
|
? {
|
package/dist/types.d.ts
CHANGED
|
@@ -161,6 +161,7 @@ export interface ThreadUpdate {
|
|
|
161
161
|
patch: ThreadPatch;
|
|
162
162
|
}
|
|
163
163
|
export type NoiseReason = "auto_submitted" | "precedence" | "bounce" | "out_of_office" | "mailing_list" | "machine_sender" | "own_address" | "connected_mailbox" | "none";
|
|
164
|
+
export type MessageDirection = "inbound" | "outbound";
|
|
164
165
|
export interface StoredMessage {
|
|
165
166
|
id: string;
|
|
166
167
|
mailbox: string;
|
|
@@ -176,6 +177,7 @@ export interface StoredMessage {
|
|
|
176
177
|
};
|
|
177
178
|
suppressAutoResponse: boolean;
|
|
178
179
|
removedAt: string | null;
|
|
180
|
+
direction: MessageDirection;
|
|
179
181
|
}
|
|
180
182
|
export interface SyncState {
|
|
181
183
|
link: string | null;
|
|
@@ -184,6 +186,8 @@ export interface SyncState {
|
|
|
184
186
|
failingSince?: string | null;
|
|
185
187
|
failures?: number;
|
|
186
188
|
lastError?: string | null;
|
|
189
|
+
sentLink?: string | null;
|
|
190
|
+
sentLastReceivedAt?: string | null;
|
|
187
191
|
}
|
|
188
192
|
export interface SyncFailing {
|
|
189
193
|
status: "failing";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxv-systems/helpdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Mailbox helpdesk domain logic for DXV apps — idempotent delta ingestion, conservative threading, ticket state machine, noise detection, and replies that a person approves in the helpdesk before they are sent from the mailbox (quoted history kept, retry-safe, never sent twice). Pure logic behind interfaces, plus the staff inbox as a route handler (./server) and a React component (./react). Zero runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dxv",
|