@dxv-systems/helpdesk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +272 -2
  2. package/dist/bounce.d.ts +13 -0
  3. package/dist/bounce.js +79 -0
  4. package/dist/controls.d.ts +13 -0
  5. package/dist/controls.js +82 -0
  6. package/dist/draft-body.d.ts +4 -0
  7. package/dist/draft-body.js +38 -0
  8. package/dist/drafting.d.ts +18 -0
  9. package/dist/drafting.js +243 -0
  10. package/dist/errors.d.ts +60 -0
  11. package/dist/errors.js +133 -0
  12. package/dist/inbox/handlers.d.ts +36 -0
  13. package/dist/inbox/handlers.js +523 -0
  14. package/dist/inbox/messages.d.ts +34 -0
  15. package/dist/inbox/messages.js +66 -0
  16. package/dist/inbox/plain-text.d.ts +1 -0
  17. package/dist/inbox/plain-text.js +58 -0
  18. package/dist/inbox/types.d.ts +116 -0
  19. package/dist/inbox/types.js +2 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.js +66 -0
  22. package/dist/ingest.d.ts +25 -0
  23. package/dist/ingest.js +335 -0
  24. package/dist/internal.d.ts +8 -0
  25. package/dist/internal.js +44 -0
  26. package/dist/noise.d.ts +17 -0
  27. package/dist/noise.js +77 -0
  28. package/dist/react.d.ts +97 -0
  29. package/dist/react.js +576 -0
  30. package/dist/reply-attachments.d.ts +28 -0
  31. package/dist/reply-attachments.js +119 -0
  32. package/dist/sending.d.ts +34 -0
  33. package/dist/sending.js +268 -0
  34. package/dist/server.d.ts +6 -0
  35. package/dist/server.js +10 -0
  36. package/dist/states.d.ts +13 -0
  37. package/dist/states.js +62 -0
  38. package/dist/subject.d.ts +2 -0
  39. package/dist/subject.js +30 -0
  40. package/dist/testing/index.d.ts +97 -0
  41. package/dist/testing/index.js +472 -0
  42. package/dist/threading.d.ts +18 -0
  43. package/dist/threading.js +66 -0
  44. package/dist/types.d.ts +339 -0
  45. package/dist/types.js +2 -0
  46. package/package.json +71 -4
