@remit/imap-worker 0.0.90 → 0.0.91

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 (172) hide show
  1. package/dist/account-check.d.ts +27 -0
  2. package/dist/account-check.js +70 -0
  3. package/dist/body-sync-gate.d.ts +12 -0
  4. package/dist/body-sync-gate.js +53 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/config-env.d.ts +5 -0
  8. package/dist/config-env.js +37 -0
  9. package/dist/connection-pool.d.ts +22 -0
  10. package/dist/connection-pool.js +97 -0
  11. package/dist/connection-scope.d.ts +8 -0
  12. package/dist/connection-scope.js +10 -0
  13. package/dist/context.d.ts +46 -0
  14. package/dist/context.js +15 -0
  15. package/dist/e2e-processor-shim.d.ts +2 -0
  16. package/dist/e2e-processor-shim.js +181 -0
  17. package/dist/emit.d.ts +15 -0
  18. package/dist/emit.js +76 -0
  19. package/dist/events.d.ts +203 -0
  20. package/dist/events.js +1 -0
  21. package/dist/failure-kind.d.ts +10 -0
  22. package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
  23. package/dist/handlers/append-sent-message.d.ts +3 -0
  24. package/dist/handlers/append-sent-message.js +152 -0
  25. package/dist/handlers/delete-account-objects.d.ts +3 -0
  26. package/dist/handlers/delete-account-objects.js +35 -0
  27. package/dist/handlers/empty-trash.d.ts +14 -0
  28. package/dist/handlers/empty-trash.js +217 -0
  29. package/dist/handlers/flag-push.d.ts +38 -0
  30. package/dist/handlers/flag-push.js +195 -0
  31. package/dist/handlers/imported-folder.d.ts +4 -0
  32. package/dist/handlers/imported-folder.js +23 -0
  33. package/dist/handlers/mailbox-management.d.ts +6 -0
  34. package/dist/handlers/mailbox-management.js +294 -0
  35. package/dist/handlers/mailbox-sync-order.d.ts +21 -0
  36. package/dist/handlers/mailbox-sync-order.js +50 -0
  37. package/dist/handlers/message-copy.d.ts +40 -0
  38. package/dist/handlers/message-copy.js +371 -0
  39. package/dist/handlers/message-delete-terminal.d.ts +60 -0
  40. package/dist/handlers/message-delete-terminal.js +77 -0
  41. package/dist/handlers/message-delete.d.ts +57 -0
  42. package/dist/handlers/message-delete.js +520 -0
  43. package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
  44. package/dist/handlers/message-move-terminal.js +94 -0
  45. package/dist/handlers/message-move.d.ts +146 -0
  46. package/dist/handlers/message-move.js +490 -0
  47. package/dist/handlers/placement-move-push.d.ts +62 -0
  48. package/dist/handlers/placement-move-push.js +307 -0
  49. package/dist/handlers/probe-message-id.d.ts +2 -0
  50. package/dist/handlers/probe-message-id.js +6 -0
  51. package/dist/handlers/sync-mailboxes.d.ts +45 -0
  52. package/dist/handlers/sync-mailboxes.js +233 -0
  53. package/dist/handlers/sync-message-body.d.ts +23 -0
  54. package/dist/handlers/sync-message-body.js +182 -0
  55. package/dist/handlers/sync-messages.d.ts +29 -0
  56. package/dist/handlers/sync-messages.js +285 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.js +67 -0
  59. package/dist/lambda-context.d.ts +8 -0
  60. package/dist/lambda-context.js +33 -0
  61. package/dist/mailbox-row.d.ts +9 -0
  62. package/dist/mailbox-row.js +6 -0
  63. package/dist/poller.d.ts +1 -0
  64. package/dist/poller.js +51 -0
  65. package/dist/process.d.ts +6 -0
  66. package/dist/process.js +3 -0
  67. package/dist/processor.d.ts +3 -0
  68. package/dist/processor.js +44 -0
  69. package/dist/queues.d.ts +10 -0
  70. package/dist/queues.js +1 -0
  71. package/dist/s3-account-objects.d.ts +3 -0
  72. package/dist/s3-account-objects.js +26 -0
  73. package/dist/scheduler/attachment-sweep.d.ts +11 -0
  74. package/dist/scheduler/attachment-sweep.js +23 -0
  75. package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +27 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
  79. package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
  80. package/dist/scheduler/decide-due.js +17 -0
  81. package/dist/scheduler/handler.d.ts +21 -0
  82. package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +25 -42
  83. package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
  84. package/dist/scheduler/loop.js +8 -0
  85. package/dist/scheduler/run-tick.d.ts +62 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +57 -78
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/sqs-queues.d.ts +4 -0
  92. package/dist/sqs-queues.js +22 -0
  93. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  94. package/dist/with-oauth-lifecycle-deps.js +17 -0
  95. package/dist/with-oauth-lifecycle.d.ts +56 -0
  96. package/dist/with-oauth-lifecycle.js +92 -0
  97. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  98. package/dist/worker-version.js +12 -0
  99. package/package.json +75 -69
  100. package/src/account-check.test.ts +0 -149
  101. package/src/account-check.ts +0 -101
  102. package/src/body-sync-gate.test.ts +0 -185
  103. package/src/body-sync-gate.ts +0 -86
  104. package/src/cli.ts +0 -218
  105. package/src/connection-scope.test.ts +0 -266
  106. package/src/connection-scope.ts +0 -314
  107. package/src/credential-less-sync-fence.test.ts +0 -152
  108. package/src/e2e-processor-shim.ts +0 -232
  109. package/src/emit.test.ts +0 -145
  110. package/src/emit.ts +0 -115
  111. package/src/events.ts +0 -252
  112. package/src/failure-kind.test.ts +0 -42
  113. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  114. package/src/handlers/append-sent-message.ts +0 -264
  115. package/src/handlers/delete-account-objects.test.ts +0 -126
  116. package/src/handlers/delete-account-objects.ts +0 -96
  117. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  118. package/src/handlers/empty-trash.ts +0 -352
  119. package/src/handlers/flag-push.ts +0 -285
  120. package/src/handlers/imported-folder.test.ts +0 -63
  121. package/src/handlers/imported-folder.ts +0 -38
  122. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  123. package/src/handlers/mailbox-management.ts +0 -486
  124. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  125. package/src/handlers/mailbox-sync-order.ts +0 -65
  126. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  127. package/src/handlers/message-copy.ts +0 -569
  128. package/src/handlers/message-delete-terminal.ts +0 -147
  129. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  130. package/src/handlers/message-delete.ts +0 -888
  131. package/src/handlers/message-move.sqlite.test.ts +0 -271
  132. package/src/handlers/message-move.test.ts +0 -216
  133. package/src/handlers/message-move.ts +0 -755
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -478
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  137. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  138. package/src/handlers/probe-message-id.ts +0 -15
  139. package/src/handlers/sync-mailboxes.test.ts +0 -183
  140. package/src/handlers/sync-mailboxes.ts +0 -425
  141. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  142. package/src/handlers/sync-message-body.test.ts +0 -150
  143. package/src/handlers/sync-message-body.ts +0 -366
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  145. package/src/handlers/sync-messages.ts +0 -537
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -82
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -165
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -62
  153. package/src/queues.ts +0 -40
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  155. package/src/scheduler/attachment-sweep.ts +0 -46
  156. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  157. package/src/scheduler/calendar-subscriptions.ts +0 -103
  158. package/src/scheduler/config.test.ts +0 -40
  159. package/src/scheduler/decide-due.test.ts +0 -44
  160. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  161. package/src/scheduler/loop.test.ts +0 -143
  162. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  163. package/src/scheduler/run-tick.ts +0 -281
  164. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  165. package/src/stranded-placement.sqlite.test.ts +0 -799
  166. package/src/test-helpers/test-world.ts +0 -425
  167. package/src/test-helpers/worker-queues.ts +0 -11
  168. package/src/thread-join.sqlite.test.ts +0 -106
  169. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  170. package/src/with-oauth-lifecycle-deps.ts +0 -71
  171. package/src/with-oauth-lifecycle.ts +0 -158
  172. package/tsconfig.json +0 -8
