@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
@@ -1,755 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
3
- import {
4
- MessageMutation,
5
- MessageStatus,
6
- MessageSyncStatus,
7
- } from "@remit/domain-enums";
8
- import type { Logger } from "@remit/logger";
9
- import { recordImapFailure } from "@remit/logger-lambda/metrics";
10
- import {
11
- guardConnectionCursor,
12
- type IImapConnection,
13
- isCursorRebuildNeeded,
14
- MailboxCursorPausedError,
15
- restoreSourcePlacement,
16
- } from "@remit/mailbox-service";
17
- import { attemptBudget } from "@remit/sqs-client/attempt-budget";
18
- import { isAccountDeleted } from "../account-check.js";
19
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
20
- import { createEmitEvent } from "../emit.js";
21
- import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
22
- import { findMailboxRow } from "../mailbox-row.js";
23
- import type { WorkerQueues } from "../queues.js";
24
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
25
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
26
- import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
27
- import { probeableMessageIdHeader } from "./probe-message-id.js";
28
-
29
- export const getMessageMoveMaxAttempts = (
30
- processEnv: NodeJS.ProcessEnv = process.env,
31
- ): number => attemptBudget("MESSAGE_MOVE_MAX_ATTEMPTS", 3, processEnv);
32
-
33
- export const MESSAGE_MOVE_MAX_ATTEMPTS = getMessageMoveMaxAttempts();
34
-
35
- type EmitSyncMessages = (
36
- event: Omit<SyncMessagesEvent, "eventId" | "timestamp">,
37
- ) => Promise<unknown>;
38
-
39
- /**
40
- * Re-read both folders' counts from IMAP after a move by enqueuing the existing
41
- * per-folder SYNC_MESSAGES sync. Counts are a projection of IMAP, never mutated
42
- * locally — the move shifted a message between source and destination, so both
43
- * folders must refresh through the one-way pipeline.
44
- */
45
- export const emitMoveResync = async (
46
- emit: EmitSyncMessages,
47
- params: {
48
- accountId: string;
49
- sourceMailboxId: string;
50
- destinationMailboxId: string;
51
- },
52
- ): Promise<void> => {
53
- const { accountId, sourceMailboxId, destinationMailboxId } = params;
54
- await Promise.all(
55
- [sourceMailboxId, destinationMailboxId].map((mailboxId) =>
56
- emit({ type: "SYNC_MESSAGES", accountId, mailboxId }),
57
- ),
58
- );
59
- };
60
-
61
- /**
62
- * The one-folder half of {@link emitMoveResync}, for a mutation that has no
63
- * destination — a permanent delete removed local rows the server still holds,
64
- * and only a resync of that folder puts them back.
65
- */
66
- export const emitMailboxResync = async (
67
- emit: EmitSyncMessages,
68
- params: { accountId: string; mailboxId: string },
69
- ): Promise<void> => {
70
- await emit({ type: "SYNC_MESSAGES", ...params });
71
- };
72
-
73
- /**
74
- * SEARCH a mailbox for copies of a message identified by its RFC822 Message-ID
75
- * header and answer the HIGHEST matching uid. Read-only (EXAMINE, not SELECT) —
76
- * this is a verification probe, never a write. `null` when nothing matched.
77
- *
78
- * The highest uid is the freshly delivered copy only when one exists; deciding
79
- * that it does is the caller's job, not this function's. Every probe in this
80
- * package wants the highest: uids ascend with arrival inside a UIDVALIDITY, so a
81
- * copy that has just landed outranks every copy of the same Message-ID the
82
- * folder already held. Where nothing was delivered, the highest is simply the
83
- * newest pre-existing copy, and a caller that has not ruled that case out binds
84
- * itself to unrelated mail.
85
- * One Message-ID can have several server copies in one account (a sieve
86
- * `fileinto` + `keep`, a resend, a repeated COPY — `deriveCopyMessageId` reuses
87
- * the row, so the second copy is a second server COPY), while `deriveMessageId`
88
- * is folder-independent and gives them one local row. Taking the lowest bound
89
- * the fresh copy's row to an older copy's uid and orphaned the fresh one, which
90
- * a later delete or Empty Trash then expunged by the wrong uid (issue #1122).
91
- *
92
- * The maximum is computed rather than read off the tail: RFC 3501 leaves the
93
- * order of a SEARCH response unspecified, so "last returned" is not "highest".
94
- */
95
- export const searchMailboxForHighestMessageIdUid = async (
96
- connection: IImapConnection,
97
- mailboxPath: string,
98
- messageIdHeader: string,
99
- ): Promise<number | null> => {
100
- await connection.openBox(mailboxPath, true);
101
- const uids = await connection.search([
102
- ["HEADER", "Message-ID", messageIdHeader],
103
- ]);
104
- if (uids.length === 0) return null;
105
- return uids.reduce((highest, uid) => (uid > highest ? uid : highest));
106
- };
107
-
108
- /**
109
- * Which side of a paused mutation the server actually holds the message on.
110
- * `gone` is an answer both folders gave; `unprobeable` is the row that carries
111
- * no Message-ID header, where the server was never asked and its silence says
112
- * nothing.
113
- */
114
- export type PausedPlacement =
115
- | { kind: "at-source" }
116
- | { kind: "at-destination"; uid: number }
117
- | { kind: "gone" }
118
- | { kind: "unprobeable" };
119
-
120
- /**
121
- * Ask the server, by Message-ID, which folder holds a message whose mutation a
122
- * paused cursor interrupted (issue #1203).
123
- *
124
- * Reached only on a redelivery, where "the command never left" has stopped
125
- * being provable: the earlier attempt's tagged OK can be lost with the
126
- * connection, so restoring the source pair on that assumption would write a
127
- * settled placement naming a folder the server has already moved the mail out
128
- * of. `message-copy.ts` asks the same question for the same reason.
129
- *
130
- * The SOURCE is asked first and a hit ends it, the gate `confirmTrashMoveUid`
131
- * documents (#1122): where the mutation never ran, every hit at the destination
132
- * is an older copy of the same Message-ID — a sieve `fileinto` + `keep`, a
133
- * multi-label store, a resend — that `deriveMessageId` folds into this one local
134
- * row, and binding to it hands a later delete somebody else's uid.
135
- *
136
- * The source's silence, however, is not the mirror image of its hit. Every
137
- * caller here reaches this on a paused cursor, which means the source's
138
- * UIDVALIDITY has moved and the folder may have been recreated holding nothing
139
- * it held before — so "not at the source" is an answer about the folder, not
140
- * about our mutation, and `at-destination` is a sighting rather than a
141
- * confirmation. Callers settle it as unproven; see `settlePausedMove`.
142
- *
143
- * Identity, not position, is what makes this askable at all: a paused cursor
144
- * means the source's UIDVALIDITY has moved, so every stored uid for it names
145
- * something else, but a SEARCH by header is independent of the axis and answers
146
- * the same question the cursor rebuild itself matches rows by. For that reason
147
- * the caller passes the UNGUARDED connection — a `guardConnectionCursor` wrap
148
- * refuses to open the mailbox at all, and it is bound to the source's snapshot
149
- * anyway, so opening the destination through it would trip that mailbox too.
150
- */
151
- export const probePausedPlacement = async (
152
- connection: IImapConnection,
153
- input: {
154
- messageIdHeader: string | undefined;
155
- sourceMailboxPath: string;
156
- destinationMailboxPath: string | undefined;
157
- },
158
- ): Promise<PausedPlacement> => {
159
- const { messageIdHeader, sourceMailboxPath, destinationMailboxPath } = input;
160
- if (!messageIdHeader) return { kind: "unprobeable" };
161
-
162
- const atSource = await searchMailboxForHighestMessageIdUid(
163
- connection,
164
- sourceMailboxPath,
165
- messageIdHeader,
166
- );
167
- if (atSource !== null) return { kind: "at-source" };
168
-
169
- if (!destinationMailboxPath) return { kind: "gone" };
170
-
171
- const atDestination = await searchMailboxForHighestMessageIdUid(
172
- connection,
173
- destinationMailboxPath,
174
- messageIdHeader,
175
- );
176
- return atDestination === null
177
- ? { kind: "gone" }
178
- : { kind: "at-destination", uid: atDestination };
179
- };
180
-
181
- /**
182
- * Resync the affected folders only once the IMAP move has resolved. A move that
183
- * fails (or is retried) must not refresh counts off a move that didn't happen,
184
- * so the resync is sequenced strictly after `performMove`.
185
- */
186
- export const moveThenResync = async (
187
- performMove: () => Promise<void>,
188
- resync: () => Promise<void>,
189
- ): Promise<void> => {
190
- await performMove();
191
- await resync();
192
- };
193
-
194
- /**
195
- * Build the `set` and `composites` payload for the ThreadMessage update on a
196
- * MESSAGE_MOVE.
197
- *
198
- * The CURRENT row state goes in `composites`; the NEW values go in `set`.
199
- * ElectroDB uses `composites` to run the conditional check on the existing row
200
- * AND to compute the previous sort-key values needed to recompute the new ones.
201
- * Passing the NEW values in `composites` makes the conditional check fail with
202
- * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
203
- * the caller silently drops the update. Same root cause as PR #186 fixed for
204
- * `flag-queue.ts`.
205
- */
206
- export const buildThreadMessageMoveUpdate = (
207
- threadMessage: Pick<
208
- ThreadMessageItem,
209
- | "sentDate"
210
- | "mailboxId"
211
- | "isRead"
212
- | "isDeleted"
213
- | "hasStars"
214
- | "hasAttachment"
215
- >,
216
- newUid: number,
217
- destinationMailboxId: string,
218
- ) => ({
219
- set: {
220
- uid: newUid,
221
- mailboxId: destinationMailboxId,
222
- isDeleted: false,
223
- },
224
- composites: {
225
- sentDate: threadMessage.sentDate,
226
- mailboxId: threadMessage.mailboxId,
227
- isRead: threadMessage.isRead,
228
- isDeleted: threadMessage.isDeleted,
229
- hasStars: threadMessage.hasStars,
230
- hasAttachment: threadMessage.hasAttachment,
231
- },
232
- });
233
-
234
- /**
235
- * Handle MESSAGE_MOVE events.
236
- * Executes IMAP MOVE command and updates local state with new UID.
237
- *
238
- * A failing move retries on SQS redelivery until `receiveCount` reaches
239
- * {@link MESSAGE_MOVE_MAX_ATTEMPTS}, at which point
240
- * {@link resolveExhaustedMessageMoveFailure} asks IMAP where the message
241
- * actually is and settles the row into one terminal outcome (issue #655).
242
- * Before that, `syncStatus: failed` marked every attempt and nothing ever
243
- * settled the row, so an exhausted move sat `moving`/`failed` forever.
244
- */
245
- export const handleMessageMove = async (
246
- event: MessageMoveEvent,
247
- log: Logger,
248
- queues: WorkerQueues,
249
- receiveCount = 1,
250
- createConnectionScope = createConnectionScopeWithCredentials,
251
- ): Promise<void> => {
252
- const emit = createEmitEvent(queues);
253
- const {
254
- account: accountService,
255
- message: messageService,
256
- threadMessage: threadMessageService,
257
- mailbox: mailboxService,
258
- secrets,
259
- } = await getClient();
260
-
261
- const { accountId, messageId, sourceMailboxId, destinationMailboxId, uid } =
262
- event;
263
-
264
- log.info(
265
- {
266
- event: event.type,
267
- accountId,
268
- messageId,
269
- from: sourceMailboxId,
270
- to: destinationMailboxId,
271
- },
272
- "Handling event",
273
- );
274
-
275
- const account = await accountService.get(accountId);
276
- if (!account) {
277
- throw new Error(`Account ${accountId} not found`);
278
- }
279
-
280
- if (isAccountDeleted(account, log)) {
281
- return;
282
- }
283
-
284
- const [message] = await messageService.get([messageId]);
285
-
286
- // The message row is already gone — some other reconciliation path deleted
287
- // it. The move is moot; ack without touching IMAP.
288
- if (!message) {
289
- log.warn(
290
- { accountId, messageId },
291
- "Skipping MESSAGE_MOVE: message row no longer exists",
292
- );
293
- return;
294
- }
295
-
296
- // This move already settled — `updateUid` cleared `status: moving` once the
297
- // server confirmed it. A redelivery reaching here would MOVE a UID the
298
- // source no longer holds, fail, and on exhaustion read the source's honest
299
- // "gone" as grounds to reconcile away a row that is correct. There is no
300
- // marker to find missing (unlike FLAG_PUSH and PLACEMENT_MOVE_PUSH), so the
301
- // row's own pending marker is what stands in for one.
302
- //
303
- // `moving` specifically, not the shared unsettled predicate: the question is
304
- // whether THIS move is still outstanding, and a row a delete has since
305
- // claimed is `deleting` — work that belongs to MESSAGE_DELETE, and a state
306
- // this move must not read as its own to finish.
307
- if (message.status !== MessageStatus.moving) {
308
- log.info(
309
- { accountId, messageId, uid: message.uid, status: message.status },
310
- "Skipping MESSAGE_MOVE: the move already settled against confirmed IMAP state",
311
- );
312
- return;
313
- }
314
-
315
- await withOAuthLifecycle(
316
- buildLifecycleDeps(secrets, accountService),
317
- account,
318
- log,
319
- async (credentials) => {
320
- // The source folder can be deleted between enqueue and this sync, leaving
321
- // a queued event pointing at a gone row. The lookup then throws
322
- // NotFoundError forever, and on the account's per-group FIFO that head
323
- // message stalls the whole pipeline (issues #287, #289, #290). A deleted
324
- // source mailbox makes the move moot, but not the row: left `moving`, it
325
- // names the destination with a uid from a folder that no longer exists,
326
- // and nothing else settles it. It is reconciled away, and the destination
327
- // resync re-projects the message if the MOVE did land there.
328
- const source = await findMailboxRow(
329
- mailboxService,
330
- accountId,
331
- sourceMailboxId,
332
- );
333
- if (source.kind === "gone") {
334
- const reconciled = await messageService.deleteIfPlacement(messageId, {
335
- status: message.status,
336
- mailboxId: message.mailboxId,
337
- uid: message.uid,
338
- });
339
- log.warn(
340
- {
341
- metric: "message_move_source_gone",
342
- accountId,
343
- messageId,
344
- mailboxId: sourceMailboxId,
345
- reconciled,
346
- },
347
- "MESSAGE_MOVE not pushed: source mailbox no longer exists (deleted); stale row reconciled",
348
- );
349
- await emitMailboxResync(emit, {
350
- accountId,
351
- mailboxId: destinationMailboxId,
352
- });
353
- return;
354
- }
355
- const mailbox = source.mailbox;
356
- const sourceMailboxPath = mailbox.fullPath;
357
- const destination = await findMailboxRow(
358
- mailboxService,
359
- accountId,
360
- destinationMailboxId,
361
- );
362
-
363
- const settleMoved = async (newUid: number): Promise<void> => {
364
- await messageService.updateUid(messageId, newUid, destinationMailboxId);
365
-
366
- const threadMessage = await threadMessageService.findByMessageId(
367
- account.accountConfigId,
368
- messageId,
369
- );
370
- if (threadMessage) {
371
- const args = buildThreadMessageMoveUpdate(
372
- threadMessage,
373
- newUid,
374
- destinationMailboxId,
375
- );
376
- await threadMessageService.update(
377
- threadMessage.accountConfigId,
378
- threadMessage.threadMessageId,
379
- args.set,
380
- { composites: args.composites },
381
- );
382
- }
383
-
384
- log.info(
385
- {
386
- messageId,
387
- oldUid: uid,
388
- newUid,
389
- destination: destinationMailboxId,
390
- },
391
- "Message moved successfully",
392
- );
393
- };
394
-
395
- /**
396
- * Put the row back on the pair it was moved off, and say so once.
397
- *
398
- * `metric` is the routine give-up the guard doc calls never-a-fault:
399
- * the user's move was silently dropped, which is worth counting and
400
- * not worth waking anyone for. `alert` is the give-up the server did
401
- * not settle — the row carries `failed` and an operator adjudicates.
402
- */
403
- const handBackToSource = async (
404
- syncStatus: MessageItem["syncStatus"],
405
- signal: { alert: string } | { metric: string },
406
- reason: string,
407
- ): Promise<void> => {
408
- const context = {
409
- ...signal,
410
- accountId,
411
- messageId,
412
- uid,
413
- sourceMailboxId,
414
- destinationMailboxId,
415
- receiveCount,
416
- reason,
417
- };
418
- if ("alert" in signal) {
419
- log.error(
420
- context,
421
- "Move given up without a settled placement; row handed back to its source for adjudication",
422
- );
423
- } else {
424
- log.warn(
425
- context,
426
- "Move not pushed to the server; row handed back to its source",
427
- );
428
- }
429
-
430
- await restoreSourcePlacement(
431
- { messageService, threadMessageService },
432
- {
433
- accountConfigId: account.accountConfigId,
434
- messageId,
435
- sourceMailboxId,
436
- uid,
437
- syncStatus,
438
- // Every hand-back this handler makes is a move's. Naming it on
439
- // the row is what stops the reading pane calling a move that
440
- // gave up a failed delete (issue #1229).
441
- abandonedMutation:
442
- syncStatus === MessageSyncStatus.abandoned
443
- ? MessageMutation.move
444
- : MessageMutation.none,
445
- },
446
- );
447
- };
448
-
449
- if (destination.kind === "gone") {
450
- await handBackToSource(
451
- MessageSyncStatus.synced,
452
- { metric: "message_move_destination_gone" },
453
- "destination mailbox no longer exists (deleted)",
454
- );
455
- await emitMailboxResync(emit, {
456
- accountId,
457
- mailboxId: sourceMailboxId,
458
- });
459
- return;
460
- }
461
- const destinationMailboxPath = destination.mailbox.fullPath;
462
-
463
- const scope = createConnectionScope(account, credentials);
464
-
465
- // The terminal resolver opens the source itself, and its answer now
466
- // WRITES a placement, so it has to reach IMAP through the same
467
- // UIDVALIDITY choke point every other outbound operation does. An
468
- // unguarded probe on a folder that was deleted and recreated finds
469
- // whatever the server has renumbered onto this uid, reads it as "still
470
- // at source", and binds the row to a stranger the next permanent delete
471
- // would expunge.
472
- const getGuardedConnection = async (): Promise<IImapConnection> =>
473
- guardConnectionCursor(
474
- await scope.getConnection(),
475
- { mailboxService },
476
- accountId,
477
- mailbox,
478
- );
479
-
480
- // A paused cursor is never acked on the optimistic row: the transition
481
- // has pointed it at the destination while `uid` still names the source,
482
- // and that pair strands the row for good — the cursor rebuild matches
483
- // rows by `(accountConfigId, mailboxId)`, so a row naming the
484
- // destination is in neither folder's set, and nothing re-enqueues a
485
- // MESSAGE_MOVE (issue #1203).
486
- //
487
- // This move therefore reconciles rather than waits (R2,
488
- // docs/architecture/imap-mutations.md): the row is settled onto
489
- // whichever pair the server can be shown to hold, and the resync below
490
- // plus the source's own cursor rebuild are its repair path.
491
- //
492
- // Putting the row back is an undo of this product's own write only where
493
- // the MOVE provably never left. `commandIssued` says what this round
494
- // did; `receiveCount` adds what an earlier one may have done and lost
495
- // with its connection. Inferring the first from the second is what
496
- // `settlePausedDelete` got wrong — attempt 1 reaches an exhausted settle
497
- // too, with the MOVE already issued.
498
- const settlePausedMove = async (
499
- commandIssued: boolean,
500
- ): Promise<void> => {
501
- const mayHaveIssued = commandIssued || receiveCount > 1;
502
-
503
- const placement: PausedPlacement = mayHaveIssued
504
- ? await probePausedPlacement(await scope.getConnection(), {
505
- messageIdHeader: await probeableMessageIdHeader(
506
- threadMessageService,
507
- account.accountConfigId,
508
- messageId,
509
- ),
510
- sourceMailboxPath,
511
- destinationMailboxPath,
512
- })
513
- : { kind: "at-source" };
514
-
515
- // A sighting at the destination is not proof this move ran. The pause
516
- // means the source's UIDVALIDITY has moved, so its "no" can be a
517
- // recreated folder answering about mail it never held, and the hit can
518
- // be an older copy of the same Message-ID that `deriveMessageId` folds
519
- // into this row — settling `moved` on it points the row at mail a
520
- // later permanent delete would expunge. `unprobeable` is silence, and
521
- // silence settles nothing either (the rule `message-copy.ts` already
522
- // applies). Both hand the row back on the source pair carrying
523
- // `failed`: that is the set the source's own cursor rebuild walks and
524
- // adjudicates by Message-ID, which is the reconcile path (R2) the
525
- // destination — not paused, no rebuild coming — does not have.
526
- const unproven =
527
- placement.kind === "at-destination" ||
528
- placement.kind === "unprobeable";
529
-
530
- if (unproven) {
531
- await handBackToSource(
532
- MessageSyncStatus.abandoned,
533
- { alert: "message_move_paused_placement_unproven" },
534
- placement.kind === "at-destination"
535
- ? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
536
- : "no Message-ID header to ask either folder with",
537
- );
538
- } else {
539
- // `gone` lands here with `at-source`: the source pair is the set the
540
- // rebuild walks, and a row it cannot match against a fresh envelope
541
- // snapshot is the one thing it reconciles away.
542
- await handBackToSource(
543
- MessageSyncStatus.synced,
544
- { metric: "message_move_dropped_on_pause" },
545
- `source mailbox cursor paused, placement ${placement.kind}`,
546
- );
547
- }
548
-
549
- await emitMoveResync(emit, {
550
- accountId,
551
- sourceMailboxId,
552
- destinationMailboxId,
553
- });
554
- };
555
-
556
- // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
557
- // paused never opens a connection on a first delivery. Optimization
558
- // only — the guardConnectionCursor openBox wrap below is the structural
559
- // guarantee.
560
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
561
- log.info(
562
- { accountId, messageId, mailboxId: sourceMailboxId },
563
- "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
564
- );
565
- await settlePausedMove(false).finally(() => scope.disconnect());
566
- return;
567
- }
568
-
569
- // Flipped the instant the MOVE leaves, so every settle downstream of it
570
- // knows the server was asked.
571
- let commandIssued = false;
572
-
573
- await scope
574
- .getConnection()
575
- .then((rawConnection) => {
576
- // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
577
- // a fresh mismatch trips the mailbox and throws once the SELECT
578
- // reveals it. The move stays applied locally either way.
579
- const connection = guardConnectionCursor(
580
- rawConnection,
581
- { mailboxService },
582
- accountId,
583
- mailbox,
584
- );
585
- return moveThenResync(
586
- async () => {
587
- // Open source mailbox (not read-only)
588
- await connection.openBox(sourceMailboxPath, false);
589
-
590
- // Execute IMAP MOVE
591
- commandIssued = true;
592
- const result = await connection.moveMessages(
593
- [uid],
594
- destinationMailboxPath,
595
- );
596
-
597
- // Get new UID from COPYUID response. A server without UIDPLUS
598
- // answers a perfectly successful MOVE with no COPYUID entry,
599
- // so an empty map is UNCONFIRMED, never evidence the message
600
- // is gone: the destination is asked by Message-ID before any
601
- // verdict, exactly as `attemptMove` does. Marking the row
602
- // `failed` and returning (the behaviour issue #655 opens on)
603
- // left it `moving`/`failed` with no DLQ entry and no metric,
604
- // because a handler that returns never redelivers.
605
- const newUid =
606
- result.uidMap.get(uid) ??
607
- (await probeableMessageIdHeader(
608
- threadMessageService,
609
- account.accountConfigId,
610
- messageId,
611
- ).then((messageIdHeader) =>
612
- messageIdHeader
613
- ? searchMailboxForHighestMessageIdUid(
614
- rawConnection,
615
- destinationMailboxPath,
616
- messageIdHeader,
617
- )
618
- : null,
619
- ));
620
-
621
- if (!newUid) {
622
- throw new Error(
623
- `Message move unconfirmed (no COPYUID entry, not found at ${destinationMailboxPath}) — retrying`,
624
- );
625
- }
626
-
627
- await settleMoved(newUid);
628
- },
629
- () =>
630
- emitMoveResync(emit, {
631
- accountId,
632
- sourceMailboxId,
633
- destinationMailboxId,
634
- }),
635
- );
636
- })
637
- .catch(async (error: unknown) => {
638
- if (error instanceof MailboxCursorPausedError) {
639
- log.info(
640
- {
641
- accountId,
642
- messageId,
643
- mailboxId: sourceMailboxId,
644
- cursorState: error.state,
645
- },
646
- "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
647
- );
648
- await settlePausedMove(commandIssued);
649
- return;
650
- }
651
-
652
- const errorMessage =
653
- error instanceof Error ? error.message : String(error);
654
-
655
- // Handle TRYCREATE - destination doesn't exist
656
- if (errorMessage.includes("TRYCREATE")) {
657
- log.info(
658
- { destinationMailboxPath },
659
- "Destination mailbox doesn't exist, creating",
660
- );
661
- const connection = await scope.getConnection();
662
- await connection.createMailbox(destinationMailboxPath);
663
- // Re-throw to let the event be retried against the folder just
664
- // created. Kept unconditional past the attempt budget: the
665
- // destination now exists, so the record is worth redriving from
666
- // the DLQ, and the terminal resolver has nothing to settle — it
667
- // would find the message exactly where it started.
668
- throw error;
669
- }
670
-
671
- if (receiveCount < MESSAGE_MOVE_MAX_ATTEMPTS) {
672
- // Transient move failure — expected (connections drop). No
673
- // alarm; queue redelivery retries, and `failed` marks the row
674
- // as unsettled meanwhile. It is not a terminal signal: only the
675
- // resolver below settles anything.
676
- await messageService.transitionPlacement(
677
- messageId,
678
- { status: MessageStatus.moving },
679
- { syncStatus: MessageSyncStatus.failed },
680
- );
681
- throw error;
682
- }
683
-
684
- // Redelivery budget exhausted: resolve into exactly one of the two
685
- // terminal outcomes instead of dead-lettering with no diagnosis,
686
- // and never by inferring the server's state from our own failures.
687
- const settled = await resolveExhaustedMessageMoveFailure(
688
- { messageService, threadMessageService, log },
689
- {
690
- placement: {
691
- status: message.status,
692
- mailboxId: message.mailboxId,
693
- uid: message.uid,
694
- },
695
- accountId,
696
- accountConfigId: account.accountConfigId,
697
- messageId,
698
- sourceMailboxId,
699
- uid,
700
- sourceMailboxPath,
701
- getConnection: getGuardedConnection,
702
- },
703
- ).catch(async (settleError: unknown) => {
704
- // The guarded probe found a mailbox whose UIDVALIDITY has moved.
705
- // Nothing may be settled off a uid on a dead axis, and the pause
706
- // is the routine skip `guardMailboxCursor` documents, not a fault
707
- // to re-throw out of this catch. The row is still settled, on the
708
- // identity axis instead: `settlePausedMove` asks by Message-ID,
709
- // which a UIDVALIDITY change does not invalidate.
710
- if (settleError instanceof MailboxCursorPausedError) {
711
- log.info(
712
- {
713
- accountId,
714
- messageId,
715
- mailboxId: sourceMailboxId,
716
- cursorState: settleError.state,
717
- },
718
- "Mailbox cursor not normal; settling the exhausted move against the server by Message-ID",
719
- );
720
- await settlePausedMove(commandIssued);
721
- return null;
722
- }
723
- throw settleError;
724
- });
725
-
726
- if (!settled) return;
727
-
728
- if (settled.outcome === "broken") {
729
- // Terminal and never re-thrown, so the handler-outcome series
730
- // records this record as a success. Counted here or it is
731
- // invisible.
732
- recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
733
- log.error(
734
- { error: errorMessage },
735
- "Message move retry exhausted; message still exists at its source",
736
- );
737
- }
738
-
739
- // Both verdicts end in a row the server has contradicted, so this
740
- // move reconciles rather than waits (R2). RECONCILED, the local rows
741
- // are gone and whichever folder actually holds the message
742
- // re-projects it with the server's own UID. BROKEN, the row has just
743
- // been put back at the source the server confirmed, and the resync
744
- // is what carries any drift either folder has picked up since.
745
- await emitMoveResync(emit, {
746
- accountId,
747
- sourceMailboxId,
748
- destinationMailboxId,
749
- });
750
- // Terminal — never re-thrown, so the caller acks either way.
751
- })
752
- .finally(() => scope.disconnect());
753
- },
754
- );
755
- };