@@ -0,0 +1,472 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createManualClock = createManualClock;
4
+ exports.createMemoryStore = createMemoryStore;
5
+ exports.createFakeMailbox = createFakeMailbox;
6
+ exports.createFakeDrafter = createFakeDrafter;
7
+ exports.createMemoryReplyAttachments = createMemoryReplyAttachments;
8
+ exports.createTestWorld = createTestWorld;
9
+ const errors_1 = require("../errors");
10
+ const clone = (v) => structuredClone(v);
11
+ const tick = () => Promise.resolve();
12
+ function createManualClock(startIso = "2026-11-02T09:00:00.000Z") {
13
+ let t = Date.parse(startIso);
14
+ return {
15
+ now: () => new Date(t),
16
+ advance: (ms) => {
17
+ t += ms;
18
+ },
19
+ set: (iso) => {
20
+ t = Date.parse(iso);
21
+ },
22
+ };
23
+ }
24
+ const mkey = (mailbox, id) => `${mailbox}\u0000${id}`;
25
+ function createMemoryStore() {
26
+ const threads = new Map();
27
+ const messages = new Map();
28
+ const draftRuns = [];
29
+ const sendIntents = new Map();
30
+ const syncStates = new Map();
31
+ const newestFirst = (a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt);
32
+ return {
33
+ threads,
34
+ messages,
35
+ draftRuns,
36
+ sendIntents,
37
+ syncStates,
38
+ async getSyncState(mailbox) {
39
+ await tick();
40
+ const s = syncStates.get(mailbox);
41
+ return s ? clone(s) : null;
42
+ },
43
+ async saveSyncState(mailbox, state) {
44
+ await tick();
45
+ syncStates.set(mailbox, clone(state));
46
+ },
47
+ async getMessage(mailbox, id) {
48
+ await tick();
49
+ const m = messages.get(mkey(mailbox, id));
50
+ return m ? clone(m) : null;
51
+ },
52
+ async markMessageRemoved(mailbox, id, at) {
53
+ await tick();
54
+ const m = messages.get(mkey(mailbox, id));
55
+ if (m)
56
+ m.removedAt = at;
57
+ },
58
+ async getThread(id) {
59
+ await tick();
60
+ const t = threads.get(id);
61
+ return t ? clone(t) : null;
62
+ },
63
+ async getLatestMessageForThread(threadId) {
64
+ await tick();
65
+ const last = [...messages.values()]
66
+ .filter((m) => m.threadId === threadId)
67
+ .sort((a, b) => a.receivedAt.localeCompare(b.receivedAt))
68
+ .at(-1);
69
+ return last ? clone(last) : null;
70
+ },
71
+ async listInFlightDraftRuns(mailbox, olderThan) {
72
+ await tick();
73
+ return draftRuns
74
+ .filter((r) => r.mailbox === mailbox &&
75
+ (r.status === "requested" || r.status === "started") &&
76
+ r.requestedAt < olderThan)
77
+ .map(clone);
78
+ },
79
+ async findThreadsByConversationId(mailbox, conversationId) {
80
+ await tick();
81
+ const ids = new Set([...messages.values()]
82
+ .filter((m) => m.mailbox === mailbox && m.conversationId === conversationId)
83
+ .map((m) => m.threadId));
84
+ return [...ids].map((id) => clone(threads.get(id))).sort(newestFirst);
85
+ },
86
+ async findMessageThreads(mailbox, internetMessageIds) {
87
+ await tick();
88
+ const wanted = new Set(internetMessageIds);
89
+ return [...messages.values()]
90
+ .filter((m) => m.mailbox === mailbox && m.internetMessageId && wanted.has(m.internetMessageId))
91
+ .map((m) => ({ threadId: m.threadId, internetMessageId: m.internetMessageId }));
92
+ },
93
+ async findThreadsBySubjectKey(mailbox, subjectKey, activeSince) {
94
+ await tick();
95
+ return [...threads.values()]
96
+ .filter((t) => t.mailbox === mailbox && t.subjectKey === subjectKey && t.lastActivityAt >= activeSince)
97
+ .map(clone)
98
+ .sort(newestFirst);
99
+ },
100
+ async createThread(thread, firstMessage) {
101
+ await tick();
102
+ if (messages.has(mkey(firstMessage.mailbox, firstMessage.id)))
103
+ return "duplicate";
104
+ threads.set(thread.id, clone(thread));
105
+ messages.set(mkey(firstMessage.mailbox, firstMessage.id), clone(firstMessage));
106
+ return "created";
107
+ },
108
+ async appendMessage(message, update) {
109
+ await tick();
110
+ if (messages.has(mkey(message.mailbox, message.id)))
111
+ return "duplicate";
112
+ const t = threads.get(update.threadId);
113
+ if (!t || t.state !== update.expectedState)
114
+ return "stale";
115
+ Object.assign(t, update.patch);
116
+ messages.set(mkey(message.mailbox, message.id), clone(message));
117
+ return "appended";
118
+ },
119
+ async updateThread(update) {
120
+ await tick();
121
+ const t = threads.get(update.threadId);
122
+ if (!t || t.state !== update.expectedState)
123
+ return false;
124
+ Object.assign(t, update.patch);
125
+ return true;
126
+ },
127
+ async createDraftRunIfAbsent(run) {
128
+ await tick();
129
+ const found = draftRuns.find((r) => r.idempotencyKey === run.idempotencyKey);
130
+ if (found)
131
+ return { created: false, run: clone(found) };
132
+ draftRuns.push(clone(run));
133
+ return { created: true, run: clone(run) };
134
+ },
135
+ async getLatestDraftRun(mailbox, inboundMessageId) {
136
+ await tick();
137
+ const runs = draftRuns.filter((r) => r.mailbox === mailbox && r.inboundMessageId === inboundMessageId);
138
+ const last = runs.sort((a, b) => a.generation - b.generation).at(-1);
139
+ return last ? clone(last) : null;
140
+ },
141
+ async getLatestDraftRunForThread(threadId) {
142
+ await tick();
143
+ const last = draftRuns.filter((r) => r.threadId === threadId).at(-1);
144
+ return last ? clone(last) : null;
145
+ },
146
+ async updateDraftRun(key, patch) {
147
+ await tick();
148
+ const run = draftRuns.find((r) => r.idempotencyKey === key);
149
+ if (!run)
150
+ throw new Error(`no draft run ${key}`);
151
+ Object.assign(run, patch);
152
+ return clone(run);
153
+ },
154
+ async listSendIntentsByStatus(mailbox, status) {
155
+ await tick();
156
+ return [...sendIntents.values()]
157
+ .filter((i) => i.mailbox === mailbox && i.status === status)
158
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
159
+ .map(clone);
160
+ },
161
+ async createSendIntentIfAbsent(intent) {
162
+ await tick();
163
+ const found = sendIntents.get(intent.id);
164
+ if (found)
165
+ return { created: false, intent: clone(found) };
166
+ sendIntents.set(intent.id, clone(intent));
167
+ return { created: true, intent: clone(intent) };
168
+ },
169
+ async getSendIntent(id) {
170
+ await tick();
171
+ const i = sendIntents.get(id);
172
+ return i ? clone(i) : null;
173
+ },
174
+ async transitionSendIntent(id, from, patch) {
175
+ await tick();
176
+ const i = sendIntents.get(id);
177
+ if (!i || !from.includes(i.status))
178
+ return null;
179
+ Object.assign(i, patch);
180
+ return clone(i);
181
+ },
182
+ };
183
+ }
184
+ const WRITE_METHODS = ["createReply", "updateDraftBody", "sendDraft"];
185
+ function createFakeMailbox(address) {
186
+ const mails = new Map();
187
+ const log = [];
188
+ const drafts = new Map();
189
+ let expired = false;
190
+ let draftCounter = 0;
191
+ const summary = (m) => ({
192
+ id: m.id,
193
+ removed: false,
194
+ ...(m.subject !== undefined ? { subject: m.subject } : {}),
195
+ ...(m.from ? { from: { address: m.from } } : {}),
196
+ ...(m.receivedDateTime ? { receivedDateTime: m.receivedDateTime } : {}),
197
+ ...(m.internetMessageId ? { internetMessageId: m.internetMessageId } : {}),
198
+ ...(m.conversationId ? { conversationId: m.conversationId } : {}),
199
+ ...(m.isDraft ? { isDraft: true } : {}),
200
+ ...(m.attachments ? { hasAttachments: m.attachments.some((a) => !a.isInline) } : {}),
201
+ });
202
+ const box = {
203
+ address,
204
+ sendBehaviour: { kind: "ok" },
205
+ sent: [],
206
+ drafts,
207
+ getMessageCalls: 0,
208
+ draftAttachments: new Map(),
209
+ addAttachmentError: null,
210
+ outage: null,
211
+ deltaCalls: [],
212
+ calls: [],
213
+ get writeCalls() {
214
+ return box.calls.filter((c) => WRITE_METHODS.includes(c.split(":")[0] ?? ""));
215
+ },
216
+ deliver(mail) {
217
+ mails.set(mail.id, mail);
218
+ log.push(summary(mail));
219
+ },
220
+ touch(id) {
221
+ const m = mails.get(id);
222
+ if (m)
223
+ log.push({ ...summary(m), isRead: true, categories: ["seen"] });
224
+ },
225
+ remove(id, reason = "deleted") {
226
+ mails.delete(id);
227
+ log.push({ id, removed: true, removedReason: reason });
228
+ },
229
+ expireLinks() {
230
+ expired = true;
231
+ },
232
+ async delta(options) {
233
+ await tick();
234
+ box.calls.push("delta");
235
+ box.deltaCalls.push(options);
236
+ if (box.outage)
237
+ throw box.outage;
238
+ const size = options.pageSize ?? 50;
239
+ let start = 0;
240
+ if (options.link) {
241
+ if (expired)
242
+ throw new errors_1.DeltaLinkExpiredError();
243
+ start = Number(options.link.split(":")[1]);
244
+ }
245
+ const since = options.since ? Date.parse(String(options.since)) : null;
246
+ const visible = (i) => Boolean(options.link) ||
247
+ since === null ||
248
+ i.removed ||
249
+ !i.receivedDateTime ||
250
+ Date.parse(i.receivedDateTime) >= since;
251
+ const slice = log.slice(start, start + size);
252
+ const end = start + slice.length;
253
+ const more = end < log.length;
254
+ return {
255
+ items: slice.filter(visible),
256
+ nextLink: more ? `p:${end}` : null,
257
+ deltaLink: more ? null : `d:${end}`,
258
+ };
259
+ },
260
+ async getMessage(id, options) {
261
+ await tick();
262
+ box.calls.push(`getMessage:${id}`);
263
+ box.getMessageCalls++;
264
+ if (box.outage)
265
+ throw box.outage;
266
+ const m = mails.get(id);
267
+ if (!m) {
268
+ const draft = box.sent.includes(id) ? undefined : drafts.get(id);
269
+ return draft ? { ...clone(draft), isDraft: true } : null;
270
+ }
271
+ const headers = Object.entries(m.headers ?? {}).map(([name, value]) => ({ name, value }));
272
+ return {
273
+ ...summary(m),
274
+ body: { contentType: "text", content: m.bodyText ?? "hello" },
275
+ toRecipients: [{ address }],
276
+ ccRecipients: [],
277
+ replyTo: (m.replyTo ?? []).map((address) => ({ address })),
278
+ ...(options?.includeHeaders
279
+ ? {
280
+ internetMessageHeaders: headers,
281
+ ...(m.inReplyTo ? { inReplyTo: m.inReplyTo } : {}),
282
+ ...(m.references ? { references: m.references } : {}),
283
+ }
284
+ : {}),
285
+ };
286
+ },
287
+ async listAttachments(messageId) {
288
+ await tick();
289
+ box.calls.push(`listAttachments:${messageId}`);
290
+ return (mails.get(messageId)?.attachments ?? []).map((a) => ({
291
+ id: a.id,
292
+ name: a.name,
293
+ contentType: a.contentType ?? "application/octet-stream",
294
+ size: a.size ?? a.content?.byteLength ?? 0,
295
+ isInline: a.isInline === true,
296
+ kind: a.kind ?? "file",
297
+ }));
298
+ },
299
+ async downloadAttachment(messageId, attachmentId) {
300
+ await tick();
301
+ box.calls.push(`downloadAttachment:${messageId}:${attachmentId}`);
302
+ const found = mails.get(messageId)?.attachments?.find((a) => a.id === attachmentId);
303
+ if (!found?.content)
304
+ throw Object.assign(new Error("attachment not found"), { status: 404 });
305
+ return found.content;
306
+ },
307
+ async createReply(messageId, options) {
308
+ await tick();
309
+ box.calls.push(`createReply:${messageId}`);
310
+ const original = mails.get(messageId);
311
+ const id = `draft-${++draftCounter}`;
312
+ const comment = (options?.comment ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;");
313
+ const content = `<html><head></head><body><div>${comment}</div><div id="appendonsend"></div>` +
314
+ `<hr><div id="divRplyFwdMsg"><b>From:</b> ${original?.from ?? "?"}</div>` +
315
+ `<blockquote>${original?.bodyText ?? "quoted original"}</blockquote></body></html>`;
316
+ const draft = {
317
+ id,
318
+ isDraft: true,
319
+ body: { contentType: "html", content },
320
+ toRecipients: [],
321
+ ccRecipients: [],
322
+ replyTo: [],
323
+ };
324
+ drafts.set(id, draft);
325
+ return clone(draft);
326
+ },
327
+ async updateDraftBody(draftId, body) {
328
+ await tick();
329
+ box.calls.push(`updateDraftBody:${draftId}`);
330
+ const d = drafts.get(draftId);
331
+ if (!d)
332
+ throw new Error(`no draft ${draftId}`);
333
+ d.body = clone(body);
334
+ },
335
+ async addAttachment(draftId, attachment) {
336
+ await tick();
337
+ box.calls.push(`addAttachment:${draftId}:${attachment.name}`);
338
+ if (box.addAttachmentError)
339
+ throw box.addAttachmentError;
340
+ const list = box.draftAttachments.get(draftId) ?? [];
341
+ list.push({ name: attachment.name, contentType: attachment.contentType, size: attachment.content.byteLength });
342
+ box.draftAttachments.set(draftId, list);
343
+ return {};
344
+ },
345
+ async sendDraft(draftId) {
346
+ await tick();
347
+ box.calls.push(`sendDraft:${draftId}`);
348
+ const b = box.sendBehaviour;
349
+ if (b.kind === "reject")
350
+ throw b.error;
351
+ box.sent.push(draftId);
352
+ if (b.kind === "lost_response")
353
+ throw b.error;
354
+ },
355
+ };
356
+ return box;
357
+ }
358
+ function createFakeDrafter() {
359
+ const runs = new Map();
360
+ const drafter = {
361
+ requests: [],
362
+ runs,
363
+ failStart: null,
364
+ async start(request) {
365
+ await tick();
366
+ drafter.requests.push(request);
367
+ if (drafter.failStart)
368
+ throw drafter.failStart;
369
+ const existing = runs.get(request.idempotencyKey);
370
+ if (existing)
371
+ return { runId: existing };
372
+ const runId = `run-${runs.size + 1}`;
373
+ runs.set(request.idempotencyKey, runId);
374
+ return { runId };
375
+ },
376
+ };
377
+ return drafter;
378
+ }
379
+ function createMemoryReplyAttachments() {
380
+ const rows = new Map();
381
+ const store = {
382
+ rows,
383
+ async add(a) {
384
+ rows.set(a.id, { ...a });
385
+ },
386
+ async listForThread(threadId) {
387
+ return [...rows.values()].filter((a) => a.threadId === threadId).map((a) => ({ ...a }));
388
+ },
389
+ async update(id, patch) {
390
+ const row = rows.get(id);
391
+ if (row)
392
+ rows.set(id, { ...row, ...patch });
393
+ },
394
+ async removeUnsent(id) {
395
+ const row = rows.get(id);
396
+ if (!row || row.sentIntentId !== null)
397
+ return null;
398
+ rows.delete(id);
399
+ return row;
400
+ },
401
+ };
402
+ const pending = new Map();
403
+ const stored = new Map();
404
+ const meta = new Map();
405
+ const removed = [];
406
+ let n = 0;
407
+ const files = {
408
+ stored,
409
+ removed,
410
+ upload(uploadToken, content) {
411
+ const p = pending.get(uploadToken);
412
+ if (!p)
413
+ throw new Error(`unknown upload ${uploadToken}`);
414
+ p.content = content;
415
+ },
416
+ async createUpload(input) {
417
+ const uploadToken = `upload-${++n}`;
418
+ pending.set(uploadToken, { ...input });
419
+ return { uploadUrl: `https://files.test/${uploadToken}`, uploadToken, contentType: input.contentType };
420
+ },
421
+ async completeUpload({ staffId, uploadToken }) {
422
+ const p = pending.get(uploadToken);
423
+ if (!p || p.staffId !== staffId)
424
+ throw new Error("invalid upload token");
425
+ if (!p.content || p.content.byteLength !== p.size)
426
+ throw new Error("the file was not uploaded");
427
+ pending.delete(uploadToken);
428
+ const fileId = `file-${++n}`;
429
+ stored.set(fileId, p.content);
430
+ meta.set(fileId, { name: p.name, contentType: p.contentType });
431
+ return { fileId, name: p.name, contentType: p.contentType, size: p.size };
432
+ },
433
+ async read({ fileId }) {
434
+ const content = stored.get(fileId);
435
+ if (!content)
436
+ throw new Error(`file ${fileId} not found`);
437
+ return content;
438
+ },
439
+ async remove({ fileId }) {
440
+ removed.push(fileId);
441
+ stored.delete(fileId);
442
+ },
443
+ };
444
+ return { store, files };
445
+ }
446
+ function createTestWorld(options = {}) {
447
+ const addresses = options.mailboxes ?? ["support@client.test"];
448
+ const mailboxes = Object.fromEntries(addresses.map((a) => [a, createFakeMailbox(a)]));
449
+ const store = createMemoryStore();
450
+ const clock = createManualClock();
451
+ const drafter = createFakeDrafter();
452
+ let n = 0;
453
+ const deps = {
454
+ store,
455
+ clock,
456
+ drafter,
457
+ gatewayFor: (mailbox) => {
458
+ const box = mailboxes[mailbox];
459
+ if (!box)
460
+ throw new Error(`unknown mailbox ${mailbox}`);
461
+ return box;
462
+ },
463
+ config: {
464
+ ownAddresses: ["noreply@app.test"],
465
+ connectedMailboxes: addresses,
466
+ ...options.config,
467
+ },
468
+ newId: () => `id-${++n}`,
469
+ ...(options.controls ? { controls: options.controls } : {}),
470
+ };
471
+ return { deps, store, clock, drafter, mailboxes };
472
+ }
@@ -0,0 +1,18 @@
1
+ import type { HelpdeskStore, Thread } from "./types";
2
+ export interface ThreadCandidate {
3
+ mailbox: string;
4
+ conversationId?: string | null;
5
+ inReplyTo?: string | null;
6
+ references?: string[] | null;
7
+ subject?: string;
8
+ from: string;
9
+ receivedAt: string;
10
+ }
11
+ export interface ThreadMatch {
12
+ thread: Thread | null;
13
+ via: "conversation" | "headers" | "subject" | "none";
14
+ }
15
+ export declare function normalizeMessageId(id: string | null | undefined): string | null;
16
+ export declare function matchThread(store: HelpdeskStore, candidate: ThreadCandidate, options?: {
17
+ subjectWindowMs?: number;
18
+ }): Promise<ThreadMatch>;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeMessageId = normalizeMessageId;
4
+ exports.matchThread = matchThread;
5
+ const subject_1 = require("./subject");
6
+ const DEFAULT_SUBJECT_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
7
+ function normalizeMessageId(id) {
8
+ const v = (id ?? "").trim().replace(/^<+|>+$/g, "").trim().toLowerCase();
9
+ return v || null;
10
+ }
11
+ const isFinal = (t) => t.state === "closed" || t.state === "ignored";
12
+ const byActivityDesc = (a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt);
13
+ function onlyOpenOrOne(threads) {
14
+ if (threads.length === 1)
15
+ return threads[0] ?? null;
16
+ const open = threads.filter((t) => !isFinal(t));
17
+ return open.length === 1 ? (open[0] ?? null) : null;
18
+ }
19
+ async function matchThread(store, candidate, options = {}) {
20
+ const none = { thread: null, via: "none" };
21
+ const { mailbox } = candidate;
22
+ const from = candidate.from.trim().toLowerCase();
23
+ const sameMailbox = (t) => t.mailbox === mailbox;
24
+ const knowsSender = (t) => t.participants.includes(from);
25
+ const forward = (0, subject_1.isForwardSubject)(candidate.subject);
26
+ const parent = normalizeMessageId(candidate.inReplyTo);
27
+ const refs = [...new Set([parent, ...(candidate.references ?? []).map(normalizeMessageId)])].filter((x) => x !== null);
28
+ const hits = refs.length ? await store.findMessageThreads(mailbox, refs) : [];
29
+ const headerThreadIds = new Set(hits.map((h) => h.threadId));
30
+ if (candidate.conversationId) {
31
+ const found = (await store.findThreadsByConversationId(mailbox, candidate.conversationId))
32
+ .filter(sameMailbox)
33
+ .sort(byActivityDesc);
34
+ const conversationThread = found.find((t) => knowsSender(t) || headerThreadIds.has(t.id));
35
+ if (conversationThread) {
36
+ if (headerThreadIds.size > 0 && !headerThreadIds.has(conversationThread.id))
37
+ return none;
38
+ return { thread: conversationThread, via: "conversation" };
39
+ }
40
+ }
41
+ if (headerThreadIds.size > 0) {
42
+ const direct = parent ? hits.find((h) => h.internetMessageId === parent) : undefined;
43
+ const ids = direct ? [direct.threadId] : [...headerThreadIds];
44
+ const threads = [];
45
+ for (const id of ids) {
46
+ const t = await store.getThread(id);
47
+ if (t && sameMailbox(t))
48
+ threads.push(t);
49
+ }
50
+ const chosen = onlyOpenOrOne(threads);
51
+ if (chosen && !(forward && !knowsSender(chosen)))
52
+ return { thread: chosen, via: "headers" };
53
+ }
54
+ const key = (0, subject_1.normalizeSubject)(candidate.subject);
55
+ if (!key)
56
+ return none;
57
+ const received = Date.parse(candidate.receivedAt);
58
+ if (Number.isNaN(received))
59
+ return none;
60
+ const since = new Date(received - (options.subjectWindowMs ?? DEFAULT_SUBJECT_WINDOW_MS));
61
+ const bySubject = (await store.findThreadsBySubjectKey(mailbox, key, since.toISOString()))
62
+ .filter((t) => sameMailbox(t) && knowsSender(t))
63
+ .sort(byActivityDesc);
64
+ const chosen = onlyOpenOrOne(bySubject);
65
+ return chosen ? { thread: chosen, via: "subject" } : none;
66
+ }