@remit/mailbox-service 0.0.112 → 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 (270) 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} +16 -72
  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} +38 -64
  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} +13 -68
  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 -97
  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 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. package/tsconfig.json +0 -8
@@ -1,145 +0,0 @@
1
- import type { IMessageRepository, MessageItem } from "@remit/data-ports";
2
- import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
3
- import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
- import type { PlacementMoveLogger } from "./placement-move.js";
5
- import {
6
- type RestoreSourcePlacementDeps,
7
- restoreSourcePlacement,
8
- } from "./restore-source-placement.js";
9
- import type { IImapConnection } from "./types.js";
10
-
11
- export interface ResolveExhaustedPlacementMoveDeps {
12
- messageService: Pick<IMessageRepository, "deleteIfPlacement"> &
13
- RestoreSourcePlacementDeps["messageService"];
14
- threadMessageService: RestoreSourcePlacementDeps["threadMessageService"];
15
- markerService: { delete(messageId: string): Promise<void> };
16
- log: PlacementMoveLogger;
17
- }
18
-
19
- export interface ResolveExhaustedPlacementMoveInput {
20
- /** Where the row stood when the caller read it; the delete holds only while it still does. */
21
- placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
22
- accountId: string;
23
- accountConfigId: string;
24
- messageId: string;
25
- uid: number;
26
- sourceMailboxId: string;
27
- sourceMailboxPath: string;
28
- getConnection: () => Promise<IImapConnection>;
29
- }
30
-
31
- export type PlacementMoveTerminalOutcome = "reconciled" | "broken";
32
-
33
- export interface ResolveExhaustedPlacementMoveResult {
34
- outcome: PlacementMoveTerminalOutcome;
35
- }
36
-
37
- /**
38
- * Resolve a PLACEMENT_MOVE_PUSH failure that has exhausted the placement-move
39
- * queue's redelivery budget into exactly one of two terminal outcomes,
40
- * mirroring `resolveExhaustedBodySyncFailures` (#1270) for the same failure
41
- * taxonomy (epic #1281 invariant 3) — no third, softer outcome.
42
- *
43
- * 1. RECONCILED (expected) — the message no longer exists at its pending-move
44
- * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
45
- * than by a FETCH coming back empty. Per invariant 2, an external delete
46
- * supersedes the marker entirely: the marker is dropped and the stale
47
- * Message/ThreadMessage rows are deleted via
48
- * `deleteIfPlacement`. This is also the
49
- * outcome for the (rarer, functionally indistinguishable from here) case
50
- * where a foreign client moved the message elsewhere — either way, our
51
- * prediction no longer holds, and the marker cannot be honoured. Metric
52
- * only, no alarm — routine.
53
- * 2. BROKEN — the message still exists at the source, but the move keeps
54
- * failing. This indicates broken code or a broken account (issue #1271),
55
- * not a transient blip. The row is put back on the source pair the server
56
- * has just confirmed, exactly as the move and delete resolvers do, and the
57
- * marker goes with it: an auto-file whose push gave up must not leave
58
- * `moving` plus the destination folder plus the source's uid standing
59
- * forever, because that pair refuses every later delete and move with a
60
- * 409, blocks the sighting repair, and nothing else clears it. The failure
61
- * is an operator's to read, so it lives in the `alert`-shaped log entry and
62
- * the alarm on it — not in a row the user's own mail is stuck behind.
63
- * Never re-thrown (terminal — the caller acks either way, since retrying a
64
- * stale or permanently-broken move can never succeed).
65
- *
66
- * An operator reading `placement_move_failed` should know one case where the
67
- * message is not actually at the source: a message another client expunged
68
- * mid-session can answer an empty FETCH while the server still lists its UID
69
- * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
70
- * lands in BROKEN, and BROKEN is terminal — the alert stands until someone
71
- * clears it, and the row goes back to a source the message may already have
72
- * left. The reverse mistake deletes live mail, so the cost is paid
73
- * deliberately: a stale alert over a row the next sync re-points is
74
- * recoverable, a deleted message is not.
75
- */
76
- export const resolveExhaustedPlacementMoveFailure = async (
77
- deps: ResolveExhaustedPlacementMoveDeps,
78
- input: ResolveExhaustedPlacementMoveInput,
79
- ): Promise<ResolveExhaustedPlacementMoveResult> => {
80
- const {
81
- accountId,
82
- accountConfigId,
83
- messageId,
84
- uid,
85
- sourceMailboxId,
86
- sourceMailboxPath,
87
- getConnection,
88
- } = input;
89
-
90
- const connection = await getConnection();
91
- await connection.openBox(sourceMailboxPath);
92
-
93
- if (await isMessageGoneFromOpenMailbox(connection, uid)) {
94
- await deps.markerService.delete(messageId);
95
- const reconciled = await deps.messageService.deleteIfPlacement(
96
- messageId,
97
- input.placement,
98
- );
99
- deps.log.info(
100
- {
101
- metric: "placement_move_stale_row_reconciled",
102
- accountId,
103
- accountConfigId,
104
- messageId,
105
- uid,
106
- sourceMailboxPath,
107
- reconciled,
108
- },
109
- "Message no longer at its pending-move source after retry exhaustion (external delete or move); marker dropped, stale row reconciled",
110
- );
111
- return { outcome: "reconciled" };
112
- }
113
-
114
- // The row first, the marker second. The marker is the operator's evidence
115
- // that this move still owed IMAP a push; dropping it before the restore
116
- // means a restore that throws leaves the row stuck `moving` with nothing
117
- // left to say why.
118
- await restoreSourcePlacement(deps, {
119
- accountConfigId,
120
- messageId,
121
- sourceMailboxId,
122
- uid,
123
- // No give-up marker: this move is Remit's own classification filing, not
124
- // something the user asked for, so a per-message "your move failed" would
125
- // report a failure against an intent nobody formed. The alert below is the
126
- // whole surface, and it is an operator's.
127
- syncStatus: MessageSyncStatus.synced,
128
- abandonedMutation: MessageMutation.none,
129
- });
130
- await deps.markerService.delete(messageId);
131
-
132
- deps.log.error(
133
- {
134
- alert: "placement_move_failed",
135
- accountId,
136
- accountConfigId,
137
- messageId,
138
- uid,
139
- sourceMailboxId,
140
- sourceMailboxPath,
141
- },
142
- "Placement move could not be pushed to IMAP after retry exhaustion; message still exists at its source — row restored to the source pair, marker dropped, alert left for operator investigation",
143
- );
144
- return { outcome: "broken" };
145
- };
@@ -1,363 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
- import type {
4
- IAddressRepository,
5
- IMailboxRepository,
6
- IMailboxSpecialUseRepository,
7
- IMessagePlacementMoveRepository,
8
- IMessageRepository,
9
- IThreadMessageRepository,
10
- } from "@remit/data-ports";
11
- import {
12
- MessageMutation,
13
- MessageStatus,
14
- MessageSyncStatus,
15
- } from "@remit/domain-enums";
16
- import { createQueueProducer } from "@remit/sqs-client/producer";
17
- import {
18
- isPlacementUnsettled,
19
- waitForPlacementToSettle,
20
- } from "./placement-settled.js";
21
-
22
- /**
23
- * Event the reconciler (imap-worker `handlePlacementMovePush`) drains. Carries
24
- * ONLY our own message id — never a UID (epic #1281 invariant 1). The UID is
25
- * resolved fresh from the Message row at push time, so the event stays valid
26
- * across any amount of queue delay or an unrelated UIDVALIDITY rebuild
27
- * (#1272).
28
- */
29
- export interface PlacementMovePushEvent {
30
- type: "PLACEMENT_MOVE_PUSH";
31
- eventId: string;
32
- timestamp: number;
33
- accountId: string;
34
- accountConfigId: string;
35
- messageId: string;
36
- }
37
-
38
- /** Whether a filing happened. `refused` names a destination outside the message's account. */
39
- export type PlacementMoveOutcome = "filed" | "skipped" | "refused";
40
-
41
- export interface PlacementMoveLogger {
42
- info(obj: Record<string, unknown>, msg: string): void;
43
- error(obj: Record<string, unknown>, msg: string): void;
44
- }
45
-
46
- const noopLogger: PlacementMoveLogger = {
47
- info: () => {},
48
- error: () => {},
49
- };
50
-
51
- export interface PlacementMoveConfig {
52
- messageService: IMessageRepository;
53
- threadMessageService: IThreadMessageRepository;
54
- markerService: IMessagePlacementMoveRepository;
55
- addressService: IAddressRepository;
56
- /**
57
- * Required, not optional: this mover carries automatic spam placement, and
58
- * one built without the folder-role seam would silently leave the sender of
59
- * every auto-filed spam suggestible in the compose picker.
60
- */
61
- mailboxSpecialUseService: IMailboxSpecialUseRepository;
62
- mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
63
- sqsQueueUrl: string;
64
- sqsEndpoint?: string;
65
- logger?: PlacementMoveLogger;
66
- moveSettleTimeoutMs?: number;
67
- moveSettlePollMs?: number;
68
- }
69
-
70
- const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
71
- const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
72
-
73
- /**
74
- * Local-first mover for a classification-driven placement move (issue #1271,
75
- * epic #1281). Distinct from {@link MessageMoveService} (user-initiated
76
- * moves/deletes/copies), which is untouched by this fix — the epic's UID
77
- * staleness invariant (1) only applies to the NEW reconciler this class
78
- * feeds; generalizing it to every move type is out of scope here.
79
- *
80
- * `moveMessage` matches {@link MessageMoveService.moveMessage}'s signature so
81
- * `BodySyncService`'s existing rescue/demote call site and its tests need no
82
- * shape changes beyond the dependency's name.
83
- *
84
- * Ordering is the fix: every step below (marker, ThreadMessage, Message row,
85
- * SQS enqueue) completes — or the whole call rejects — BEFORE the caller
86
- * (`BodySyncService.storeStreamedBody`) writes `bodyStorageKey`. A rejection
87
- * here therefore always happens before the body-sync skip-guard can trip, so
88
- * a retry reprocesses the message from scratch. Nothing here is swallowed —
89
- * let it crash, same as any other infra failure (an SQS enqueue failure is a
90
- * serious operational failure, not a routine blip: it propagates so a
91
- * repeated failure reaches the alarmed message-mgmt DLQ, never absorbed).
92
- *
93
- * Recovery is driven by the marker's explicit state engine (`pending ->
94
- * queued -> processing -> processed`, `MessagePlacementMoveState`), not by
95
- * comparing the message's current location against the requested
96
- * destination — that comparison alone cannot tell "already confirmed" apart
97
- * from "local move landed but the enqueue never did" (the defect this issue
98
- * originally fixes). A retry always re-fetches the marker and drives it
99
- * forward from whichever state it is actually in.
100
- */
101
- export class PlacementMoveService {
102
- private messageService: IMessageRepository;
103
- private threadMessageService: IThreadMessageRepository;
104
- private markerService: IMessagePlacementMoveRepository;
105
- private addressService: IAddressRepository;
106
- private mailboxSpecialUseService: IMailboxSpecialUseRepository;
107
- private mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
108
- private sqs: SQSClient;
109
- private queueUrl: string;
110
- private log: PlacementMoveLogger;
111
- private moveSettleTimeoutMs: number;
112
- private moveSettlePollMs: number;
113
-
114
- constructor(config: PlacementMoveConfig) {
115
- this.messageService = config.messageService;
116
- this.threadMessageService = config.threadMessageService;
117
- this.markerService = config.markerService;
118
- this.addressService = config.addressService;
119
- this.mailboxSpecialUseService = config.mailboxSpecialUseService;
120
- this.mailboxService = config.mailboxService;
121
- this.queueUrl = config.sqsQueueUrl;
122
- this.log = config.logger ?? noopLogger;
123
- this.moveSettleTimeoutMs =
124
- config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
125
- this.moveSettlePollMs =
126
- config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
127
- this.sqs = createQueueProducer({
128
- queueUrl: config.sqsQueueUrl,
129
- endpoint: config.sqsEndpoint,
130
- });
131
- }
132
-
133
- moveMessage = async (
134
- accountConfigId: string,
135
- messageId: string,
136
- destinationMailboxId: string,
137
- accountId: string,
138
- ): Promise<PlacementMoveOutcome> => {
139
- let message = await this.messageService.get(messageId);
140
-
141
- // Recovery: a marker already exists for THIS message and destination —
142
- // from an earlier (possibly partially-failed) call. The marker's STATE,
143
- // not a comparison against the message's current location, decides what
144
- // happens next (PR #1289 review finding 1 + design amendment: an
145
- // explicit pending -> queued -> processing -> processed engine).
146
- const existingMarker = await this.markerService.find(messageId);
147
- if (
148
- existingMarker &&
149
- existingMarker.destinationMailboxId === destinationMailboxId
150
- ) {
151
- if (existingMarker.state === "pending") {
152
- // Local move + marker already committed; only the enqueue is
153
- // missing (or a further retry after the enqueue itself failed
154
- // again). Drive the SAME marker forward — never re-derive the
155
- // local move, which already happened.
156
- await this.enqueuePush({ accountId, accountConfigId, messageId });
157
- await this.markerService.updateState(messageId, "queued");
158
- this.log.info(
159
- { messageId, accountId, destinationMailboxId },
160
- "Pending marker survived an earlier enqueue failure; re-enqueued and advanced to queued",
161
- );
162
- }
163
- // queued / processing / processed already has its own driver in
164
- // flight (the SQS message, or the reconciler currently running) —
165
- // nothing to do here either way.
166
- return "filed";
167
- }
168
-
169
- // A different destination, decided while an earlier move is still
170
- // unconfirmed (issue #496): the row names that move's destination but its
171
- // `uid` still belongs to the folder before it, so a marker bound to the
172
- // pair as it stands sends the reconciler to move the destination folder's
173
- // OWN message at that uid. Wait for the earlier move to confirm and bind
174
- // to the settled row (docs/architecture/imap-mutations.md R2). Blocking is
175
- // cheap — a move settles in well under a second — and on timeout the
176
- // dependent write is simply not made.
177
- // A delete in flight is not a move to wait out: the user has asked for the
178
- // message to go, and a classification filing behind it has nothing left to
179
- // file. Skipping is the answer, and it is a clean one — throwing would
180
- // burn the ceiling and then drive an ordinary sequence toward the DLQ.
181
- if (message.status === MessageStatus.deleting) {
182
- this.log.info(
183
- { messageId, accountId, destinationMailboxId },
184
- "Placement move not applied: this message is being deleted",
185
- );
186
- return "skipped";
187
- }
188
-
189
- if (isPlacementUnsettled(message)) {
190
- message = await waitForPlacementToSettle(this.messageService, messageId, {
191
- timeoutMs: this.moveSettleTimeoutMs,
192
- pollMs: this.moveSettlePollMs,
193
- });
194
- if (message.status === MessageStatus.deleting) {
195
- this.log.info(
196
- { messageId, accountId, destinationMailboxId },
197
- "Placement move not applied: a delete claimed this message while the earlier move settled",
198
- );
199
- return "skipped";
200
- }
201
- if (isPlacementUnsettled(message)) {
202
- throw new Error(
203
- `Placement move for ${messageId} to ${destinationMailboxId} not applied: an earlier move has not settled`,
204
- );
205
- }
206
- }
207
-
208
- const sourceMailboxId = message.mailboxId;
209
-
210
- // Genuine no-op: nothing pending for this destination, and the message
211
- // is already there (a duplicate verdict recomputed after a confirmed
212
- // move).
213
- if (sourceMailboxId === destinationMailboxId) return "skipped";
214
-
215
- // IMAP has no MOVE between accounts, so a filing into a folder this
216
- // account does not own would only ever be handed back.
217
- if (
218
- (await this.mailboxService.resolveAccountId(destinationMailboxId)) !==
219
- accountId
220
- ) {
221
- this.log.error(
222
- {
223
- alert: "placement_move_foreign_folder",
224
- messageId,
225
- accountId,
226
- sourceMailboxId,
227
- destinationMailboxId,
228
- },
229
- "Placement move refused: the destination is not a folder of this message's account",
230
- );
231
- return "refused";
232
- }
233
-
234
- // Fresh move. Strictly sequential — nothing here is parallel: the local
235
- // move (marker + ThreadMessage + Message row) fully commits BEFORE the
236
- // queue kick runs. `put` is idempotent and always resets `state` back to
237
- // `pending`, since a fresh decision starts a new lifecycle regardless of
238
- // what a stale row (different destination) held.
239
- await this.markerService.put({
240
- messageId,
241
- accountId,
242
- accountConfigId,
243
- sourceMailboxId,
244
- destinationMailboxId,
245
- });
246
-
247
- // The Message row first, the listing row after it — the order the user
248
- // move and the delete already use. A transition off the row the wait above
249
- // settled on (imap-mutations R3): this lane rides a standard queue that
250
- // the account's FIFO group does not order, so a user's own move or delete
251
- // can claim the row between that read and this write, and writing anyway
252
- // would stamp Remit's classification over a folder the user just chose,
253
- // with the old uid attached.
254
- //
255
- // Writing the listing row first meant a loser left the two disagreeing —
256
- // the listing showing the classification folder while the Message row sat
257
- // where the winner put it — and nothing routine reconciles that.
258
- //
259
- // The marker goes with the loss. It is the record that a move owes IMAP a
260
- // push, and this move is not happening, so leaving it would send the
261
- // reconciler after a placement nobody wrote and would inflate the folder
262
- // counts it predicts.
263
- const filed = await this.messageService.transitionPlacement(
264
- messageId,
265
- {
266
- status: MessageStatus.active,
267
- mailboxId: sourceMailboxId,
268
- uid: message.uid,
269
- },
270
- {
271
- mailboxId: destinationMailboxId,
272
- status: MessageStatus.moving,
273
- syncStatus: MessageSyncStatus.pending,
274
- abandonedMutation: MessageMutation.none,
275
- originalMailboxId: sourceMailboxId,
276
- originalUid: message.uid,
277
- },
278
- );
279
- if (!filed) {
280
- await this.markerService.delete(messageId);
281
- this.log.info(
282
- { messageId, accountId, sourceMailboxId, destinationMailboxId },
283
- "Placement move not applied: something else claimed this message's placement first",
284
- );
285
- return "skipped";
286
- }
287
-
288
- await this.updateThreadMessageMailbox(
289
- accountConfigId,
290
- messageId,
291
- destinationMailboxId,
292
- );
293
-
294
- await this.addressService.reconcileJunkOnlyForMessage(
295
- messageId,
296
- await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
297
- accountConfigId,
298
- ),
299
- );
300
-
301
- // The queue kick is a serious operational step, not a routine one — a
302
- // failure here MUST propagate (never swallowed): the marker stays
303
- // `pending`, the surrounding body-sync call fails, SQS redelivers, and
304
- // a repeated failure reaches the alarmed message-mgmt DLQ. The state
305
- // engine makes this *recoverable* (the recovery branch above drives a
306
- // surviving `pending` marker forward on the next call) — it must never
307
- // make it *quiet*.
308
- await this.enqueuePush({ accountId, accountConfigId, messageId });
309
- await this.markerService.updateState(messageId, "queued");
310
-
311
- this.log.info(
312
- { messageId, accountId, sourceMailboxId, destinationMailboxId },
313
- "Placement move applied locally; pending marker queued for IMAP push",
314
- );
315
- return "filed";
316
- };
317
-
318
- private updateThreadMessageMailbox = async (
319
- accountConfigId: string,
320
- messageId: string,
321
- destinationMailboxId: string,
322
- ): Promise<void> => {
323
- const threadMessage = await this.threadMessageService.getByMessageId(
324
- accountConfigId,
325
- messageId,
326
- );
327
-
328
- await this.threadMessageService.update(
329
- threadMessage.accountConfigId,
330
- threadMessage.threadMessageId,
331
- { mailboxId: destinationMailboxId },
332
- {
333
- composites: {
334
- sentDate: threadMessage.sentDate,
335
- mailboxId: threadMessage.mailboxId,
336
- isRead: threadMessage.isRead,
337
- isDeleted: threadMessage.isDeleted,
338
- hasStars: threadMessage.hasStars,
339
- hasAttachment: threadMessage.hasAttachment,
340
- },
341
- },
342
- );
343
- };
344
-
345
- private enqueuePush = async (params: {
346
- accountId: string;
347
- accountConfigId: string;
348
- messageId: string;
349
- }): Promise<void> => {
350
- const event: PlacementMovePushEvent = {
351
- type: "PLACEMENT_MOVE_PUSH",
352
- eventId: randomUUID(),
353
- timestamp: Date.now(),
354
- ...params,
355
- };
356
- await this.sqs.send(
357
- new SendMessageCommand({
358
- QueueUrl: this.queueUrl,
359
- MessageBody: JSON.stringify(event),
360
- }),
361
- );
362
- };
363
- }
@@ -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
- });