@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,490 @@
1
+ import { MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
2
+ import { guardConnectionCursor, isCursorRebuildNeeded, MailboxCursorPausedError, 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 { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
9
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
10
+ /**
11
+ * Re-read both folders' counts from IMAP after a move by enqueuing the existing
12
+ * per-folder SYNC_MESSAGES sync. Counts are a projection of IMAP, never mutated
13
+ * locally — the move shifted a message between source and destination, so both
14
+ * folders must refresh through the one-way pipeline.
15
+ */
16
+ export const emitMoveResync = async (emit, params) => {
17
+ const { accountId, sourceMailboxId, destinationMailboxId } = params;
18
+ await Promise.all([sourceMailboxId, destinationMailboxId].map((mailboxId) => emit({ type: "SYNC_MESSAGES", accountId, mailboxId })));
19
+ };
20
+ /**
21
+ * The one-folder half of {@link emitMoveResync}, for a mutation that has no
22
+ * destination — a permanent delete removed local rows the server still holds,
23
+ * and only a resync of that folder puts them back.
24
+ */
25
+ export const emitMailboxResync = async (emit, params) => {
26
+ await emit({ type: "SYNC_MESSAGES", ...params });
27
+ };
28
+ /**
29
+ * SEARCH a mailbox for copies of a message identified by its RFC822 Message-ID
30
+ * header and answer the HIGHEST matching uid. Read-only (EXAMINE, not SELECT) —
31
+ * this is a verification probe, never a write. `null` when nothing matched.
32
+ *
33
+ * The highest uid is the freshly delivered copy only when one exists; deciding
34
+ * that it does is the caller's job, not this function's. Every probe in this
35
+ * package wants the highest: uids ascend with arrival inside a UIDVALIDITY, so a
36
+ * copy that has just landed outranks every copy of the same Message-ID the
37
+ * folder already held. Where nothing was delivered, the highest is simply the
38
+ * newest pre-existing copy, and a caller that has not ruled that case out binds
39
+ * itself to unrelated mail.
40
+ * One Message-ID can have several server copies in one account (a sieve
41
+ * `fileinto` + `keep`, a resend, a repeated COPY — `deriveCopyMessageId` reuses
42
+ * the row, so the second copy is a second server COPY), while `deriveMessageId`
43
+ * is folder-independent and gives them one local row. Taking the lowest bound
44
+ * the fresh copy's row to an older copy's uid and orphaned the fresh one, which
45
+ * a later delete or Empty Trash then expunged by the wrong uid (issue #1122).
46
+ *
47
+ * The maximum is computed rather than read off the tail: RFC 3501 leaves the
48
+ * order of a SEARCH response unspecified, so "last returned" is not "highest".
49
+ */
50
+ export const searchMailboxForHighestMessageIdUid = async (connection, mailboxPath, messageIdHeader) => {
51
+ await connection.openBox(mailboxPath, true);
52
+ const uids = await connection.search([
53
+ ["HEADER", "Message-ID", messageIdHeader],
54
+ ]);
55
+ if (uids.length === 0)
56
+ return null;
57
+ return uids.reduce((highest, uid) => (uid > highest ? uid : highest));
58
+ };
59
+ /**
60
+ * Ask the server, by Message-ID, which folder holds a message whose mutation a
61
+ * paused cursor interrupted (issue #1203).
62
+ *
63
+ * Reached only on a redelivery, where "the command never left" has stopped
64
+ * being provable: the earlier attempt's tagged OK can be lost with the
65
+ * connection, so restoring the source pair on that assumption would write a
66
+ * settled placement naming a folder the server has already moved the mail out
67
+ * of. `message-copy.ts` asks the same question for the same reason.
68
+ *
69
+ * The SOURCE is asked first and a hit ends it, the gate `confirmTrashMoveUid`
70
+ * documents (#1122): where the mutation never ran, every hit at the destination
71
+ * is an older copy of the same Message-ID — a sieve `fileinto` + `keep`, a
72
+ * multi-label store, a resend — that `deriveMessageId` folds into this one local
73
+ * row, and binding to it hands a later delete somebody else's uid.
74
+ *
75
+ * The source's silence, however, is not the mirror image of its hit. Every
76
+ * caller here reaches this on a paused cursor, which means the source's
77
+ * UIDVALIDITY has moved and the folder may have been recreated holding nothing
78
+ * it held before — so "not at the source" is an answer about the folder, not
79
+ * about our mutation, and `at-destination` is a sighting rather than a
80
+ * confirmation. Callers settle it as unproven; see `settlePausedMove`.
81
+ *
82
+ * Identity, not position, is what makes this askable at all: a paused cursor
83
+ * means the source's UIDVALIDITY has moved, so every stored uid for it names
84
+ * something else, but a SEARCH by header is independent of the axis and answers
85
+ * the same question the cursor rebuild itself matches rows by. For that reason
86
+ * the caller passes the UNGUARDED connection — a `guardConnectionCursor` wrap
87
+ * refuses to open the mailbox at all, and it is bound to the source's snapshot
88
+ * anyway, so opening the destination through it would trip that mailbox too.
89
+ */
90
+ export const probePausedPlacement = async (connection, input) => {
91
+ const { messageIdHeader, sourceMailboxPath, destinationMailboxPath } = input;
92
+ if (!messageIdHeader)
93
+ return { kind: "unprobeable" };
94
+ const atSource = await searchMailboxForHighestMessageIdUid(connection, sourceMailboxPath, messageIdHeader);
95
+ if (atSource !== null)
96
+ return { kind: "at-source" };
97
+ if (!destinationMailboxPath)
98
+ return { kind: "gone" };
99
+ const atDestination = await searchMailboxForHighestMessageIdUid(connection, destinationMailboxPath, messageIdHeader);
100
+ return atDestination === null
101
+ ? { kind: "gone" }
102
+ : { kind: "at-destination", uid: atDestination };
103
+ };
104
+ /**
105
+ * Resync the affected folders only once the IMAP move has resolved. A move that
106
+ * fails (or is retried) must not refresh counts off a move that didn't happen,
107
+ * so the resync is sequenced strictly after `performMove`.
108
+ */
109
+ export const moveThenResync = async (performMove, resync) => {
110
+ await performMove();
111
+ await resync();
112
+ };
113
+ /**
114
+ * Build the `set` and `composites` payload for the ThreadMessage update on a
115
+ * MESSAGE_MOVE.
116
+ *
117
+ * The CURRENT row state goes in `composites`; the NEW values go in `set`.
118
+ * ElectroDB uses `composites` to run the conditional check on the existing row
119
+ * AND to compute the previous sort-key values needed to recompute the new ones.
120
+ * Passing the NEW values in `composites` makes the conditional check fail with
121
+ * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
122
+ * the caller silently drops the update. Same root cause as PR #186 fixed for
123
+ * `flag-queue.ts`.
124
+ */
125
+ export const buildThreadMessageMoveUpdate = (threadMessage, newUid, destinationMailboxId) => ({
126
+ set: {
127
+ uid: newUid,
128
+ mailboxId: destinationMailboxId,
129
+ isDeleted: false,
130
+ },
131
+ composites: {
132
+ sentDate: threadMessage.sentDate,
133
+ mailboxId: threadMessage.mailboxId,
134
+ isRead: threadMessage.isRead,
135
+ isDeleted: threadMessage.isDeleted,
136
+ hasStars: threadMessage.hasStars,
137
+ hasAttachment: threadMessage.hasAttachment,
138
+ },
139
+ });
140
+ /**
141
+ * Handle MESSAGE_MOVE events.
142
+ * Executes IMAP MOVE command and updates local state with new UID.
143
+ *
144
+ * A failing move retries on SQS redelivery until `receiveCount` reaches
145
+ * the `config.attempts.messageMove` budget, at which point
146
+ * {@link resolveExhaustedMessageMoveFailure} asks IMAP where the message
147
+ * actually is and settles the row into one terminal outcome (issue #655).
148
+ * Before that, `syncStatus: failed` marked every attempt and nothing ever
149
+ * settled the row, so an exhausted move sat `moving`/`failed` forever.
150
+ */
151
+ export const handleMessageMove = async (event, ctx, receiveCount) => {
152
+ const { log, pool, metrics, config } = ctx;
153
+ const emit = emitFrom(ctx);
154
+ const { account: accountService, message: messageService, threadMessage: threadMessageService, mailbox: mailboxService, secrets, } = ctx.client;
155
+ const { accountId, messageId, sourceMailboxId, destinationMailboxId, uid } = event;
156
+ log.info({
157
+ event: event.type,
158
+ accountId,
159
+ messageId,
160
+ from: sourceMailboxId,
161
+ to: destinationMailboxId,
162
+ }, "Handling event");
163
+ const account = await accountService.get(accountId);
164
+ if (!account) {
165
+ throw new Error(`Account ${accountId} not found`);
166
+ }
167
+ if (isAccountDeleted(account, log)) {
168
+ return;
169
+ }
170
+ const [message] = await messageService.get([messageId]);
171
+ // The message row is already gone — some other reconciliation path deleted
172
+ // it. The move is moot; ack without touching IMAP.
173
+ if (!message) {
174
+ log.warn({ accountId, messageId }, "Skipping MESSAGE_MOVE: message row no longer exists");
175
+ return;
176
+ }
177
+ // This move already settled — `updateUid` cleared `status: moving` once the
178
+ // server confirmed it. A redelivery reaching here would MOVE a UID the
179
+ // source no longer holds, fail, and on exhaustion read the source's honest
180
+ // "gone" as grounds to reconcile away a row that is correct. There is no
181
+ // marker to find missing (unlike FLAG_PUSH and PLACEMENT_MOVE_PUSH), so the
182
+ // row's own pending marker is what stands in for one.
183
+ //
184
+ // `moving` specifically, not the shared unsettled predicate: the question is
185
+ // whether THIS move is still outstanding, and a row a delete has since
186
+ // claimed is `deleting` — work that belongs to MESSAGE_DELETE, and a state
187
+ // this move must not read as its own to finish.
188
+ if (message.status !== MessageStatus.moving) {
189
+ log.info({ accountId, messageId, uid: message.uid, status: message.status }, "Skipping MESSAGE_MOVE: the move already settled against confirmed IMAP state");
190
+ return;
191
+ }
192
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
193
+ // The source folder can be deleted between enqueue and this sync, leaving
194
+ // a queued event pointing at a gone row. The lookup then throws
195
+ // NotFoundError forever, and on the account's per-group FIFO that head
196
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
197
+ // source mailbox makes the move moot, but not the row: left `moving`, it
198
+ // names the destination with a uid from a folder that no longer exists,
199
+ // and nothing else settles it. It is reconciled away, and the destination
200
+ // resync re-projects the message if the MOVE did land there.
201
+ const source = await findMailboxRow(mailboxService, accountId, sourceMailboxId);
202
+ if (source.kind === "gone") {
203
+ const reconciled = await messageService.deleteIfPlacement(messageId, {
204
+ status: message.status,
205
+ mailboxId: message.mailboxId,
206
+ uid: message.uid,
207
+ });
208
+ log.warn({
209
+ metric: "message_move_source_gone",
210
+ accountId,
211
+ messageId,
212
+ mailboxId: sourceMailboxId,
213
+ reconciled,
214
+ }, "MESSAGE_MOVE not pushed: source mailbox no longer exists (deleted); stale row reconciled");
215
+ await emitMailboxResync(emit, {
216
+ accountId,
217
+ mailboxId: destinationMailboxId,
218
+ });
219
+ return;
220
+ }
221
+ const mailbox = source.mailbox;
222
+ const sourceMailboxPath = mailbox.fullPath;
223
+ const destination = await findMailboxRow(mailboxService, accountId, destinationMailboxId);
224
+ const settleMoved = async (newUid) => {
225
+ await messageService.updateUid(messageId, newUid, destinationMailboxId);
226
+ const threadMessage = await threadMessageService.findByMessageId(account.accountConfigId, messageId);
227
+ if (threadMessage) {
228
+ const args = buildThreadMessageMoveUpdate(threadMessage, newUid, destinationMailboxId);
229
+ await threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, args.set, { composites: args.composites });
230
+ }
231
+ log.info({
232
+ messageId,
233
+ oldUid: uid,
234
+ newUid,
235
+ destination: destinationMailboxId,
236
+ }, "Message moved successfully");
237
+ };
238
+ /**
239
+ * Put the row back on the pair it was moved off, and say so once.
240
+ *
241
+ * `metric` is the routine give-up the guard doc calls never-a-fault:
242
+ * the user's move was silently dropped, which is worth counting and
243
+ * not worth waking anyone for. `alert` is the give-up the server did
244
+ * not settle — the row carries `failed` and an operator adjudicates.
245
+ */
246
+ const handBackToSource = async (syncStatus, signal, reason) => {
247
+ const context = {
248
+ ...signal,
249
+ accountId,
250
+ messageId,
251
+ uid,
252
+ sourceMailboxId,
253
+ destinationMailboxId,
254
+ receiveCount,
255
+ reason,
256
+ };
257
+ if ("alert" in signal) {
258
+ log.error(context, "Move given up without a settled placement; row handed back to its source for adjudication");
259
+ }
260
+ else {
261
+ log.warn(context, "Move not pushed to the server; row handed back to its source");
262
+ }
263
+ await restoreSourcePlacement({ messageService, threadMessageService }, {
264
+ accountConfigId: account.accountConfigId,
265
+ messageId,
266
+ sourceMailboxId,
267
+ uid,
268
+ syncStatus,
269
+ // Every hand-back this handler makes is a move's. Naming it on
270
+ // the row is what stops the reading pane calling a move that
271
+ // gave up a failed delete (issue #1229).
272
+ abandonedMutation: syncStatus === MessageSyncStatus.abandoned
273
+ ? MessageMutation.move
274
+ : MessageMutation.none,
275
+ });
276
+ };
277
+ if (destination.kind === "gone") {
278
+ await handBackToSource(MessageSyncStatus.synced, { metric: "message_move_destination_gone" }, "destination mailbox no longer exists (deleted)");
279
+ await emitMailboxResync(emit, {
280
+ accountId,
281
+ mailboxId: sourceMailboxId,
282
+ });
283
+ return;
284
+ }
285
+ const destinationMailboxPath = destination.mailbox.fullPath;
286
+ const scope = pool.borrow(account, credentials);
287
+ // The terminal resolver opens the source itself, and its answer now
288
+ // WRITES a placement, so it has to reach IMAP through the same
289
+ // UIDVALIDITY choke point every other outbound operation does. An
290
+ // unguarded probe on a folder that was deleted and recreated finds
291
+ // whatever the server has renumbered onto this uid, reads it as "still
292
+ // at source", and binds the row to a stranger the next permanent delete
293
+ // would expunge.
294
+ const getGuardedConnection = async () => guardConnectionCursor(await scope.getConnection(), { mailboxService }, accountId, mailbox);
295
+ // A paused cursor is never acked on the optimistic row: the transition
296
+ // has pointed it at the destination while `uid` still names the source,
297
+ // and that pair strands the row for good — the cursor rebuild matches
298
+ // rows by `(accountConfigId, mailboxId)`, so a row naming the
299
+ // destination is in neither folder's set, and nothing re-enqueues a
300
+ // MESSAGE_MOVE (issue #1203).
301
+ //
302
+ // This move therefore reconciles rather than waits (R2,
303
+ // docs/architecture/imap-mutations.md): the row is settled onto
304
+ // whichever pair the server can be shown to hold, and the resync below
305
+ // plus the source's own cursor rebuild are its repair path.
306
+ //
307
+ // Putting the row back is an undo of this product's own write only where
308
+ // the MOVE provably never left. `commandIssued` says what this round
309
+ // did; `receiveCount` adds what an earlier one may have done and lost
310
+ // with its connection. Inferring the first from the second is what
311
+ // `settlePausedDelete` got wrong — attempt 1 reaches an exhausted settle
312
+ // too, with the MOVE already issued.
313
+ const settlePausedMove = async (commandIssued) => {
314
+ const mayHaveIssued = commandIssued || receiveCount > 1;
315
+ const placement = mayHaveIssued
316
+ ? await probePausedPlacement(await scope.getConnection(), {
317
+ messageIdHeader: await probeableMessageIdHeader(threadMessageService, account.accountConfigId, messageId),
318
+ sourceMailboxPath,
319
+ destinationMailboxPath,
320
+ })
321
+ : { kind: "at-source" };
322
+ // A sighting at the destination is not proof this move ran. The pause
323
+ // means the source's UIDVALIDITY has moved, so its "no" can be a
324
+ // recreated folder answering about mail it never held, and the hit can
325
+ // be an older copy of the same Message-ID that `deriveMessageId` folds
326
+ // into this row — settling `moved` on it points the row at mail a
327
+ // later permanent delete would expunge. `unprobeable` is silence, and
328
+ // silence settles nothing either (the rule `message-copy.ts` already
329
+ // applies). Both hand the row back on the source pair carrying
330
+ // `failed`: that is the set the source's own cursor rebuild walks and
331
+ // adjudicates by Message-ID, which is the reconcile path (R2) the
332
+ // destination — not paused, no rebuild coming — does not have.
333
+ const unproven = placement.kind === "at-destination" ||
334
+ placement.kind === "unprobeable";
335
+ if (unproven) {
336
+ await handBackToSource(MessageSyncStatus.abandoned, { alert: "message_move_paused_placement_unproven" }, placement.kind === "at-destination"
337
+ ? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
338
+ : "no Message-ID header to ask either folder with");
339
+ }
340
+ else {
341
+ // `gone` lands here with `at-source`: the source pair is the set the
342
+ // rebuild walks, and a row it cannot match against a fresh envelope
343
+ // snapshot is the one thing it reconciles away.
344
+ await handBackToSource(MessageSyncStatus.synced, { metric: "message_move_dropped_on_pause" }, `source mailbox cursor paused, placement ${placement.kind}`);
345
+ }
346
+ await emitMoveResync(emit, {
347
+ accountId,
348
+ sourceMailboxId,
349
+ destinationMailboxId,
350
+ });
351
+ };
352
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
353
+ // paused never opens a connection on a first delivery. Optimization
354
+ // only — the guardConnectionCursor openBox wrap below is the structural
355
+ // guarantee.
356
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
357
+ log.info({ accountId, messageId, mailboxId: sourceMailboxId }, "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server");
358
+ await settlePausedMove(false).finally(() => scope.release());
359
+ return;
360
+ }
361
+ // Flipped the instant the MOVE leaves, so every settle downstream of it
362
+ // knows the server was asked.
363
+ let commandIssued = false;
364
+ await scope
365
+ .getConnection()
366
+ .then((rawConnection) => {
367
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
368
+ // a fresh mismatch trips the mailbox and throws once the SELECT
369
+ // reveals it. The move stays applied locally either way.
370
+ const connection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, mailbox);
371
+ return moveThenResync(async () => {
372
+ // Open source mailbox (not read-only)
373
+ await connection.openBox(sourceMailboxPath, false);
374
+ // Execute IMAP MOVE
375
+ commandIssued = true;
376
+ const result = await connection.moveMessages([uid], destinationMailboxPath);
377
+ // Get new UID from COPYUID response. A server without UIDPLUS
378
+ // answers a perfectly successful MOVE with no COPYUID entry,
379
+ // so an empty map is UNCONFIRMED, never evidence the message
380
+ // is gone: the destination is asked by Message-ID before any
381
+ // verdict, exactly as `attemptMove` does. Marking the row
382
+ // `failed` and returning (the behaviour issue #655 opens on)
383
+ // left it `moving`/`failed` with no DLQ entry and no metric,
384
+ // because a handler that returns never redelivers.
385
+ const newUid = result.uidMap.get(uid) ??
386
+ (await probeableMessageIdHeader(threadMessageService, account.accountConfigId, messageId).then((messageIdHeader) => messageIdHeader
387
+ ? searchMailboxForHighestMessageIdUid(rawConnection, destinationMailboxPath, messageIdHeader)
388
+ : null));
389
+ if (!newUid) {
390
+ throw new Error(`Message move unconfirmed (no COPYUID entry, not found at ${destinationMailboxPath}) — retrying`);
391
+ }
392
+ await settleMoved(newUid);
393
+ }, () => emitMoveResync(emit, {
394
+ accountId,
395
+ sourceMailboxId,
396
+ destinationMailboxId,
397
+ }));
398
+ })
399
+ .catch(async (error) => {
400
+ if (error instanceof MailboxCursorPausedError) {
401
+ log.info({
402
+ accountId,
403
+ messageId,
404
+ mailboxId: sourceMailboxId,
405
+ cursorState: error.state,
406
+ }, "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server");
407
+ await settlePausedMove(commandIssued);
408
+ return;
409
+ }
410
+ const errorMessage = error instanceof Error ? error.message : String(error);
411
+ // Handle TRYCREATE - destination doesn't exist
412
+ if (errorMessage.includes("TRYCREATE")) {
413
+ log.info({ destinationMailboxPath }, "Destination mailbox doesn't exist, creating");
414
+ const connection = await scope.getConnection();
415
+ await connection.createMailbox(destinationMailboxPath);
416
+ // Re-throw to let the event be retried against the folder just
417
+ // created. Kept unconditional past the attempt budget: the
418
+ // destination now exists, so the record is worth redriving from
419
+ // the DLQ, and the terminal resolver has nothing to settle — it
420
+ // would find the message exactly where it started.
421
+ throw error;
422
+ }
423
+ if (receiveCount < config.attempts.messageMove) {
424
+ // Transient move failure — expected (connections drop). No
425
+ // alarm; queue redelivery retries, and `failed` marks the row
426
+ // as unsettled meanwhile. It is not a terminal signal: only the
427
+ // resolver below settles anything.
428
+ await messageService.transitionPlacement(messageId, { status: MessageStatus.moving }, { syncStatus: MessageSyncStatus.failed });
429
+ throw error;
430
+ }
431
+ // Redelivery budget exhausted: resolve into exactly one of the two
432
+ // terminal outcomes instead of dead-lettering with no diagnosis,
433
+ // and never by inferring the server's state from our own failures.
434
+ const settled = await resolveExhaustedMessageMoveFailure({ messageService, threadMessageService, log }, {
435
+ placement: {
436
+ status: message.status,
437
+ mailboxId: message.mailboxId,
438
+ uid: message.uid,
439
+ },
440
+ accountId,
441
+ accountConfigId: account.accountConfigId,
442
+ messageId,
443
+ sourceMailboxId,
444
+ uid,
445
+ sourceMailboxPath,
446
+ getConnection: getGuardedConnection,
447
+ }).catch(async (settleError) => {
448
+ // The guarded probe found a mailbox whose UIDVALIDITY has moved.
449
+ // Nothing may be settled off a uid on a dead axis, and the pause
450
+ // is the routine skip `guardMailboxCursor` documents, not a fault
451
+ // to re-throw out of this catch. The row is still settled, on the
452
+ // identity axis instead: `settlePausedMove` asks by Message-ID,
453
+ // which a UIDVALIDITY change does not invalidate.
454
+ if (settleError instanceof MailboxCursorPausedError) {
455
+ log.info({
456
+ accountId,
457
+ messageId,
458
+ mailboxId: sourceMailboxId,
459
+ cursorState: settleError.state,
460
+ }, "Mailbox cursor not normal; settling the exhausted move against the server by Message-ID");
461
+ await settlePausedMove(commandIssued);
462
+ return null;
463
+ }
464
+ throw settleError;
465
+ });
466
+ if (!settled)
467
+ return;
468
+ if (settled.outcome === "broken") {
469
+ // Terminal and never re-thrown, so the handler-outcome series
470
+ // records this record as a success. Counted here or it is
471
+ // invisible.
472
+ metrics.imapFailure("MESSAGE_MOVE_EXHAUSTED", "other", 1);
473
+ log.error({ error: errorMessage }, "Message move retry exhausted; message still exists at its source");
474
+ }
475
+ // Both verdicts end in a row the server has contradicted, so this
476
+ // move reconciles rather than waits (R2). RECONCILED, the local rows
477
+ // are gone and whichever folder actually holds the message
478
+ // re-projects it with the server's own UID. BROKEN, the row has just
479
+ // been put back at the source the server confirmed, and the resync
480
+ // is what carries any drift either folder has picked up since.
481
+ await emitMoveResync(emit, {
482
+ accountId,
483
+ sourceMailboxId,
484
+ destinationMailboxId,
485
+ });
486
+ // Terminal — never re-thrown, so the caller acks either way.
487
+ })
488
+ .finally(() => scope.release());
489
+ });
490
+ };
@@ -0,0 +1,62 @@
1
+ import { type IImapConnection } from "@remit/mailbox-service";
2
+ import type { ProcessContext } from "../context.js";
3
+ import type { PlacementMovePushEvent } from "../events.js";
4
+ interface MoveOutcome {
5
+ kind: "moved" | "not-found" | "trycreate";
6
+ newUid?: number;
7
+ }
8
+ /**
9
+ * Attempt the IMAP MOVE.
10
+ *
11
+ * Takes two connection handles rather than one: `sourceConnection` and
12
+ * `destinationConnection` wrap the SAME underlying connection but are each
13
+ * cursor-guarded (#1272) against their OWN mailbox — a `guardConnectionCursor`
14
+ * wrap binds `openBox` checks to whichever ONE mailbox snapshot it was built
15
+ * with, so verifying at the destination must never run through the source's
16
+ * guard (it would compare the destination's served UIDVALIDITY against the
17
+ * source's stored one and misfire).
18
+ *
19
+ * - `moved` with a `newUid` — confirmed, either via COPYUID or (see below)
20
+ * an explicit destination verification.
21
+ * - `trycreate` — the destination mailbox doesn't exist yet; the caller
22
+ * creates it and retries.
23
+ * - `not-found` — confirmed absent from BOTH source and destination. Only
24
+ * this outcome supersedes the pending-move marker (drops it, reconciles
25
+ * the row as an external delete).
26
+ * - Any other case throws (propagates for SQS retry, or as
27
+ * `MailboxCursorPausedError` for the caller's cursor-pause handling) —
28
+ * including an UNCONFIRMED move (no COPYUID, or an explicit "not
29
+ * found"/NONEXISTENT from the server) whose message is still sitting at
30
+ * the source. A MOVE that resolves without a uidMap entry on a
31
+ * non-UIDPLUS server is a plausible SUCCESS, not evidence the message is
32
+ * gone (PR #1289 review finding 2) — deleting the local row on that
33
+ * ambiguity would be data loss. `not-found` is only returned once BOTH a
34
+ * destination Message-ID search (when the header is known) misses AND
35
+ * {@link isMessageGoneFromOpenMailbox} confirms the message absent from the
36
+ * source — a FETCH returning no row does not, on its own, confirm anything.
37
+ */
38
+ export declare const attemptMove: (sourceConnection: IImapConnection, destinationConnection: IImapConnection, sourceMailboxPath: string, destinationMailboxPath: string, uid: number, messageIdHeader: string | undefined) => Promise<MoveOutcome>;
39
+ /**
40
+ * Handle PLACEMENT_MOVE_PUSH events (issue #1271, epic #1281). Drains ONE
41
+ * pending placement-move marker: resolves the UID fresh from the Message row
42
+ * (never trusts a captured value — invariant 1), pushes the IMAP MOVE, and
43
+ * clears the marker ONLY on confirmed success. Every precedence rule (epic
44
+ * invariant 2) is enforced here:
45
+ * - Pending Remit move wins on location: nothing in this handler ever
46
+ * corrects `message.mailboxId` back toward the source — a resync elsewhere
47
+ * (message-sync.ts) never overwrites an existing row's `mailboxId` either
48
+ * (create-only-if-not-exists semantics), so the local move is never undone.
49
+ * - A NEWER local action superseding this marker (drop without pushing) —
50
+ * see the `message.mailboxId !== marker.destinationMailboxId` check below.
51
+ * - An external delete supersedes the marker entirely — the `not-found`
52
+ * outcome drops the marker and reconciles the stale row.
53
+ *
54
+ * Cursor-guarded (#1272, epic #1281 invariant 5): both the source and
55
+ * destination mailbox are checked for a non-`normal` `cursorState` before
56
+ * connecting, and the connection is wrapped per-mailbox via
57
+ * `guardConnectionCursor` so no stored UID touches the server while either
58
+ * mailbox's axis is being rebuilt. A trip pauses the push (routine, no
59
+ * alarm) — the marker stays durable and pushes again on the next event.
60
+ */
61
+ export declare const handlePlacementMovePush: (event: PlacementMovePushEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;
62
+ export {};