@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 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` loops until the round is complete.
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 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.
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 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.
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
 
@@ -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 ? { status: intent.status, at: intent.updatedAt } : null,
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 };
@@ -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 restartFrom(state, overlapMs) {
38
- if (!state.lastReceivedAt)
39
- return state.connectedAt;
40
- const back = Date.parse(state.lastReceivedAt) - overlapMs;
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
- async function fetchPage(deps, gateway, state) {
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 first = (s) => gateway.delta(s.link
46
- ? { link: s.link, ...(pageSize ? { pageSize } : {}) }
56
+ const cursor = cursorOf(state, folder);
57
+ const first = (link) => gateway.delta(link
58
+ ? { link, ...(pageSize ? { pageSize } : {}) }
47
59
  : {
48
- folderId: "inbox",
49
- since: restartFrom(s, deps.config.restartOverlapMs ?? DEFAULT_RESTART_OVERLAP_MS),
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(state), restarted: false };
65
+ return { page: await first(cursor.link), restarted: false };
54
66
  }
55
67
  catch (err) {
56
- if (!state.link || !(0, errors_1.isDeltaLinkExpired)(err))
68
+ if (!cursor.link || !(0, errors_1.isDeltaLinkExpired)(err))
57
69
  throw err;
58
- return { page: await first({ ...state, link: null }), restarted: true };
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 ingestPage(deps, mailbox) {
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 processItem(deps, gateway, mailbox, item, state, result);
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, { ...state, link, lastReceivedAt: newest });
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
- for (let i = 0; i < (options.maxPages ?? 20); i++) {
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 entries = detail.messages.map((m) => ({ kind: m.bounce ? "bounce" : "mail", at: m.receivedAt, message: m }));
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.";
@@ -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;
@@ -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 log = [];
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
- log.push(summary(mail));
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
- log.push({ ...summary(m), isRead: true, categories: ["seen"] });
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
- log.push({ id, removed: true, removedReason: reason });
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
- start = Number(options.link.split(":")[1]);
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 ? `p:${end}` : null,
257
- deltaLink: more ? null : `d:${end}`,
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
- toRecipients: [{ address }],
276
- ccRecipients: [],
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.2.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",