@remit/mailbox-service 0.0.111 → 0.0.113

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 (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -1,562 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IAddressRepository,
5
- IEnvelopeRepository,
6
- IMailboxRepository,
7
- IMailboxSpecialUseRepository,
8
- IMessageRepository,
9
- IThreadMessageRepository,
10
- IUnitOfWork,
11
- } from "@remit/data-ports";
12
- import { CreateFailedConflictError } from "@remit/data-ports/errors";
13
- import { deriveCopyMessageId, deriveMessageId } from "@remit/data-ports/id";
14
- import { MailboxCursorState } from "@remit/domain-enums";
15
- import type { ManagedConnectionFactory } from "./connection-factory.js";
16
- import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
- import { MessageSyncService } from "./message-sync.js";
18
- import {
19
- NO_JUNK_ROLES,
20
- noFolderRoles,
21
- trashRole,
22
- } from "./test-helpers/folder-roles.js";
23
- import type { IImapConnection, ImapMessage } from "./types.js";
24
-
25
- const stubAddressService = (): IAddressRepository =>
26
- ({
27
- reconcileJunkOnlyForMessage: async () => {},
28
- }) as unknown as IAddressRepository;
29
-
30
- // A copy is a per-folder placement of the same mail. These tests pin the three
31
- // properties issue #75 turns on: the copy row is deterministic (idempotent), a
32
- // delete removes exactly the placement it targets, and neither the copy nor the
33
- // original leaves an unreachable row behind.
34
-
35
- const ACCOUNT = "acc-1";
36
- const ACCOUNT_CONFIG = "cfg-1";
37
- const SOURCE_MAILBOX = "mbx-inbox";
38
- const DEST_MAILBOX = "mbx-archive";
39
- const HEADER = "<abc@example.com>";
40
- const SOURCE_ID = deriveMessageId(ACCOUNT, HEADER);
41
- const THREAD_ID = "thread-1";
42
-
43
- interface ThreadRow {
44
- accountConfigId: string;
45
- threadMessageId: string;
46
- threadId: string;
47
- messageId: string;
48
- mailboxId: string;
49
- messageIdHeader: string;
50
- isRead?: boolean;
51
- hasStars?: boolean;
52
- listId?: string;
53
- }
54
-
55
- const buildWorld = () => {
56
- const messages = new Map<string, Record<string, unknown>>([
57
- [
58
- SOURCE_ID,
59
- {
60
- messageId: SOURCE_ID,
61
- mailboxId: SOURCE_MAILBOX,
62
- uid: 42,
63
- rfc822Size: 100,
64
- internalDate: 1_700_000_000_000,
65
- messageIdHeader: HEADER,
66
- envelopeId: "env-1",
67
- rootBodyPartId: "body-1",
68
- bodyStorageKey: "s3://body-1",
69
- category: "primary",
70
- classificationState: "Examined",
71
- hasListUnsubscribe: false,
72
- // Settled, which is what a delete's predicate asks for: a row every
73
- // repository read carries a status, and leaving it off made the
74
- // fixture answer for a state no row is ever in.
75
- status: "active",
76
- syncStatus: "synced",
77
- },
78
- ],
79
- ]);
80
-
81
- const threadRows: ThreadRow[] = [
82
- {
83
- accountConfigId: ACCOUNT_CONFIG,
84
- threadMessageId: `tm:${THREAD_ID}::${SOURCE_ID}`,
85
- threadId: THREAD_ID,
86
- messageId: SOURCE_ID,
87
- mailboxId: SOURCE_MAILBOX,
88
- messageIdHeader: HEADER,
89
- isRead: false,
90
- hasStars: false,
91
- listId: "invitations.linkedin.com",
92
- },
93
- ];
94
-
95
- const mailboxes = new Map<string, Record<string, unknown>>([
96
- [
97
- SOURCE_MAILBOX,
98
- { mailboxId: SOURCE_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
99
- ],
100
- [
101
- DEST_MAILBOX,
102
- { mailboxId: DEST_MAILBOX, fullPath: "Archive", accountId: ACCOUNT },
103
- ],
104
- ]);
105
-
106
- const messageService = {
107
- get: async (id: string | string[]) => {
108
- if (Array.isArray(id)) {
109
- return id.map((i) => messages.get(i)).filter(Boolean);
110
- }
111
- const m = messages.get(id);
112
- if (!m) throw new Error(`no message ${id}`);
113
- return m;
114
- },
115
- // Faithful to the production repo: a duplicate messageId throws a
116
- // conflict. create is NOT idempotent — only upsert is — so a copy path
117
- // that relied on create swallowing the duplicate would strand the second
118
- // attempt here (issue #75). The copy path uses upsert for exactly this
119
- // reason.
120
- create: async (input: Record<string, unknown>) => {
121
- const id = input.messageId as string;
122
- if (messages.has(id)) {
123
- throw new CreateFailedConflictError("Message", input);
124
- }
125
- messages.set(id, { ...input });
126
- return messages.get(id);
127
- },
128
- upsert: async (input: Record<string, unknown>) => {
129
- const id = input.messageId as string;
130
- if (!messages.has(id)) messages.set(id, { ...input });
131
- return messages.get(id);
132
- },
133
- update: async (id: string, patch: Record<string, unknown>) => {
134
- const m = messages.get(id);
135
- if (m) Object.assign(m, patch);
136
- return m;
137
- },
138
- transitionPlacement: async (
139
- id: string,
140
- expected: Record<string, unknown>,
141
- patch: Record<string, unknown>,
142
- ) => {
143
- const m = messages.get(id) as Record<string, unknown> | undefined;
144
- if (!m) return undefined;
145
- for (const [field, want] of Object.entries(expected)) {
146
- if (want !== undefined && m[field] !== want) return undefined;
147
- }
148
- Object.assign(m, patch);
149
- return m;
150
- },
151
- } as unknown as IMessageRepository;
152
-
153
- const threadMessageService = {
154
- create: async (input: Record<string, unknown>) => {
155
- const threadId = input.threadId as string;
156
- const messageId = input.messageId as string;
157
- const existing = threadRows.find(
158
- (r) => r.threadId === threadId && r.messageId === messageId,
159
- );
160
- if (existing) return existing;
161
- const row: ThreadRow = {
162
- accountConfigId: input.accountConfigId as string,
163
- threadMessageId: `tm:${threadId}::${messageId}`,
164
- threadId,
165
- messageId,
166
- mailboxId: input.mailboxId as string,
167
- messageIdHeader: input.messageIdHeader as string,
168
- listId: input.listId as string | undefined,
169
- };
170
- threadRows.push(row);
171
- return row;
172
- },
173
- getByMessageId: async (_cfg: string, messageId: string) => {
174
- const row = threadRows.find((r) => r.messageId === messageId);
175
- if (!row) throw new Error(`no thread message ${messageId}`);
176
- return row;
177
- },
178
- findByMessageId: async (_cfg: string, messageId: string) =>
179
- threadRows.find((r) => r.messageId === messageId) ?? null,
180
- findAllByMessageId: async (_cfg: string, messageId: string) =>
181
- threadRows.filter((r) => r.messageId === messageId),
182
- delete: async (_cfg: string, threadMessageId: string) => {
183
- const idx = threadRows.findIndex(
184
- (r) => r.threadMessageId === threadMessageId,
185
- );
186
- if (idx >= 0) threadRows.splice(idx, 1);
187
- },
188
- } as unknown as IThreadMessageRepository;
189
-
190
- const mailboxService = {
191
- get: async (_acc: string, id: string | string[]) => {
192
- if (Array.isArray(id)) {
193
- return id.map((i) => mailboxes.get(i)).filter(Boolean);
194
- }
195
- return mailboxes.get(id);
196
- },
197
- } as unknown as IMailboxRepository;
198
-
199
- // No Trash configured, so deleteMessages takes the permanent-delete path —
200
- // the branch that eagerly removes ThreadMessage rows (issue #212), which is
201
- // where a copy's placement row must be reachable.
202
- const mailboxSpecialUseService = {
203
- findTrashMailbox: async () => null,
204
- resolveTrashRole: async () => trashRole(null),
205
- resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
206
- } as unknown as IMailboxSpecialUseRepository;
207
-
208
- const config: MessageMoveConfig = {
209
- messageService,
210
- mailboxService,
211
- mailboxSpecialUseService,
212
- threadMessageService,
213
- addressService: stubAddressService(),
214
- sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
215
- };
216
-
217
- const service = new MessageMoveService(config);
218
- // The service builds a real SQS producer in its constructor; a unit test has
219
- // no queue, so swap in a recorder.
220
- const sent: unknown[] = [];
221
- (
222
- service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
223
- ).sqs = {
224
- send: async (command: unknown) => {
225
- sent.push(command);
226
- return {};
227
- },
228
- };
229
-
230
- return {
231
- service,
232
- threadRows,
233
- messages,
234
- sent,
235
- messageService,
236
- threadMessageService,
237
- };
238
- };
239
-
240
- // A message copied into the destination folder, as IMAP later enumerates it:
241
- // same valid Message-ID header as the source, a real server UID. Sync derives
242
- // its identity from that header (folder-independent), so the id it computes is
243
- // the ORIGINAL's — not the copy's folder-scoped id.
244
- const destinationServerMessage = (uid: number): ImapMessage =>
245
- ({
246
- uid,
247
- seq: uid,
248
- flags: [],
249
- internalDate: new Date("2023-11-14T22:13:20Z"),
250
- size: 100,
251
- modseq: "510",
252
- envelope: {
253
- date: "Tue, 14 Nov 2023 22:13:20 +0000",
254
- subject: "Hello",
255
- from: [{ mailbox: "sender", host: "example.com" }],
256
- sender: [],
257
- replyTo: [],
258
- to: [{ mailbox: "me", host: "example.com" }],
259
- cc: [],
260
- bcc: [],
261
- inReplyTo: "",
262
- messageId: HEADER,
263
- },
264
- }) as ImapMessage;
265
-
266
- // A MessageSyncService that reads and writes the SAME rows the copy produced,
267
- // so "copy then sync" is exercised against one shared world. A stored envelope
268
- // with a valid Message-ID takes the CHANGEDSINCE path; anything the round tries
269
- // to create lands in the shared maps, so a duplicate would be visible to the
270
- // caller's row-count assertions.
271
- const buildSyncOverDestination = (
272
- world: ReturnType<typeof buildWorld>,
273
- changed: ImapMessage[],
274
- ) => {
275
- const destMailbox = {
276
- mailboxId: DEST_MAILBOX,
277
- accountId: ACCOUNT,
278
- fullPath: "Archive",
279
- uidValidity: 100,
280
- lastSyncUid: 1,
281
- highWaterMarkUid: 20,
282
- highestModseq: "500",
283
- cursorState: MailboxCursorState.normal,
284
- };
285
-
286
- const connection = {
287
- openBox: async () => ({ uidvalidity: 100, uidnext: 99 }),
288
- getMailboxStatus: async () => ({
289
- messages: 10,
290
- recent: 0,
291
- unseen: 1,
292
- uidNext: 99,
293
- uidValidity: 100,
294
- highestModseq: "600",
295
- deletedCount: 0,
296
- }),
297
- supportsCondstore: () => true,
298
- fetchMessagesChangedSince: async () => changed,
299
- search: async () => [],
300
- fetchMessages: async () => [],
301
- fetchEnvelopeSnapshots: async () => [],
302
- } as unknown as IImapConnection;
303
-
304
- const connectionFactory = {
305
- getConnection: () => connection,
306
- close: async () => {},
307
- } as ManagedConnectionFactory;
308
-
309
- const mailboxService = {
310
- get: async () => destMailbox,
311
- update: async () => destMailbox,
312
- } as unknown as IMailboxRepository;
313
-
314
- // A create that reaches DynamoDB would append to the shared maps, so the
315
- // caller's "nothing accumulated" assertions catch a regression that mints a
316
- // third row.
317
- const unitOfWork: IUnitOfWork = {
318
- transaction: (fn) =>
319
- fn({
320
- message: world.messageService,
321
- envelope: {
322
- upsertEnvelope: async () => undefined,
323
- upsertBodyParts: async () => undefined,
324
- } as unknown as IEnvelopeRepository,
325
- address: {
326
- upsertCorrespondentAddress: async () => undefined,
327
- upsertEnvelopeAddress: async () => undefined,
328
- } as unknown as IAddressRepository,
329
- threadMessage: world.threadMessageService,
330
- }),
331
- };
332
-
333
- return new MessageSyncService(
334
- connectionFactory,
335
- mailboxService,
336
- noFolderRoles,
337
- world.messageService,
338
- {} as IEnvelopeRepository,
339
- {} as IAddressRepository,
340
- world.threadMessageService,
341
- undefined,
342
- unitOfWork,
343
- );
344
- };
345
-
346
- describe("MessageMoveService.copyMessage — deterministic per-folder identity (#75)", () => {
347
- it("derives the copy id from source + destination, not at random", async () => {
348
- const { service, threadRows } = buildWorld();
349
-
350
- await service.copyMessages(
351
- ACCOUNT_CONFIG,
352
- [SOURCE_ID],
353
- DEST_MAILBOX,
354
- ACCOUNT,
355
- );
356
-
357
- const expected = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
358
- const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
359
- assert.ok(copyRow, "a placement row exists in the destination folder");
360
- assert.equal(copyRow.messageId, expected);
361
- });
362
-
363
- it("carries the source row's list id onto the copy", async () => {
364
- // The copy inherits its source's stored body, so no classifying pass ever
365
- // runs over it to derive a list id of its own (issue #331).
366
- const { service, threadRows } = buildWorld();
367
-
368
- await service.copyMessages(
369
- ACCOUNT_CONFIG,
370
- [SOURCE_ID],
371
- DEST_MAILBOX,
372
- ACCOUNT,
373
- );
374
-
375
- const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
376
- assert.equal(copyRow?.listId, "invitations.linkedin.com");
377
- });
378
-
379
- it("carries the source's classification state onto the copy", async () => {
380
- // Defaulting the copy to `NotExamined` would put a row no classifier will
381
- // ever reach — its body is already stored — into the cohort a backfill
382
- // selects as never examined (issue #331).
383
- const { service, messages } = buildWorld();
384
-
385
- await service.copyMessages(
386
- ACCOUNT_CONFIG,
387
- [SOURCE_ID],
388
- DEST_MAILBOX,
389
- ACCOUNT,
390
- );
391
-
392
- const copy = [...messages.values()].find(
393
- (m) => m.mailboxId === DEST_MAILBOX,
394
- );
395
- assert.equal(copy?.classificationState, "Examined");
396
- });
397
-
398
- it("a replayed copy converges on one row, no duplicate", async () => {
399
- const { service, threadRows, messages } = buildWorld();
400
-
401
- await service.copyMessages(
402
- ACCOUNT_CONFIG,
403
- [SOURCE_ID],
404
- DEST_MAILBOX,
405
- ACCOUNT,
406
- );
407
- // A replay (retried COPY event, or the user copying the same mail again)
408
- // re-derives the same id. The copy path upserts, so the second write is a
409
- // no-op on the existing row rather than the CreateFailedConflictError a
410
- // plain create would throw — the operation is idempotent.
411
- await service.copyMessages(
412
- ACCOUNT_CONFIG,
413
- [SOURCE_ID],
414
- DEST_MAILBOX,
415
- ACCOUNT,
416
- );
417
-
418
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
419
- assert.equal(
420
- threadRows.filter((r) => r.messageId === copyId).length,
421
- 1,
422
- "exactly one copy row after a replay",
423
- );
424
- assert.equal(
425
- threadRows.length,
426
- 2,
427
- "the original and one copy — nothing accumulates",
428
- );
429
- assert.equal(messages.has(copyId), true);
430
- });
431
-
432
- it("copy then a destination-folder sync creates no third row", async () => {
433
- const world = buildWorld();
434
-
435
- await world.service.copyMessages(
436
- ACCOUNT_CONFIG,
437
- [SOURCE_ID],
438
- DEST_MAILBOX,
439
- ACCOUNT,
440
- );
441
-
442
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
443
- assert.equal(world.threadRows.length, 2, "original plus one copy");
444
- assert.equal(world.messages.size, 2, "original plus one copy");
445
-
446
- // IMAP now enumerates the copied message in the destination folder. Sync
447
- // derives its id from the valid Message-ID header — the folder-independent
448
- // original id, not the copy's — finds the existing original row, and takes
449
- // the flag-only branch. The crux of #75: no third or duplicate row.
450
- const sync = buildSyncOverDestination(world, [
451
- destinationServerMessage(77),
452
- ]);
453
- const result = await sync.syncMessages(
454
- DEST_MAILBOX,
455
- ACCOUNT,
456
- ACCOUNT_CONFIG,
457
- );
458
-
459
- assert.equal(result.syncedCount, 0, "sync created no new message");
460
- assert.equal(
461
- world.threadRows.length,
462
- 2,
463
- "still original plus the copy — sync added nothing",
464
- );
465
- assert.equal(
466
- world.messages.size,
467
- 2,
468
- "still original plus the copy — sync added nothing",
469
- );
470
- assert.equal(
471
- world.threadRows.filter((r) => r.messageId === copyId).length,
472
- 1,
473
- "the copy row is untouched",
474
- );
475
- });
476
-
477
- it("copy then delete of the copy removes the copy row and leaves the original", async () => {
478
- const { service, threadRows, messages } = buildWorld();
479
-
480
- await service.copyMessages(
481
- ACCOUNT_CONFIG,
482
- [SOURCE_ID],
483
- DEST_MAILBOX,
484
- ACCOUNT,
485
- );
486
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
487
-
488
- // A fresh copy row is `moving` until the server hands back its COPYUID, and
489
- // a delete against it would name uid 0. Settle it the way `updateUid`
490
- // does, which is also the only state in which a user can see it to delete.
491
- Object.assign(messages.get(copyId) ?? {}, {
492
- uid: 77,
493
- status: "active",
494
- syncStatus: "synced",
495
- });
496
-
497
- await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
498
- permanent: true,
499
- });
500
-
501
- assert.equal(
502
- threadRows.some((r) => r.messageId === copyId),
503
- false,
504
- "copy row removed",
505
- );
506
- assert.equal(
507
- threadRows.some((r) => r.messageId === SOURCE_ID),
508
- true,
509
- "original row survives",
510
- );
511
- });
512
-
513
- it("copy then delete of the original removes the original and leaves the copy — no orphan of the wrong row", async () => {
514
- const { service, threadRows } = buildWorld();
515
-
516
- await service.copyMessages(
517
- ACCOUNT_CONFIG,
518
- [SOURCE_ID],
519
- DEST_MAILBOX,
520
- ACCOUNT,
521
- );
522
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
523
-
524
- await service.deleteMessages(ACCOUNT_CONFIG, [SOURCE_ID], ACCOUNT, {
525
- permanent: true,
526
- });
527
-
528
- assert.equal(
529
- threadRows.some((r) => r.messageId === SOURCE_ID),
530
- false,
531
- "original row removed",
532
- );
533
- assert.equal(
534
- threadRows.some((r) => r.messageId === copyId),
535
- true,
536
- "copy row survives its own delete boundary",
537
- );
538
- });
539
-
540
- it("a copy into two folders is two distinct, reachable rows", async () => {
541
- const { service, threadRows } = buildWorld();
542
-
543
- await service.copyMessages(
544
- ACCOUNT_CONFIG,
545
- [SOURCE_ID],
546
- DEST_MAILBOX,
547
- ACCOUNT,
548
- );
549
- await service.copyMessages(
550
- ACCOUNT_CONFIG,
551
- [SOURCE_ID],
552
- SOURCE_MAILBOX,
553
- ACCOUNT,
554
- );
555
-
556
- const idArchive = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
557
- const idInbox = deriveCopyMessageId(SOURCE_ID, SOURCE_MAILBOX);
558
- assert.notEqual(idArchive, idInbox);
559
- assert.equal(threadRows.filter((r) => r.messageId === idArchive).length, 1);
560
- assert.equal(threadRows.filter((r) => r.messageId === idInbox).length, 1);
561
- });
562
- });