@@ -0,0 +1,307 @@
1
+ import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
2
+ import { guardConnectionCursor, isCursorRebuildNeeded, isMessageGoneFromOpenMailbox, MailboxCursorPausedError, resolveExhaustedPlacementMoveFailure, restoreSourcePlacement, } from "@remit/mailbox-service";
3
+ import { isAccountDeleted } from "../account-check.js";
4
+ import { emitFrom } from "../emit.js";
5
+ import { findMailboxRow } from "../mailbox-row.js";
6
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
7
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
8
+ import { buildThreadMessageMoveUpdate, emitMailboxResync, emitMoveResync, searchMailboxForHighestMessageIdUid, } from "./message-move.js";
9
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
10
+ /**
11
+ * Attempt the IMAP MOVE.
12
+ *
13
+ * Takes two connection handles rather than one: `sourceConnection` and
14
+ * `destinationConnection` wrap the SAME underlying connection but are each
15
+ * cursor-guarded (#1272) against their OWN mailbox — a `guardConnectionCursor`
16
+ * wrap binds `openBox` checks to whichever ONE mailbox snapshot it was built
17
+ * with, so verifying at the destination must never run through the source's
18
+ * guard (it would compare the destination's served UIDVALIDITY against the
19
+ * source's stored one and misfire).
20
+ *
21
+ * - `moved` with a `newUid` — confirmed, either via COPYUID or (see below)
22
+ * an explicit destination verification.
23
+ * - `trycreate` — the destination mailbox doesn't exist yet; the caller
24
+ * creates it and retries.
25
+ * - `not-found` — confirmed absent from BOTH source and destination. Only
26
+ * this outcome supersedes the pending-move marker (drops it, reconciles
27
+ * the row as an external delete).
28
+ * - Any other case throws (propagates for SQS retry, or as
29
+ * `MailboxCursorPausedError` for the caller's cursor-pause handling) —
30
+ * including an UNCONFIRMED move (no COPYUID, or an explicit "not
31
+ * found"/NONEXISTENT from the server) whose message is still sitting at
32
+ * the source. A MOVE that resolves without a uidMap entry on a
33
+ * non-UIDPLUS server is a plausible SUCCESS, not evidence the message is
34
+ * gone (PR #1289 review finding 2) — deleting the local row on that
35
+ * ambiguity would be data loss. `not-found` is only returned once BOTH a
36
+ * destination Message-ID search (when the header is known) misses AND
37
+ * {@link isMessageGoneFromOpenMailbox} confirms the message absent from the
38
+ * source — a FETCH returning no row does not, on its own, confirm anything.
39
+ */
40
+ export const attemptMove = async (sourceConnection, destinationConnection, sourceMailboxPath, destinationMailboxPath, uid, messageIdHeader) => {
41
+ await sourceConnection.openBox(sourceMailboxPath, false);
42
+ const resolved = await sourceConnection
43
+ .moveMessages([uid], destinationMailboxPath)
44
+ .then((result) => ({ ok: true, newUid: result.uidMap.get(uid) }))
45
+ .catch((error) => {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ if (message.includes("TRYCREATE")) {
48
+ return { ok: false, trycreate: true };
49
+ }
50
+ if (message.includes("not found") || message.includes("NONEXISTENT")) {
51
+ return { ok: false, trycreate: false };
52
+ }
53
+ throw error;
54
+ });
55
+ if (resolved.ok && resolved.newUid) {
56
+ return { kind: "moved", newUid: resolved.newUid };
57
+ }
58
+ if (!resolved.ok && resolved.trycreate) {
59
+ return { kind: "trycreate" };
60
+ }
61
+ // Unconfirmed: either no COPYUID entry, or the server explicitly claimed
62
+ // "not found" — never trust either without independent verification. A
63
+ // message this MOVE delivered is the newest copy of that Message-ID at the
64
+ // destination, so the probe binds to the highest matching uid (#1122).
65
+ // Unlike `confirmTrashMoveUid`, this probe is ungated: where the MOVE never
66
+ // ran and an older copy filed there earlier is the only match, the marker
67
+ // binds to that unrelated mail. Pre-existing, and not narrowed by taking the
68
+ // highest — the source is not asked first here, so nothing rules the case
69
+ // out.
70
+ if (messageIdHeader) {
71
+ const destinationUid = await searchMailboxForHighestMessageIdUid(destinationConnection, destinationMailboxPath, messageIdHeader);
72
+ if (destinationUid)
73
+ return { kind: "moved", newUid: destinationUid };
74
+ }
75
+ await sourceConnection.openBox(sourceMailboxPath, true);
76
+ if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
77
+ throw new Error("Placement move unresolved (unconfirmed at destination, still present at source) — retrying");
78
+ }
79
+ return { kind: "not-found" };
80
+ };
81
+ /**
82
+ * Handle PLACEMENT_MOVE_PUSH events (issue #1271, epic #1281). Drains ONE
83
+ * pending placement-move marker: resolves the UID fresh from the Message row
84
+ * (never trusts a captured value — invariant 1), pushes the IMAP MOVE, and
85
+ * clears the marker ONLY on confirmed success. Every precedence rule (epic
86
+ * invariant 2) is enforced here:
87
+ * - Pending Remit move wins on location: nothing in this handler ever
88
+ * corrects `message.mailboxId` back toward the source — a resync elsewhere
89
+ * (message-sync.ts) never overwrites an existing row's `mailboxId` either
90
+ * (create-only-if-not-exists semantics), so the local move is never undone.
91
+ * - A NEWER local action superseding this marker (drop without pushing) —
92
+ * see the `message.mailboxId !== marker.destinationMailboxId` check below.
93
+ * - An external delete supersedes the marker entirely — the `not-found`
94
+ * outcome drops the marker and reconciles the stale row.
95
+ *
96
+ * Cursor-guarded (#1272, epic #1281 invariant 5): both the source and
97
+ * destination mailbox are checked for a non-`normal` `cursorState` before
98
+ * connecting, and the connection is wrapped per-mailbox via
99
+ * `guardConnectionCursor` so no stored UID touches the server while either
100
+ * mailbox's axis is being rebuilt. A trip pauses the push (routine, no
101
+ * alarm) — the marker stays durable and pushes again on the next event.
102
+ */
103
+ export const handlePlacementMovePush = async (event, ctx, receiveCount) => {
104
+ const { log, pool, metrics, config } = ctx;
105
+ const emit = emitFrom(ctx);
106
+ const { account: accountService, mailbox: mailboxService, message: messageService, threadMessage: threadMessageService, placementMove: markerService, secrets, } = ctx.client;
107
+ const { accountId, accountConfigId, messageId } = event;
108
+ const marker = await markerService.find(messageId);
109
+ if (!marker) {
110
+ log.info({ messageId, accountId }, "No pending placement-move marker (already confirmed or superseded); nothing to push");
111
+ return;
112
+ }
113
+ const account = await accountService.get(accountId);
114
+ if (!account) {
115
+ throw new Error(`Account ${accountId} not found`);
116
+ }
117
+ if (isAccountDeleted(account, log)) {
118
+ return;
119
+ }
120
+ const message = await messageService.get(messageId);
121
+ const placementRead = {
122
+ status: message.status,
123
+ mailboxId: message.mailboxId,
124
+ uid: message.uid,
125
+ };
126
+ // A newer local action (drag-and-drop move, auto-moved Undo) already
127
+ // changed the message's location away from what this marker still
128
+ // promises — that later intent wins locally. Drop the now-stale marker
129
+ // without touching IMAP.
130
+ if (message.mailboxId !== marker.destinationMailboxId) {
131
+ await markerService.delete(messageId);
132
+ log.info({
133
+ messageId,
134
+ accountId,
135
+ markerDestination: marker.destinationMailboxId,
136
+ currentMailboxId: message.mailboxId,
137
+ }, "Placement move superseded by a newer local action; marker dropped without pushing");
138
+ return;
139
+ }
140
+ // The worker has picked up the event and is about to actually attempt the
141
+ // IMAP MOVE — advance the state engine (pending/queued -> processing).
142
+ // Idempotent to call again on a redelivered event (a prior attempt that
143
+ // died mid-flight already left it here).
144
+ await markerService.updateState(messageId, "processing");
145
+ // No MOVE has left for this marker, so the row is settled, not just unmarked.
146
+ const [source, destination] = await Promise.all([
147
+ findMailboxRow(mailboxService, accountId, marker.sourceMailboxId),
148
+ findMailboxRow(mailboxService, accountId, marker.destinationMailboxId),
149
+ ]);
150
+ if (source.kind === "gone" || destination.kind === "gone") {
151
+ const context = {
152
+ alert: "placement_move_mailbox_unreachable",
153
+ messageId,
154
+ accountId,
155
+ sourceMailboxId: marker.sourceMailboxId,
156
+ destinationMailboxId: marker.destinationMailboxId,
157
+ };
158
+ if (source.kind === "gone") {
159
+ const reconciled = await messageService.deleteIfPlacement(messageId, placementRead);
160
+ await markerService.delete(messageId);
161
+ log.error({ ...context, reconciled }, "Placement move not pushed: its source folder is gone; the stale row was reconciled");
162
+ if (destination.kind === "present") {
163
+ await emitMailboxResync(emit, {
164
+ accountId,
165
+ mailboxId: marker.destinationMailboxId,
166
+ });
167
+ }
168
+ return;
169
+ }
170
+ await restoreSourcePlacement({ messageService, threadMessageService }, {
171
+ accountConfigId,
172
+ messageId,
173
+ sourceMailboxId: marker.sourceMailboxId,
174
+ uid: message.uid,
175
+ syncStatus: MessageSyncStatus.synced,
176
+ abandonedMutation: MessageMutation.none,
177
+ });
178
+ await markerService.delete(messageId);
179
+ log.error(context, "Placement move not pushed: its destination folder is gone; the row was handed back to its source");
180
+ await emitMailboxResync(emit, {
181
+ accountId,
182
+ mailboxId: marker.sourceMailboxId,
183
+ });
184
+ return;
185
+ }
186
+ const sourceMailbox = source.mailbox;
187
+ const destinationMailbox = destination.mailbox;
188
+ // Cheap frugal skip (epic #1281 invariant 6): either mailbox already known
189
+ // paused never even borrows a connection. Optimization only — the
190
+ // guardConnectionCursor wraps below are the structural guarantee (#1272).
191
+ if (isCursorRebuildNeeded(sourceMailbox.cursorState) ||
192
+ isCursorRebuildNeeded(destinationMailbox.cursorState)) {
193
+ log.info({
194
+ messageId,
195
+ accountId,
196
+ sourceCursorState: sourceMailbox.cursorState,
197
+ destinationCursorState: destinationMailbox.cursorState,
198
+ }, "Mailbox cursor not normal; pausing outbound placement-move push this round");
199
+ return;
200
+ }
201
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
202
+ const scope = pool.borrow(account, credentials);
203
+ await scope
204
+ .getConnection()
205
+ .then(async (rawConnection) => {
206
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5).
207
+ // Two independently-scoped wraps around the SAME connection — see
208
+ // the attemptMove doc comment for why one guard per mailbox matters.
209
+ const sourceConnection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, sourceMailbox);
210
+ const destinationConnection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, destinationMailbox);
211
+ const outcome = await attemptMove(sourceConnection, destinationConnection, sourceMailbox.fullPath, destinationMailbox.fullPath, message.uid, await probeableMessageIdHeader(threadMessageService, accountConfigId, messageId));
212
+ if (outcome.kind === "trycreate") {
213
+ await destinationConnection.createMailbox(destinationMailbox.fullPath);
214
+ throw new Error(`Destination mailbox ${destinationMailbox.fullPath} did not exist; created, retrying`);
215
+ }
216
+ if (outcome.kind === "not-found") {
217
+ // External delete supersedes the marker entirely (epic invariant 2).
218
+ await markerService.delete(messageId);
219
+ const reconciled = await messageService.deleteIfPlacement(messageId, placementRead);
220
+ log.info({
221
+ messageId,
222
+ accountId,
223
+ uid: message.uid,
224
+ sourceMailboxPath: sourceMailbox.fullPath,
225
+ reconciled,
226
+ }, "Message no longer at its pending-move source (external delete or moved away); marker dropped, stale row reconciled");
227
+ await emitMoveResync(emit, {
228
+ accountId,
229
+ sourceMailboxId: marker.sourceMailboxId,
230
+ destinationMailboxId: marker.destinationMailboxId,
231
+ });
232
+ return;
233
+ }
234
+ const newUid = outcome.newUid;
235
+ await messageService.updateUid(messageId, newUid, marker.destinationMailboxId);
236
+ const threadMessage = await threadMessageService.findByMessageId(accountConfigId, messageId);
237
+ if (threadMessage) {
238
+ const args = buildThreadMessageMoveUpdate(threadMessage, newUid, marker.destinationMailboxId);
239
+ await threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, args.set, { composites: args.composites });
240
+ }
241
+ // Confirmed IMAP acknowledgement — the "processed" terminal state.
242
+ // Deleted immediately rather than persisted first (epic invariant
243
+ // 4: no lingering row once the move is no longer pending) — clears
244
+ // ONLY here, never on attempt (the defect issue #1271 fixes).
245
+ await markerService.delete(messageId);
246
+ log.info({
247
+ messageId,
248
+ accountId,
249
+ oldUid: message.uid,
250
+ newUid,
251
+ destination: destinationMailbox.fullPath,
252
+ }, "Placement move confirmed on IMAP; marker cleared");
253
+ await emitMoveResync(emit, {
254
+ accountId,
255
+ sourceMailboxId: marker.sourceMailboxId,
256
+ destinationMailboxId: marker.destinationMailboxId,
257
+ });
258
+ })
259
+ .catch(async (error) => {
260
+ // Expected pause (epic #1281 invariant 3), not a fault: ack and
261
+ // skip rather than propagating into SQS retry/DLQ. The marker
262
+ // stays durable; the push resumes once the mailbox returns to
263
+ // normal (driven by the next PLACEMENT_MOVE_PUSH for this message,
264
+ // or the surviving-marker re-enqueue in PlacementMoveService).
265
+ if (error instanceof MailboxCursorPausedError) {
266
+ log.info({ messageId, accountId, cursorState: error.state }, "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound placement-move push");
267
+ return;
268
+ }
269
+ if (receiveCount < config.attempts.placementMove) {
270
+ // Transient push failure — expected (connections drop). No alarm;
271
+ // SQS redelivery retries from the still-durable marker.
272
+ throw error;
273
+ }
274
+ // Redelivery budget exhausted: resolve into exactly one of the two
275
+ // terminal outcomes (epic invariant 3) instead of dead-lettering
276
+ // with no diagnosis.
277
+ const { outcome } = await resolveExhaustedPlacementMoveFailure({ markerService, messageService, threadMessageService, log }, {
278
+ placement: {
279
+ status: message.status,
280
+ mailboxId: message.mailboxId,
281
+ uid: message.uid,
282
+ },
283
+ accountId,
284
+ accountConfigId,
285
+ messageId,
286
+ uid: message.uid,
287
+ sourceMailboxId: marker.sourceMailboxId,
288
+ sourceMailboxPath: sourceMailbox.fullPath,
289
+ getConnection: scope.getConnection,
290
+ });
291
+ if (outcome === "reconciled") {
292
+ await emitMoveResync(emit, {
293
+ accountId,
294
+ sourceMailboxId: marker.sourceMailboxId,
295
+ destinationMailboxId: marker.destinationMailboxId,
296
+ });
297
+ return;
298
+ }
299
+ // Terminal and never re-thrown, so the handler-outcome series
300
+ // records this record as a success. Counted here or it is invisible.
301
+ metrics.imapFailure("PLACEMENT_MOVE_EXHAUSTED", "other", 1);
302
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "Placement move retry exhausted; message still exists at its source");
303
+ // Terminal — never re-thrown, so the caller acks either way.
304
+ })
305
+ .finally(() => scope.release());
306
+ });
307
+ };
@@ -0,0 +1,2 @@
1
+ import type { IThreadMessageRepository } from "@remit/data-ports";
2
+ export declare const probeableMessageIdHeader: (threadMessageService: Pick<IThreadMessageRepository, "findByMessageId">, accountConfigId: string, messageId: string) => Promise<string | undefined>;
@@ -0,0 +1,6 @@
1
+ import { isValidMessageId } from "@remit/data-ports/id";
2
+ export const probeableMessageIdHeader = async (threadMessageService, accountConfigId, messageId) => {
3
+ const row = await threadMessageService.findByMessageId(accountConfigId, messageId);
4
+ const header = row?.messageIdHeader;
5
+ return isValidMessageId(header) ? header : undefined;
6
+ };
@@ -0,0 +1,45 @@
1
+ import type { ProcessContext } from "../context.js";
2
+ import type { SyncMailboxesEvent, SyncMessagesEvent } from "../events.js";
3
+ /**
4
+ * Whether this fan-out should enqueue a mailbox.
5
+ *
6
+ * A sync asked for by name — POST /sync, which is the refresh control,
7
+ * pull-to-refresh, and the client's automatic poll — always syncs every
8
+ * mailbox, whatever ran a moment ago. A sync that happens as a side effect of
9
+ * something else (config load, OAuth connect, account create, the scheduled
10
+ * tick) skips mailboxes synced inside the freshness window, which is
11
+ * what stops those triggers from re-enumerating an account's folders on every
12
+ * page load.
13
+ *
14
+ * A mailbox that has never synced is always due.
15
+ */
16
+ export declare const mailboxNeedsSync: (mailbox: {
17
+ lastMessageSyncAt?: number;
18
+ }, event: Pick<SyncMailboxesEvent, "explicitRequest">, now: number, freshnessMs: number) => boolean;
19
+ export declare const syncMailboxes: (event: SyncMailboxesEvent, ctx: ProcessContext) => Promise<void>;
20
+ type SyncMessagesInput = Omit<SyncMessagesEvent, "eventId" | "timestamp">;
21
+ /**
22
+ * Separate INBOX from the rest of the fan-out. `collectAllMailboxes` sorts it
23
+ * first, but sort order alone decides nothing once the emits go out together.
24
+ */
25
+ export declare const splitInboxFirst: <T extends {
26
+ fullPath: string;
27
+ }>(mailboxes: readonly T[]) => {
28
+ inbox: T | undefined;
29
+ rest: T[];
30
+ };
31
+ /**
32
+ * Emit INBOX's SYNC_MESSAGES event and wait for it before emitting the rest.
33
+ *
34
+ * Every event of an account shares one FIFO group (`MessageGroupId =
35
+ * accountId`), so the queue serves them strictly in arrival order. Emitting
36
+ * the whole list through `pMap` only bounds concurrency — it races the writes,
37
+ * and INBOX could land behind up to `EVENT_EMIT_CONCURRENCY - 1` other
38
+ * folders. The mail a person pressed refresh for then arrived only after every
39
+ * one of those folders had finished syncing.
40
+ */
41
+ export declare const emitSyncMessagesEvents: (accountId: string, mailboxes: readonly {
42
+ mailboxId: string;
43
+ fullPath: string;
44
+ }[], emit: (event: SyncMessagesInput) => Promise<unknown>) => Promise<void>;
45
+ export {};
@@ -0,0 +1,233 @@
1
+ import { writeFolderRoleAppointment } from "@remit/backend/folder-role-appointments";
2
+ import { bindImportedFolders, disableFiltersMissingFolders, } from "@remit/config-transfer";
3
+ import { SyncPhase } from "@remit/domain-enums";
4
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
5
+ import { MailboxQueueService, MailboxSyncService, MailConnectionError, standardMailboxManagement, } from "@remit/mailbox-service";
6
+ import pMap from "p-map";
7
+ import { isAccountDeleted, isMailSyncDisabled, isUnsyncableHost, } from "../account-check.js";
8
+ import { emitFrom } from "../emit.js";
9
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
10
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
11
+ import { createImportedFolder } from "./imported-folder.js";
12
+ import { orderMailboxesForSync } from "./mailbox-sync-order.js";
13
+ const EVENT_EMIT_CONCURRENCY = 20;
14
+ /**
15
+ * Whether this fan-out should enqueue a mailbox.
16
+ *
17
+ * A sync asked for by name — POST /sync, which is the refresh control,
18
+ * pull-to-refresh, and the client's automatic poll — always syncs every
19
+ * mailbox, whatever ran a moment ago. A sync that happens as a side effect of
20
+ * something else (config load, OAuth connect, account create, the scheduled
21
+ * tick) skips mailboxes synced inside the freshness window, which is
22
+ * what stops those triggers from re-enumerating an account's folders on every
23
+ * page load.
24
+ *
25
+ * A mailbox that has never synced is always due.
26
+ */
27
+ export const mailboxNeedsSync = (mailbox, event, now, freshnessMs) => {
28
+ if (event.explicitRequest)
29
+ return true;
30
+ if (!mailbox.lastMessageSyncAt)
31
+ return true;
32
+ return now - mailbox.lastMessageSyncAt >= freshnessMs;
33
+ };
34
+ export const syncMailboxes = async (event, ctx) => {
35
+ const { client, log, queues } = ctx;
36
+ const { account: accountService, mailbox: mailboxService, mailboxSpecialUse: mailboxSpecialUseService, secrets, } = client;
37
+ const binder = {
38
+ repositories: client,
39
+ appointFolderRole: (configId, accountId, role, mailboxId, lastKnownPath) => writeFolderRoleAppointment(client.accountSetting, configId, accountId, role, mailboxId, lastKnownPath),
40
+ createFolder: createImportedFolder(mailboxService, new MailboxQueueService({
41
+ mailboxService,
42
+ enqueue: standardMailboxManagement(queues.mailboxManagement),
43
+ logger: log,
44
+ })),
45
+ };
46
+ const { accountId } = event;
47
+ log.info({ event: event.type, accountId }, "Handling event");
48
+ const account = await accountService.get(accountId);
49
+ if (!account) {
50
+ throw new Error(`Account ${accountId} not found`);
51
+ }
52
+ if (isAccountDeleted(account, log)) {
53
+ return;
54
+ }
55
+ if (isMailSyncDisabled(account, log)) {
56
+ return;
57
+ }
58
+ // A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
59
+ // sync attempt would retry and dead-letter forever. Skip cleanly — ack the
60
+ // event without connecting or throwing.
61
+ if (isUnsyncableHost(account, log)) {
62
+ return;
63
+ }
64
+ // withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, flip on
65
+ // terminal auth failure, rethrow transient). The inner try/catch only
66
+ // records the terminal non-auth error phase before letting the wrapper
67
+ // rethrow for SQS retry/DLQ.
68
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
69
+ try {
70
+ await syncMailboxesForAccount(event, account, credentials, mailboxService, mailboxSpecialUseService, accountService, binder, log, emitFrom(ctx), ctx.pool, ctx.config.mailboxFreshnessMs);
71
+ }
72
+ catch (err) {
73
+ // Auth failures are handled by the wrapper — rethrow untouched so it
74
+ // flips the account to reauth_required rather than recording an error
75
+ // phase.
76
+ if (err instanceof RefreshTokenError ||
77
+ (err instanceof MailConnectionError && err.kind === "auth")) {
78
+ throw err;
79
+ }
80
+ // Record the terminal error phase before crashing (let-it-crash:
81
+ // record state, then rethrow so the event is retried/DLQ'd).
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ await accountService.update(accountId, {
84
+ syncPhase: SyncPhase.error,
85
+ lastError: message,
86
+ });
87
+ throw err;
88
+ }
89
+ });
90
+ };
91
+ const syncMailboxesForAccount = async (event, account, credentials, mailboxService, mailboxSpecialUseService, accountService, binder, log, emit, pool, freshnessMs) => {
92
+ const { accountId } = account;
93
+ const borrowed = pool.borrow(account, credentials);
94
+ const result = await (async () => {
95
+ const connection = await borrowed.getConnection();
96
+ await accountService.markAuthenticated(accountId);
97
+ await accountService.update(accountId, {
98
+ syncPhase: SyncPhase.discovering_mailboxes,
99
+ });
100
+ const mailboxSyncService = new MailboxSyncService(mailboxService, mailboxSpecialUseService, log);
101
+ return mailboxSyncService.syncMailboxes({ accountId }, connection);
102
+ })().finally(() => borrowed.release());
103
+ await accountService.update(accountId, { lastSyncAt: Date.now() });
104
+ log.info({ result }, "Mailbox sync complete");
105
+ // The folder list this account holds is now known, which is the one thing a
106
+ // configuration import could not know when it ran: every folder in a file is
107
+ // named by IMAP path, and the ids are minted here. Binding what is bindable
108
+ // belongs at exactly this point, and is idempotent, so a later discovery
109
+ // simply finds nothing left to do.
110
+ const bound = await bindImportedFolders(binder, account.accountConfigId, accountId);
111
+ if (Object.values(bound).some((count) => count > 0)) {
112
+ log.info({ accountId, ...bound }, "Bound imported folder references");
113
+ }
114
+ const disabled = await disableFiltersMissingFolders(binder.repositories, account.accountConfigId);
115
+ if (disabled > 0) {
116
+ log.warn({ accountId, disabled }, "Disabled filters whose move folder no longer exists");
117
+ }
118
+ const allMailboxes = await collectAllMailboxes(accountId, mailboxService);
119
+ // This fan-out is where an account's IMAP work is decided: one SEARCH per
120
+ // mailbox per event, on a queue every account shares. `GET /config` fires a
121
+ // trigger per account on every call, so without a gate here an idle client
122
+ // re-enumerates every folder it owns on every page load.
123
+ //
124
+ // The gate is per mailbox and a sync asked for by name skips it outright.
125
+ // That distinction is the whole point: the previous cooldown gated
126
+ // everything, which is what made a refresh a no-op whenever a side-effect
127
+ // trigger had just run (issue #37).
128
+ const now = Date.now();
129
+ const mailboxes = allMailboxes.filter((mailbox) => mailboxNeedsSync(mailbox, event, now, freshnessMs));
130
+ const skipped = allMailboxes.length - mailboxes.length;
131
+ if (skipped > 0) {
132
+ log.info({ accountId, skipped }, "Skipped recently-synced mailboxes for a side-effect trigger");
133
+ }
134
+ if (allMailboxes.length === 0) {
135
+ log.info({ accountId }, "No mailboxes to sync messages for");
136
+ await accountService.update(accountId, {
137
+ syncPhase: SyncPhase.complete,
138
+ mailboxCountTotal: 0,
139
+ mailboxCountSynced: 0,
140
+ });
141
+ return;
142
+ }
143
+ if (mailboxes.length === 0) {
144
+ log.info({ accountId }, "Every mailbox is fresh; nothing to sync");
145
+ await accountService.update(accountId, {
146
+ syncPhase: SyncPhase.complete,
147
+ mailboxCountTotal: allMailboxes.length,
148
+ mailboxCountSynced: allMailboxes.length,
149
+ });
150
+ return;
151
+ }
152
+ log.info({ accountId, count: mailboxes.length }, "Emitting SYNC_MESSAGES events");
153
+ // Phase transition. Only the enqueued mailboxes emit a completion, so the
154
+ // gated ones are pre-credited — otherwise synced could never reach total.
155
+ //
156
+ // The counter is progress through THIS round, not a lifetime total: a new
157
+ // round restarts it, which is why it can read lower than a moment ago while
158
+ // a previous round is still draining. That is the same instant
159
+ // `account.lastSyncAt` is stamped, and the per-mailbox completion guard in
160
+ // sync-messages.ts keys off exactly that stamp — so a completion still in
161
+ // flight from the previous round counts once towards the new one, and each
162
+ // mailbox counts at most once per round.
163
+ const inboxEnqueued = mailboxes.some((m) => m.fullPath.toUpperCase() === "INBOX");
164
+ await accountService.update(accountId, {
165
+ syncPhase: inboxEnqueued
166
+ ? SyncPhase.syncing_inbox
167
+ : SyncPhase.syncing_others,
168
+ mailboxCountTotal: allMailboxes.length,
169
+ mailboxCountSynced: skipped,
170
+ });
171
+ await emitSyncMessagesEvents(accountId, mailboxes, emit);
172
+ };
173
+ /**
174
+ * Separate INBOX from the rest of the fan-out. `collectAllMailboxes` sorts it
175
+ * first, but sort order alone decides nothing once the emits go out together.
176
+ */
177
+ export const splitInboxFirst = (mailboxes) => {
178
+ const at = mailboxes.findIndex((mailbox) => mailbox.fullPath.toUpperCase() === "INBOX");
179
+ if (at === -1)
180
+ return { inbox: undefined, rest: [...mailboxes] };
181
+ return {
182
+ inbox: mailboxes[at],
183
+ rest: mailboxes.filter((_, index) => index !== at),
184
+ };
185
+ };
186
+ /**
187
+ * Emit INBOX's SYNC_MESSAGES event and wait for it before emitting the rest.
188
+ *
189
+ * Every event of an account shares one FIFO group (`MessageGroupId =
190
+ * accountId`), so the queue serves them strictly in arrival order. Emitting
191
+ * the whole list through `pMap` only bounds concurrency — it races the writes,
192
+ * and INBOX could land behind up to `EVENT_EMIT_CONCURRENCY - 1` other
193
+ * folders. The mail a person pressed refresh for then arrived only after every
194
+ * one of those folders had finished syncing.
195
+ */
196
+ export const emitSyncMessagesEvents = async (accountId, mailboxes, emit) => {
197
+ const eventFor = (mailboxId) => ({
198
+ type: "SYNC_MESSAGES",
199
+ accountId,
200
+ mailboxId,
201
+ });
202
+ const { inbox, rest } = splitInboxFirst(mailboxes);
203
+ if (inbox)
204
+ await emit(eventFor(inbox.mailboxId));
205
+ await pMap(rest, ({ mailboxId }) => emit(eventFor(mailboxId)), {
206
+ concurrency: EVENT_EMIT_CONCURRENCY,
207
+ });
208
+ };
209
+ /**
210
+ * Collect all mailboxes for an account, ordered for sync fan-out by
211
+ * special-use: INBOX first, then Sent/Drafts, then normal folders, with
212
+ * Junk/Spam and Trash last (issue #567). Real mail dispatches ahead of bulk
213
+ * folders so a fresh account fills its inbox before its spam.
214
+ */
215
+ const collectAllMailboxes = async (accountId, mailboxService) => {
216
+ const mailboxes = [];
217
+ let continuationToken;
218
+ do {
219
+ const result = await mailboxService.listByAccount(accountId, {
220
+ continuationToken,
221
+ });
222
+ for (const mailbox of result.items) {
223
+ mailboxes.push({
224
+ mailboxId: mailbox.mailboxId,
225
+ fullPath: mailbox.fullPath,
226
+ lastMessageSyncAt: mailbox.lastMessageSyncAt,
227
+ specialUse: mailbox.specialUse,
228
+ });
229
+ }
230
+ continuationToken = result.continuationToken ?? undefined;
231
+ } while (continuationToken);
232
+ return orderMailboxesForSync(mailboxes);
233
+ };
@@ -0,0 +1,23 @@
1
+ import type { ProcessContext } from "../context.js";
2
+ import type { SyncMessageBodyEvent } from "../events.js";
3
+ /**
4
+ * The ordered message ids to sync, plus a uid lookup when the event carried the
5
+ * preferred `messages` shape. The uid map lets the body-sync service skip the
6
+ * per-message DDB get; legacy events (ids only) leave it undefined and the
7
+ * service resolves uids itself.
8
+ */
9
+ export interface ResolvedBatch {
10
+ messageIds: string[];
11
+ uidByMessageId?: Map<string, number>;
12
+ force: boolean;
13
+ }
14
+ export declare const resolveBatch: (event: SyncMessageBodyEvent) => ResolvedBatch;
15
+ /**
16
+ * Build the loud, structured error thrown when a batch still has failed
17
+ * messages and SQS redelivery budget left. Throwing (rather than swallowing
18
+ * into a fresh re-enqueue) is what lets a genuine processing failure reach
19
+ * the body-dlq once `BODY_SYNC_MAX_ATTEMPTS` is exhausted — the SQS-level
20
+ * redrive owns retry scheduling now, not this handler.
21
+ */
22
+ export declare const buildRetryableFailureError: (failedMessageIds: string[], receiveCount: number, maxAttempts: number) => Error;
23
+ export declare const syncMessageBody: (event: SyncMessageBodyEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;