@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,147 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, test } from "node:test";
3
- import type { MessageItem } from "@remit/data-ports";
4
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
5
- import { carriesForeignUid, placementBindingOf } from "./placement-settled.js";
6
-
7
- /**
8
- * Pins `placementBindingOf` directly. It is the guard #1098/#1143 depend on and
9
- * only end-to-end specs covered it, so a change made for the client's benefit
10
- * could have moved it without anything going red.
11
- *
12
- * Note what these cases say about `syncStatus`: `failed` is a transient attempt
13
- * with a redelivery behind it, so a foreign-uid row carrying it is `in_flight`
14
- * and gets the settle ceiling. Only `abandoned` — the give-up value of the
15
- * placement state model (imap-mutations R3) — refuses outright.
16
- */
17
- const row = (over: Partial<MessageItem>): MessageItem =>
18
- ({
19
- messageId: "m-1",
20
- mailboxId: "mbx-dest",
21
- uid: 7,
22
- status: MessageStatus.moving,
23
- syncStatus: MessageSyncStatus.pending,
24
- originalMailboxId: "mbx-src",
25
- originalUid: 7,
26
- ...over,
27
- }) as unknown as MessageItem;
28
-
29
- describe("placementBindingOf", () => {
30
- test("a settled row binds consistently", () => {
31
- assert.equal(
32
- placementBindingOf(row({ status: MessageStatus.active })),
33
- "consistent",
34
- );
35
- });
36
-
37
- test("a row whose uid still names the source is in flight", () => {
38
- assert.equal(placementBindingOf(row({})), "in_flight");
39
- });
40
-
41
- /**
42
- * A give-up cannot reach this pair. `abandoned` is written only alongside
43
- * `active` or `deleted`, on a row already put back on a placement the server
44
- * confirmed, so the binding it produces is `consistent` — there is no
45
- * give-up member here to return, and no dependent mutation is refused on
46
- * account of one.
47
- */
48
- test("a give-up never presents as a foreign-uid row at all", () => {
49
- assert.equal(
50
- placementBindingOf(
51
- row({
52
- status: MessageStatus.active,
53
- syncStatus: MessageSyncStatus.abandoned,
54
- mailboxId: "mbx-src",
55
- }),
56
- ),
57
- "consistent",
58
- );
59
- });
60
-
61
- test("`failed` on a foreign-uid row is mid-retry, so it waits rather than refusing", () => {
62
- assert.equal(
63
- placementBindingOf(row({ syncStatus: MessageSyncStatus.failed })),
64
- "in_flight",
65
- );
66
- });
67
-
68
- // R3's state table calls `deleting` in flight exactly as `moving` is: a
69
- // delete points the row at Trash with the source's uid, so a dependent
70
- // mutation reading it resolves the same mismatched pair.
71
- test("a delete in flight is unsettled, not settled", () => {
72
- assert.equal(
73
- placementBindingOf(row({ status: MessageStatus.deleting })),
74
- "in_flight",
75
- );
76
- });
77
-
78
- test("a moving row that was never moved binds consistently", () => {
79
- assert.equal(
80
- placementBindingOf(
81
- row({ originalUid: undefined, originalMailboxId: undefined }),
82
- ),
83
- "consistent",
84
- );
85
- });
86
-
87
- test("a moving row whose uid has been repointed binds consistently", () => {
88
- assert.equal(placementBindingOf(row({ uid: 42 })), "consistent");
89
- });
90
-
91
- test("`failed` alone, with no foreign uid, is still consistent", () => {
92
- assert.equal(
93
- placementBindingOf(
94
- row({
95
- status: MessageStatus.active,
96
- syncStatus: MessageSyncStatus.failed,
97
- }),
98
- ),
99
- "consistent",
100
- );
101
- });
102
- });
103
-
104
- /**
105
- * The same question with the `status` gate off, for callers that resolve a uid
106
- * off a row some other operation may have re-marked on its way past (#1217).
107
- */
108
- describe("carriesForeignUid", () => {
109
- test("holds whatever an operation has since written to `status`", () => {
110
- for (const status of [
111
- MessageStatus.moving,
112
- MessageStatus.deleting,
113
- MessageStatus.active,
114
- ]) {
115
- assert.equal(carriesForeignUid(row({ status })), true, status);
116
- }
117
- });
118
-
119
- test("is false once `updateUid` settles the move", () => {
120
- // The settle writes the destination's own uid and drops `originalUid` in
121
- // the same statement, so a destination counter that hands back the
122
- // source's number cannot leave the row reading foreign forever.
123
- assert.equal(
124
- carriesForeignUid(
125
- row({
126
- status: MessageStatus.active,
127
- syncStatus: MessageSyncStatus.synced,
128
- originalUid: undefined,
129
- }),
130
- ),
131
- false,
132
- );
133
- });
134
-
135
- test("is false for a row that was never moved", () => {
136
- assert.equal(
137
- carriesForeignUid(
138
- row({ originalUid: undefined, originalMailboxId: undefined }),
139
- ),
140
- false,
141
- );
142
- });
143
-
144
- test("is false once a restore points the row back at that folder", () => {
145
- assert.equal(carriesForeignUid(row({ mailboxId: "mbx-src" })), false);
146
- });
147
- });
@@ -1,482 +0,0 @@
1
- /**
2
- * The three optimistic writers, against a row that moves under them
3
- * (docs/architecture/imap-mutations.md R3).
4
- *
5
- * Each reads a message, does several lookups, then writes where it decided to
6
- * put it. Between the read and the write, PLACEMENT_MOVE_PUSH can settle the
7
- * same row: that lane rides a standard queue, which the account's FIFO group
8
- * does not order against. A blind write then stamps `moving` and the OLD uid
9
- * over a folder and uid the mail server has just confirmed — a pair
10
- * `carriesForeignUid` reads as consistent, so nothing downstream catches it —
11
- * and the event that follows sends the worker after whatever now holds the old
12
- * number.
13
- *
14
- * The repository honours the predicate here the way the real one does, so a
15
- * losing write returns `undefined` and lands nothing at all.
16
- */
17
-
18
- import assert from "node:assert/strict";
19
- import { describe, it } from "node:test";
20
- import type {
21
- IAddressRepository,
22
- IMailboxRepository,
23
- IMailboxSpecialUseRepository,
24
- IMessagePlacementMoveRepository,
25
- IMessageRepository,
26
- IThreadMessageRepository,
27
- PlacementPredicate,
28
- PlacementTransitionInput,
29
- } from "@remit/data-ports";
30
- import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
31
- import { MessageMoveService } from "./message-move.js";
32
- import { PlacementMoveService } from "./placement-move.js";
33
- import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
34
-
35
- const ACCOUNT = "acc-1";
36
- const ACCOUNT_CONFIG = "cfg-1";
37
- const INBOX = "mbx-inbox";
38
- const ARCHIVE = "mbx-archive";
39
- const TRASH = "mbx-trash";
40
- const JUNK = "mbx-junk";
41
- const MESSAGE_ID = "msg-1";
42
-
43
- /** What the row is when each writer reads it. */
44
- const READ_UID = 42;
45
-
46
- /** What the placement push settles it to while the writer is still deciding. */
47
- const SETTLED_UID = 907;
48
-
49
- const mailboxes = [
50
- { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT, uidValidity: 1 },
51
- {
52
- mailboxId: ARCHIVE,
53
- fullPath: "Archive",
54
- accountId: ACCOUNT,
55
- uidValidity: 1,
56
- },
57
- { mailboxId: TRASH, fullPath: "Trash", accountId: ACCOUNT, uidValidity: 1 },
58
- { mailboxId: JUNK, fullPath: "Junk", accountId: ACCOUNT, uidValidity: 1 },
59
- ];
60
-
61
- interface CapturedEvent {
62
- type: string;
63
- messageId?: string;
64
- uid?: number;
65
- }
66
-
67
- interface Row {
68
- messageId: string;
69
- accountConfigId: string;
70
- mailboxId: string;
71
- uid: number;
72
- status: string;
73
- syncStatus: string;
74
- abandonedMutation: string;
75
- originalMailboxId?: string;
76
- originalUid?: number;
77
- }
78
-
79
- const freshRow = (): Row => ({
80
- messageId: MESSAGE_ID,
81
- accountConfigId: ACCOUNT_CONFIG,
82
- mailboxId: INBOX,
83
- uid: READ_UID,
84
- status: "active",
85
- syncStatus: "synced",
86
- abandonedMutation: "none",
87
- });
88
-
89
- interface World {
90
- row: Row;
91
- events: CapturedEvent[];
92
- writes: PlacementTransitionInput[];
93
- /** Destination folders the listing row was actually moved to. */
94
- threadMoves: string[];
95
- /** Stand in for the push settling the row onto Archive with Archive's uid. */
96
- settleElsewhere: () => void;
97
- messageService: IMessageRepository;
98
- threadMessageService: IThreadMessageRepository;
99
- mailboxService: IMailboxRepository;
100
- mailboxSpecialUseService: IMailboxSpecialUseRepository;
101
- addressService: IAddressRepository;
102
- }
103
-
104
- const buildWorld = (onLookup?: (world: World) => void): World => {
105
- const row = freshRow();
106
- const events: CapturedEvent[] = [];
107
- const writes: PlacementTransitionInput[] = [];
108
-
109
- const threadMoves: string[] = [];
110
- const world = { row, events, writes, threadMoves } as World;
111
-
112
- world.settleElsewhere = () => {
113
- row.mailboxId = ARCHIVE;
114
- row.uid = SETTLED_UID;
115
- row.status = "active";
116
- row.syncStatus = "synced";
117
- row.originalUid = undefined;
118
- };
119
-
120
- world.messageService = {
121
- // A snapshot, the way a repository read is one. Handing back the live
122
- // object would let the caller's `message` follow the concurrent settle,
123
- // and the predicate it builds from it would match by accident.
124
- get: async (ids: string | string[]) =>
125
- Array.isArray(ids) ? [{ ...row }] : ({ ...row } as unknown),
126
- update: async () => row,
127
- transitionPlacement: async (
128
- _messageId: string,
129
- expected: PlacementPredicate,
130
- next: PlacementTransitionInput,
131
- ) => {
132
- if (expected.status !== undefined && expected.status !== row.status) {
133
- return undefined;
134
- }
135
- if (
136
- expected.mailboxId !== undefined &&
137
- expected.mailboxId !== row.mailboxId
138
- ) {
139
- return undefined;
140
- }
141
- if (expected.uid !== undefined && expected.uid !== row.uid) {
142
- return undefined;
143
- }
144
- writes.push(next);
145
- Object.assign(row, next);
146
- return row;
147
- },
148
- } as unknown as IMessageRepository;
149
-
150
- world.threadMessageService = {
151
- getByMessageId: async () => ({
152
- accountConfigId: ACCOUNT_CONFIG,
153
- threadMessageId: "tm-1",
154
- messageId: MESSAGE_ID,
155
- mailboxId: row.mailboxId,
156
- sentDate: 1,
157
- isRead: false,
158
- isDeleted: false,
159
- hasStars: false,
160
- hasAttachment: false,
161
- }),
162
- findAllByMessageId: async () => [],
163
- update: async (
164
- _accountConfigId: string,
165
- _threadMessageId: string,
166
- patch: { mailboxId?: string },
167
- ) => {
168
- if (patch.mailboxId) threadMoves.push(patch.mailboxId);
169
- },
170
- } as unknown as IThreadMessageRepository;
171
-
172
- // Every writer resolves folders between reading the row and writing it, so
173
- // this is where the concurrent settle is made to land.
174
- world.mailboxService = {
175
- get: async (_accountId: string, ids: string | string[]) => {
176
- onLookup?.(world);
177
- return Array.isArray(ids)
178
- ? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
179
- : mailboxes.find((mailbox) => mailbox.mailboxId === ids);
180
- },
181
- } as unknown as IMailboxRepository;
182
-
183
- world.mailboxSpecialUseService = {
184
- findTrashMailbox: async () => mailboxes[2],
185
- resolveTrashRole: async () => ({ kind: "flagged", mailbox: mailboxes[2] }),
186
- resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
187
- } as unknown as IMailboxSpecialUseRepository;
188
-
189
- world.addressService = {
190
- reconcileJunkOnlyForMessage: async () => {},
191
- } as unknown as IAddressRepository;
192
-
193
- return world;
194
- };
195
-
196
- const buildMoveService = (world: World): MessageMoveService => {
197
- const service = new MessageMoveService({
198
- messageService: world.messageService,
199
- mailboxService: world.mailboxService,
200
- mailboxSpecialUseService: world.mailboxSpecialUseService,
201
- threadMessageService: world.threadMessageService,
202
- addressService: world.addressService,
203
- sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
204
- moveSettleTimeoutMs: 100,
205
- moveSettlePollMs: 10,
206
- });
207
- (
208
- service as unknown as {
209
- enqueueEvent: (e: CapturedEvent) => Promise<void>;
210
- enqueueEventsBatch: (e: CapturedEvent[]) => Promise<void>;
211
- }
212
- ).enqueueEvent = async (event) => {
213
- world.events.push(event);
214
- };
215
- (
216
- service as unknown as {
217
- enqueueEventsBatch: (e: CapturedEvent[]) => Promise<void>;
218
- }
219
- ).enqueueEventsBatch = async (batch) => {
220
- world.events.push(...batch);
221
- };
222
- return service;
223
- };
224
-
225
- describe("a user move writes only against the placement it read (R3)", () => {
226
- it("moves the message when nothing else touched the row", async () => {
227
- const world = buildWorld();
228
- const service = buildMoveService(world);
229
-
230
- await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, ARCHIVE, ACCOUNT);
231
-
232
- assert.equal(world.events.length, 1);
233
- assert.equal(world.row.status, "moving");
234
- assert.equal(world.row.mailboxId, ARCHIVE);
235
- });
236
-
237
- it("refuses, and writes nothing, when the row settles elsewhere mid-decision", async () => {
238
- let settled = false;
239
- const world = buildWorld((w) => {
240
- if (settled) return;
241
- settled = true;
242
- w.settleElsewhere();
243
- });
244
- const service = buildMoveService(world);
245
-
246
- await assert.rejects(
247
- () => service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT),
248
- (error: unknown) =>
249
- error instanceof MessagePlacementUnsettledError &&
250
- error.statusCode === 409,
251
- );
252
-
253
- assert.deepEqual(world.writes, [], "no placement was written");
254
- assert.deepEqual(world.events, [], "and nothing was enqueued");
255
- assert.equal(
256
- world.row.mailboxId,
257
- ARCHIVE,
258
- "the winner's placement stands exactly as it left it",
259
- );
260
- assert.equal(world.row.uid, SETTLED_UID);
261
- assert.equal(world.row.status, "active");
262
- });
263
- });
264
-
265
- describe("a delete writes only against the placement it read (R3)", () => {
266
- it("marks the message when nothing else touched the row", async () => {
267
- const world = buildWorld();
268
- const service = buildMoveService(world);
269
-
270
- await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
271
-
272
- assert.equal(world.events.length, 1);
273
- assert.equal(world.row.status, "moving");
274
- assert.equal(world.row.mailboxId, TRASH);
275
- });
276
-
277
- it("skips the message, and enqueues nothing for it, when the row settles elsewhere", async () => {
278
- let settled = false;
279
- const world = buildWorld((w) => {
280
- if (settled) return;
281
- settled = true;
282
- w.settleElsewhere();
283
- });
284
- const service = buildMoveService(world);
285
-
286
- const { refusedMessageIds } = await service.deleteMessages(
287
- ACCOUNT_CONFIG,
288
- [MESSAGE_ID],
289
- ACCOUNT,
290
- );
291
-
292
- assert.deepEqual(
293
- refusedMessageIds,
294
- [MESSAGE_ID],
295
- "the caller is told which rows this delete never claimed",
296
- );
297
- assert.deepEqual(world.writes, [], "no placement was written");
298
- assert.deepEqual(
299
- world.events,
300
- [],
301
- "and no delete was enqueued against a uid the source no longer holds",
302
- );
303
- assert.equal(world.row.mailboxId, ARCHIVE);
304
- assert.equal(world.row.uid, SETTLED_UID);
305
- });
306
- });
307
-
308
- // The folder lookup is scoped to one account. The daily brief lists every
309
- // account's mail, so a selection made there carries rows this lookup cannot
310
- // resolve — and dropping them silently answered "deleted" over messages still
311
- // sitting where they were.
312
- describe("a delete reports a row whose folder it could not resolve", () => {
313
- it("refuses it rather than counting it as deleted", async () => {
314
- const world = buildWorld();
315
- world.row.mailboxId = "mbx-another-account";
316
- const service = buildMoveService(world);
317
-
318
- const { refusedMessageIds } = await service.deleteMessages(
319
- ACCOUNT_CONFIG,
320
- [MESSAGE_ID],
321
- ACCOUNT,
322
- );
323
-
324
- assert.deepEqual(refusedMessageIds, [MESSAGE_ID]);
325
- assert.deepEqual(world.writes, [], "nothing was written");
326
- assert.deepEqual(world.events, [], "and nothing was enqueued");
327
- });
328
- });
329
-
330
- describe("a permanent delete writes only against the placement it read (R3)", () => {
331
- // The row is already in Trash, so the batch takes the expunge path rather
332
- // than the move-to-Trash one — a different writer, and the fourth of the
333
- // four. Its event names one uid in one folder, so a row that has moved since
334
- // the read must not be marked for it: a restore out of Trash settling in
335
- // between was overwritten with `deleting`, and the expunge went after the
336
- // uid Trash no longer holds.
337
- const inTrash = (world: World): void => {
338
- world.row.mailboxId = TRASH;
339
- };
340
-
341
- it("marks the message when nothing else touched the row", async () => {
342
- const world = buildWorld();
343
- inTrash(world);
344
- const service = buildMoveService(world);
345
-
346
- const { refusedMessageIds } = await service.deleteMessages(
347
- ACCOUNT_CONFIG,
348
- [MESSAGE_ID],
349
- ACCOUNT,
350
- { permanent: true },
351
- );
352
-
353
- assert.deepEqual(refusedMessageIds, []);
354
- assert.equal(world.row.status, "deleting");
355
- assert.equal(world.events.length, 1);
356
- });
357
-
358
- it("reports the message, and enqueues nothing, when the row settles elsewhere", async () => {
359
- let settled = false;
360
- const world = buildWorld((w) => {
361
- if (settled) return;
362
- settled = true;
363
- w.settleElsewhere();
364
- });
365
- inTrash(world);
366
- const service = buildMoveService(world);
367
-
368
- const { refusedMessageIds } = await service.deleteMessages(
369
- ACCOUNT_CONFIG,
370
- [MESSAGE_ID],
371
- ACCOUNT,
372
- { permanent: true },
373
- );
374
-
375
- // Reported, never silent: the client removed the row optimistically, and
376
- // one that reappears with nothing said is a failure the user cannot act
377
- // on — the same class as a button that does nothing (#1229).
378
- assert.deepEqual(refusedMessageIds, [MESSAGE_ID]);
379
- assert.deepEqual(world.writes, [], "no placement was written");
380
- assert.deepEqual(world.events, [], "and no expunge was enqueued");
381
- assert.equal(world.row.mailboxId, ARCHIVE);
382
- assert.equal(world.row.status, "active");
383
- });
384
- });
385
-
386
- describe("a classification filing writes only against the placement it read (R3)", () => {
387
- const buildPlacementService = (world: World, onPut?: () => void) => {
388
- const markers = new Map<string, unknown>();
389
- const pushed: string[] = [];
390
- const markerService = {
391
- find: async () => markers.get(MESSAGE_ID) ?? null,
392
- // The last step between reading the row and writing it, so this is
393
- // where a concurrent settle is staged.
394
- put: async (input: Record<string, unknown>) => {
395
- onPut?.();
396
- const marker = { ...input, state: "pending", createdAt: Date.now() };
397
- markers.set(MESSAGE_ID, marker);
398
- return marker;
399
- },
400
- updateState: async () => markers.get(MESSAGE_ID),
401
- delete: async () => {
402
- markers.delete(MESSAGE_ID);
403
- },
404
- } as unknown as IMessagePlacementMoveRepository;
405
-
406
- const service = new PlacementMoveService({
407
- messageService: world.messageService,
408
- threadMessageService: world.threadMessageService,
409
- markerService,
410
- addressService: world.addressService,
411
- mailboxSpecialUseService: world.mailboxSpecialUseService,
412
- sqsQueueUrl: "http://localhost:9324/000000000000/remit-message-mgmt",
413
- moveSettleTimeoutMs: 100,
414
- moveSettlePollMs: 10,
415
- });
416
- (service as unknown as { enqueuePush: () => Promise<void> }).enqueuePush =
417
- async () => {
418
- pushed.push(MESSAGE_ID);
419
- };
420
- return { service, markers, pushed };
421
- };
422
-
423
- it("files the message when nothing else touched the row", async () => {
424
- const world = buildWorld();
425
- const { service, markers, pushed } = buildPlacementService(world);
426
-
427
- await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
428
-
429
- assert.equal(world.row.mailboxId, JUNK);
430
- assert.deepEqual(
431
- world.threadMoves,
432
- [JUNK],
433
- "and the listing row follows the message it describes",
434
- );
435
- assert.deepEqual(pushed, [MESSAGE_ID]);
436
- assert.ok(markers.get(MESSAGE_ID));
437
- });
438
-
439
- // The Message row is written first, so a loser leaves nothing behind. Moving
440
- // the listing row ahead of the transition made the loser diverge instead: the
441
- // listing showed the classification folder while the Message row sat where
442
- // the winner put it, and nothing routine reconciles that.
443
- it("leaves the listing row alone too when the row settles elsewhere", async () => {
444
- const world = buildWorld();
445
- const { service, markers, pushed } = buildPlacementService(world, () =>
446
- world.settleElsewhere(),
447
- );
448
-
449
- await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
450
-
451
- assert.deepEqual(world.writes, [], "no placement was written");
452
- assert.deepEqual(
453
- world.threadMoves,
454
- [],
455
- "and the listing row never claimed a folder the message is not in",
456
- );
457
- assert.deepEqual(pushed, [], "and no push was enqueued");
458
- assert.equal(
459
- markers.get(MESSAGE_ID),
460
- undefined,
461
- "the marker goes with it: nothing owes IMAP this move",
462
- );
463
- assert.equal(world.row.mailboxId, ARCHIVE);
464
- });
465
-
466
- // A permanent delete has already claimed the row. Waiting the ceiling out and
467
- // then throwing drove an ordinary sequence toward the DLQ; there is nothing
468
- // left to file, so this skips.
469
- it("skips a message a delete has claimed, rather than burning the ceiling", async () => {
470
- const world = buildWorld();
471
- world.row.status = "deleting";
472
- const { service, markers, pushed } = buildPlacementService(world);
473
-
474
- const startedAt = Date.now();
475
- await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
476
-
477
- assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
478
- assert.deepEqual(world.writes, []);
479
- assert.deepEqual(pushed, []);
480
- assert.equal(markers.get(MESSAGE_ID), undefined);
481
- });
482
- });