@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,191 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IMailboxSpecialUseRepository,
5
- IQuarantineRepository,
6
- MessageData,
7
- MessageItem,
8
- QuarantineItem,
9
- QuarantineUpsertInput,
10
- } from "@remit/data-ports";
11
- import {
12
- QuarantinedUids,
13
- QuarantineService,
14
- resolveMailboxRole,
15
- shapeFromMessageData,
16
- } from "./quarantine.js";
17
-
18
- const noopLog = { info: () => {}, warn: () => {} };
19
-
20
- const buildService = (
21
- specialUse: string[] = [],
22
- ): {
23
- service: QuarantineService;
24
- writes: QuarantineUpsertInput[];
25
- listed: string[];
26
- } => {
27
- const writes: QuarantineUpsertInput[] = [];
28
- const listed: string[] = [];
29
- const repository = {
30
- listByAccountConfigId: async (accountConfigId: string) => {
31
- listed.push(accountConfigId);
32
- return [] as QuarantineItem[];
33
- },
34
- upsert: async (input: QuarantineUpsertInput) => {
35
- writes.push(input);
36
- },
37
- } satisfies IQuarantineRepository;
38
-
39
- const mailboxSpecialUseService = {
40
- listByMailboxId: async () => specialUse.map((use) => ({ specialUse: use })),
41
- } as unknown as IMailboxSpecialUseRepository;
42
-
43
- return {
44
- service: new QuarantineService(
45
- repository,
46
- mailboxSpecialUseService,
47
- "sha-abc",
48
- noopLog,
49
- ),
50
- writes,
51
- listed,
52
- };
53
- };
54
-
55
- const context = {
56
- accountId: "acct-1",
57
- accountConfigId: "cfg-1",
58
- mailboxId: "mbx-1",
59
- mailboxPath: "INBOX",
60
- uidValidity: 1_712_000_000,
61
- attempts: 2,
62
- };
63
-
64
- const failure = {
65
- stage: "BodyParse" as const,
66
- code: "UnreadableBody" as const,
67
- message: "the parser said no",
68
- };
69
-
70
- describe("QuarantineService.record", () => {
71
- it("stamps the worker build, not the client's", async () => {
72
- const { service, writes } = buildService();
73
- await service.record(context, 40217, failure);
74
- assert.equal(writes[0]?.workerVersion, "sha-abc");
75
- });
76
-
77
- it("names the message by mailbox, uidValidity and uid", async () => {
78
- const { service, writes } = buildService();
79
- await service.record(context, 40217, failure);
80
- assert.deepEqual(
81
- {
82
- mailboxId: writes[0]?.mailboxId,
83
- uidValidity: writes[0]?.uidValidity,
84
- uid: writes[0]?.uid,
85
- },
86
- { mailboxId: "mbx-1", uidValidity: 1_712_000_000, uid: 40217 },
87
- );
88
- });
89
-
90
- it("supplies no quarantineId, so the random-id default is unreachable", async () => {
91
- const { service, writes } = buildService();
92
- await service.record(context, 40217, failure);
93
- assert.ok(!("quarantineId" in (writes[0] as object)));
94
- });
95
-
96
- it("omits an absent diagnostic rather than writing a null through", async () => {
97
- const { service, writes } = buildService();
98
- await service.record(
99
- { ...context, mailboxPath: "Clients/Acme" },
100
- 1,
101
- failure,
102
- );
103
- assert.ok(!("failurePartPath" in (writes[0] as object)));
104
- assert.ok(!("mailboxRole" in (writes[0] as object)));
105
- });
106
-
107
- it("reads the folder's role off the server's SPECIAL-USE", async () => {
108
- const { service, writes } = buildService(["Junk"]);
109
- await service.record({ ...context, mailboxPath: "Spam" }, 1, failure);
110
- assert.equal(writes[0]?.mailboxRole, "Junk");
111
- });
112
- });
113
-
114
- describe("resolveMailboxRole", () => {
115
- it("gives INBOX its role without a SPECIAL-USE flag, which it never has", () => {
116
- assert.equal(resolveMailboxRole("INBOX", []), "Inbox");
117
- });
118
-
119
- it("leaves a plain folder roleless instead of inventing one", () => {
120
- assert.equal(resolveMailboxRole("Clients/Acme", []), undefined);
121
- });
122
-
123
- it("drops a SPECIAL-USE that is not a canonical role", () => {
124
- assert.equal(resolveMailboxRole("Priority", ["Important"]), undefined);
125
- });
126
- });
127
-
128
- describe("QuarantinedUids", () => {
129
- const entries = [
130
- { mailboxId: "mbx-1", uidValidity: 10, uid: 5 },
131
- ] as QuarantineItem[];
132
-
133
- it("matches a uid on the same mailbox and UIDVALIDITY", () => {
134
- assert.equal(new QuarantinedUids(entries).has("mbx-1", 10, 5), true);
135
- });
136
-
137
- it("does not match the same uid on a new UIDVALIDITY, which is a different message", () => {
138
- assert.equal(new QuarantinedUids(entries).has("mbx-1", 11, 5), false);
139
- });
140
-
141
- it("does not match the same uid in another mailbox", () => {
142
- assert.equal(new QuarantinedUids(entries).has("mbx-2", 10, 5), false);
143
- });
144
- });
145
-
146
- describe("shapeFromMessageData", () => {
147
- const message = {
148
- rfc822Size: 2048,
149
- messageIdHeader: "<xyz@example.com>",
150
- } as MessageItem;
151
-
152
- const data = {
153
- bodyPart: [
154
- {
155
- bodyPartId: "bp-0",
156
- partPath: "0",
157
- mediaType: "multipart",
158
- mediaSubtype: "alternative",
159
- transferEncoding: "7bit",
160
- },
161
- {
162
- bodyPartId: "bp-1",
163
- partPath: "1",
164
- mediaType: "text",
165
- mediaSubtype: "plain",
166
- transferEncoding: "quoted-printable",
167
- },
168
- ],
169
- bodyPartParameter: [
170
- {
171
- bodyPartId: "bp-0",
172
- parameterName: "charset",
173
- parameterValue: "iso-8859-1",
174
- },
175
- ],
176
- } as unknown as MessageData;
177
-
178
- it("rebuilds the tree from the rows metadata sync already wrote", () => {
179
- assert.deepEqual(shapeFromMessageData(message, data).structure, [
180
- { depth: 0, contentType: "multipart/alternative" },
181
- { depth: 1, contentType: "text/plain" },
182
- ]);
183
- });
184
-
185
- it("takes the root part's declared charset and encoding", () => {
186
- const shape = shapeFromMessageData(message, data);
187
- assert.equal(shape.charset, "iso-8859-1");
188
- assert.equal(shape.transferEncoding, "7bit");
189
- assert.equal(shape.contentType, "multipart/alternative");
190
- });
191
- });
package/src/quarantine.ts DELETED
@@ -1,228 +0,0 @@
1
- import type {
2
- IMailboxSpecialUseRepository,
3
- IQuarantineRepository,
4
- MessageData,
5
- MessageItem,
6
- QuarantineItem,
7
- QuarantineMimeNodeItem,
8
- } from "@remit/data-ports";
9
- import { quarantineMessageIdHash, ROOT_PART_PATH } from "@remit/data-ports/id";
10
- import { CanonicalMailboxRole } from "@remit/domain-enums";
11
-
12
- type CanonicalRole = NonNullable<QuarantineItem["mailboxRole"]>;
13
- type FailureStage = QuarantineItem["failureStage"];
14
- type FailureCode = QuarantineItem["failureCode"];
15
-
16
- const CANONICAL_ROLES = new Set<string>(Object.values(CanonicalMailboxRole));
17
-
18
- /**
19
- * The canonical role of the folder a quarantined message arrived in, read from
20
- * the server's own RFC 6154 SPECIAL-USE declaration — the same source the
21
- * appointment flow seeds from. A folder the server declares nothing about has
22
- * no role, which is the normal state for a plain folder and is why the field
23
- * is optional. `Important` is a SPECIAL-USE with no canonical role and drops
24
- * out here rather than being invented into one.
25
- */
26
- export const resolveMailboxRole = (
27
- mailboxPath: string,
28
- specialUse: readonly string[],
29
- ): CanonicalRole | undefined => {
30
- if (mailboxPath.toUpperCase() === "INBOX") return CanonicalMailboxRole.Inbox;
31
- const matched = specialUse.find((use) => CANONICAL_ROLES.has(use));
32
- return matched as CanonicalRole | undefined;
33
- };
34
-
35
- /** Everything about the quarantine that is the same for every uid in a round. */
36
- export interface QuarantineContext {
37
- accountId: string;
38
- accountConfigId: string;
39
- mailboxId: string;
40
- mailboxPath: string;
41
- uidValidity: number;
42
- /** Rounds tried before the message was set aside. */
43
- attempts: number;
44
- }
45
-
46
- /**
47
- * The repro fingerprint: what the message declared itself to be. Every field
48
- * is optional-by-absence because a message can fail before its shape was read,
49
- * and a required column would force the writer to invent a value or drop the
50
- * record.
51
- */
52
- export interface QuarantineMessageShape {
53
- contentType?: string;
54
- transferEncoding?: string;
55
- charset?: string;
56
- sizeBytes?: number;
57
- structure: QuarantineMimeNodeItem[];
58
- messageIdHash?: string;
59
- }
60
-
61
- export interface QuarantineFailure {
62
- stage: FailureStage;
63
- code: FailureCode;
64
- /** Parser error text. Stored, shown on screen, never published. */
65
- message: string;
66
- partPath?: string;
67
- }
68
-
69
- const EMPTY_SHAPE: QuarantineMessageShape = { structure: [] };
70
-
71
- /**
72
- * Message shape off the rows metadata sync already wrote. The body path has no
73
- * FETCH result to read — it streams raw bytes — but the MIME tree it needs was
74
- * walked into BodyPart rows when the message's metadata was synced, so the
75
- * fingerprint comes from there rather than from re-reading headers the parser
76
- * has just refused.
77
- */
78
- export const shapeFromMessageData = (
79
- message: MessageItem,
80
- data: MessageData,
81
- ): QuarantineMessageShape => {
82
- const parts = [...data.bodyPart].sort((a, b) =>
83
- a.partPath.localeCompare(b.partPath, "en", { numeric: true }),
84
- );
85
- const root = parts.find((part) => part.partPath === ROOT_PART_PATH);
86
- const rootCharset = root
87
- ? data.bodyPartParameter.find(
88
- (param) =>
89
- param.bodyPartId === root.bodyPartId &&
90
- param.parameterName.toLowerCase() === "charset",
91
- )?.parameterValue
92
- : undefined;
93
-
94
- return {
95
- ...(root ? { contentType: `${root.mediaType}/${root.mediaSubtype}` } : {}),
96
- ...(root ? { transferEncoding: root.transferEncoding } : {}),
97
- ...(rootCharset ? { charset: rootCharset } : {}),
98
- ...(message.rfc822Size ? { sizeBytes: message.rfc822Size } : {}),
99
- structure: parts.map((part) => ({
100
- depth: partPathDepth(part.partPath),
101
- contentType: `${part.mediaType}/${part.mediaSubtype}`,
102
- })),
103
- ...hashOf(message.messageIdHeader),
104
- };
105
- };
106
-
107
- const partPathDepth = (partPath: string): number =>
108
- partPath === ROOT_PART_PATH ? 0 : partPath.split(".").length;
109
-
110
- const hashOf = (
111
- messageIdHeader: string | undefined,
112
- ): { messageIdHash?: string } => {
113
- const messageIdHash = quarantineMessageIdHash(messageIdHeader);
114
- return messageIdHash ? { messageIdHash } : {};
115
- };
116
-
117
- /**
118
- * The set of messages already set aside, held for the duration of one sync
119
- * round. A round loads it once and filters against it in memory: the list is
120
- * small by design — a growing one is a bug being reported, not a page to
121
- * paginate — and a lookup per message would put a query on the hot path for a
122
- * state that is almost always empty.
123
- */
124
- export class QuarantinedUids {
125
- private readonly keys: ReadonlySet<string>;
126
-
127
- constructor(entries: readonly QuarantineItem[]) {
128
- this.keys = new Set(
129
- entries.map((entry) =>
130
- uidKey(entry.mailboxId, entry.uidValidity, entry.uid),
131
- ),
132
- );
133
- }
134
-
135
- get size(): number {
136
- return this.keys.size;
137
- }
138
-
139
- has(mailboxId: string, uidValidity: number, uid: number): boolean {
140
- return this.keys.has(uidKey(mailboxId, uidValidity, uid));
141
- }
142
- }
143
-
144
- const uidKey = (mailboxId: string, uidValidity: number, uid: number): string =>
145
- `${mailboxId}:${uidValidity}:${uid}`;
146
-
147
- export interface QuarantineLogger {
148
- info(obj: Record<string, unknown>, msg: string): void;
149
- warn(obj: Record<string, unknown>, msg: string): void;
150
- }
151
-
152
- /**
153
- * Writes the record a message becomes when the sync path cannot apply it
154
- * (issue #72), and loads the set a round filters against.
155
- *
156
- * Only a message defect reaches this class. Deciding that is the caller's job
157
- * and it is made at exactly one kind of catch site — one narrow enough that
158
- * the error can only have come from the message itself. An S3, queue or
159
- * database failure propagates; writing one here would advance a cursor past
160
- * mail that is fine and tell the user it was unreadable.
161
- */
162
- export class QuarantineService {
163
- constructor(
164
- private readonly repository: IQuarantineRepository,
165
- private readonly mailboxSpecialUseService: IMailboxSpecialUseRepository,
166
- private readonly workerVersion: string,
167
- private readonly log: QuarantineLogger,
168
- ) {}
169
-
170
- async load(accountConfigId: string): Promise<QuarantinedUids> {
171
- return new QuarantinedUids(
172
- await this.repository.listByAccountConfigId(accountConfigId),
173
- );
174
- }
175
-
176
- /**
177
- * Set a message aside. Resolves only once the row is durable, because the
178
- * caller's next act is to let a cursor move past this uid — and a cursor
179
- * that moves past work no record survives is the silent loss this whole
180
- * feature exists to end.
181
- */
182
- async record(
183
- context: QuarantineContext,
184
- uid: number,
185
- failure: QuarantineFailure,
186
- shape: QuarantineMessageShape = EMPTY_SHAPE,
187
- ): Promise<void> {
188
- // Resolved here rather than per round: a round almost never writes a row,
189
- // so the lookup belongs on the write and not on the hot path.
190
- const specialUse = await this.mailboxSpecialUseService.listByMailboxId(
191
- context.mailboxId,
192
- );
193
- const mailboxRole = resolveMailboxRole(
194
- context.mailboxPath,
195
- specialUse.map((entry) => entry.specialUse),
196
- );
197
-
198
- await this.repository.upsert({
199
- accountConfigId: context.accountConfigId,
200
- accountId: context.accountId,
201
- mailboxId: context.mailboxId,
202
- uidValidity: context.uidValidity,
203
- uid,
204
- ...(mailboxRole ? { mailboxRole } : {}),
205
- mailboxPath: context.mailboxPath,
206
- quarantinedAt: Date.now(),
207
- attempts: context.attempts,
208
- failureStage: failure.stage,
209
- failureCode: failure.code,
210
- failureMessage: failure.message,
211
- ...(failure.partPath ? { failurePartPath: failure.partPath } : {}),
212
- workerVersion: this.workerVersion,
213
- ...shape,
214
- });
215
-
216
- this.log.warn(
217
- {
218
- mailboxId: context.mailboxId,
219
- mailboxPath: context.mailboxPath,
220
- uid,
221
- uidValidity: context.uidValidity,
222
- failureStage: failure.stage,
223
- failureCode: failure.code,
224
- },
225
- "Message quarantined; cursor may advance past it",
226
- );
227
- }
228
- }
@@ -1,213 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, it } from "node:test";
3
- import {
4
- extractSnippetFromEmail,
5
- generateSnippet,
6
- normalizeSubject,
7
- removeQuotedContent,
8
- } from "./snippet.js";
9
-
10
- describe("removeQuotedContent", () => {
11
- it("removes content after > quote marker", () => {
12
- const text = "Hello\nWorld\n> quoted line\nmore quoted";
13
- assert.strictEqual(removeQuotedContent(text), "Hello\nWorld");
14
- });
15
-
16
- it("removes content after 'On ... wrote:' pattern", () => {
17
- const text = "Hello\nOn Monday, John wrote:\nQuoted content";
18
- assert.strictEqual(removeQuotedContent(text), "Hello");
19
- });
20
-
21
- it("removes content after original message separator", () => {
22
- const text = "Hello\n--- Original Message ---\nQuoted content";
23
- assert.strictEqual(removeQuotedContent(text), "Hello");
24
- });
25
-
26
- it("removes content after Outlook underline separator", () => {
27
- const text = "Hello\n___\nQuoted content";
28
- assert.strictEqual(removeQuotedContent(text), "Hello");
29
- });
30
-
31
- it("removes content after Outlook forward header", () => {
32
- const text = "Hello\nFrom: John Sent: Today To: Jane\nQuoted content";
33
- assert.strictEqual(removeQuotedContent(text), "Hello");
34
- });
35
-
36
- it("returns full text when no quote markers", () => {
37
- const text = "Hello\nWorld\nNo quotes here";
38
- assert.strictEqual(removeQuotedContent(text), text);
39
- });
40
- });
41
-
42
- describe("normalizeSubject", () => {
43
- describe("prefix removal", () => {
44
- it("removes Re: prefix", () => {
45
- const result = normalizeSubject("Re: Meeting tomorrow");
46
- assert.ok(!result.includes("re"));
47
- assert.ok(result.includes("meet"));
48
- });
49
-
50
- it("removes Fwd: prefix", () => {
51
- const result = normalizeSubject("Fwd: Important document");
52
- assert.ok(!result.includes("fwd"));
53
- });
54
-
55
- it("removes numbered Re: prefix", () => {
56
- const result = normalizeSubject("Re[3]: Meeting tomorrow");
57
- assert.ok(!result.includes("re"));
58
- });
59
-
60
- it("removes multiple prefixes", () => {
61
- const result = normalizeSubject("Re: Fwd: Re: Original subject");
62
- assert.ok(!result.includes("re"));
63
- assert.ok(!result.includes("fwd"));
64
- });
65
-
66
- it("removes German Aw: prefix", () => {
67
- const result = normalizeSubject("Aw: Besprechung morgen");
68
- assert.ok(!result.includes("aw"));
69
- });
70
-
71
- it("removes Portuguese Res: prefix", () => {
72
- const result = normalizeSubject("Res: Reunião amanhã");
73
- assert.ok(!result.includes("res"));
74
- });
75
- });
76
-
77
- describe("language detection", () => {
78
- it("auto-detects English and uses English stemming", () => {
79
- const result = normalizeSubject(
80
- "Meeting tomorrow about running the project quickly",
81
- );
82
- // Should stem "running" to "run", "quickly" to "quick"
83
- assert.ok(result.includes("run") || result.includes("quick"));
84
- });
85
-
86
- it("auto-detects German and uses German processing", () => {
87
- const result = normalizeSubject(
88
- "Besprechung über die Verbindung und den Projektstand morgen",
89
- );
90
- // Should process with German stemmer
91
- assert.ok(result.length > 0);
92
- });
93
-
94
- it("auto-detects French and uses French processing", () => {
95
- const result = normalizeSubject(
96
- "Réunion demain matin pour discuter du projet",
97
- );
98
- // Should preserve French accented characters and process
99
- assert.ok(result.includes("réunion") || result.includes("reunion"));
100
- });
101
-
102
- it("uses provided language override", () => {
103
- const result = normalizeSubject("Meeting tomorrow", "en");
104
- assert.ok(result.includes("meet"));
105
- });
106
- });
107
-
108
- describe("normalization", () => {
109
- it("removes stopwords", () => {
110
- const result = normalizeSubject("The meeting is about the project");
111
- // "the" and "is" should be removed
112
- assert.ok(!result.split(" ").includes("the"));
113
- });
114
-
115
- it("handles Unicode characters", () => {
116
- const result = normalizeSubject("Réunion à Paris");
117
- // Should not strip accented characters
118
- assert.ok(result.includes("réunion") || result.includes("reunion"));
119
- assert.ok(result.includes("pari"));
120
- });
121
-
122
- it("deduplicates consecutive words", () => {
123
- const result = normalizeSubject("Meeting meeting meeting update");
124
- // After stemming, should dedupe
125
- const words = result.split(" ");
126
- for (let i = 1; i < words.length; i++) {
127
- assert.notStrictEqual(
128
- words[i],
129
- words[i - 1],
130
- "Should not have consecutive duplicates",
131
- );
132
- }
133
- });
134
- });
135
- });
136
-
137
- describe("generateSnippet", () => {
138
- it("generates snippet from text", () => {
139
- const text = "Hello, this is a test message.";
140
- assert.strictEqual(generateSnippet(text), text);
141
- });
142
-
143
- it("removes quoted content", () => {
144
- const text = "Hello\n> quoted line";
145
- assert.strictEqual(generateSnippet(text), "Hello");
146
- });
147
-
148
- it("normalizes whitespace", () => {
149
- const text = "Hello world\n\nmultiple spaces";
150
- assert.strictEqual(generateSnippet(text), "Hello world multiple spaces");
151
- });
152
-
153
- it("truncates long text at word boundary", () => {
154
- const text = "The quick brown fox jumps over the lazy dog repeatedly";
155
- const result = generateSnippet(text, 30);
156
- assert.ok(result.length <= 30);
157
- assert.ok(result.endsWith("..."));
158
- });
159
-
160
- it("truncates single long word", () => {
161
- const text = "supercalifragilisticexpialidocious";
162
- const result = generateSnippet(text, 20);
163
- assert.ok(result.endsWith("..."));
164
- // When no word boundary found, truncates at maxLength-1 + "..."
165
- // so result is at most maxLength + 2 (for the extra chars in "...")
166
- assert.ok(result.length <= 22);
167
- });
168
- });
169
-
170
- describe("extractSnippetFromEmail", () => {
171
- it("prefers plain text over HTML", () => {
172
- const result = extractSnippetFromEmail("Plain text", "<p>HTML</p>");
173
- assert.strictEqual(result, "Plain text");
174
- });
175
-
176
- it("falls back to HTML when no plain text", () => {
177
- const result = extractSnippetFromEmail(undefined, "<p>HTML content</p>");
178
- assert.ok(result.includes("HTML content"));
179
- });
180
-
181
- it("strips HTML tags", () => {
182
- const result = extractSnippetFromEmail(
183
- undefined,
184
- "<p><strong>Bold</strong> text</p>",
185
- );
186
- assert.ok(!result.includes("<"));
187
- assert.ok(!result.includes(">"));
188
- assert.ok(result.includes("Bold"));
189
- assert.ok(result.includes("text"));
190
- });
191
-
192
- it("removes script and style tags", () => {
193
- const html = "<style>body{}</style><script>alert(1)</script><p>Content</p>";
194
- const result = extractSnippetFromEmail(undefined, html);
195
- assert.ok(!result.includes("body"));
196
- assert.ok(!result.includes("alert"));
197
- assert.ok(result.includes("Content"));
198
- });
199
-
200
- it("decodes HTML entities", () => {
201
- const html = "<p>&amp; &lt; &gt; &quot; &nbsp;</p>";
202
- const result = extractSnippetFromEmail(undefined, html);
203
- assert.ok(result.includes("&"));
204
- assert.ok(result.includes("<"));
205
- assert.ok(result.includes(">"));
206
- assert.ok(result.includes('"'));
207
- });
208
-
209
- it("returns empty string when no content", () => {
210
- const result = extractSnippetFromEmail(undefined, undefined);
211
- assert.strictEqual(result, "");
212
- });
213
- });