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