@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,569 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import {
3
- MessageMutation,
4
- MessageStatus,
5
- MessageSyncStatus,
6
- } from "@remit/domain-enums";
7
- import type { Logger } from "@remit/logger";
8
- import {
9
- guardConnectionCursor,
10
- type IImapConnection,
11
- isCursorRebuildNeeded,
12
- MailboxCursorPausedError,
13
- } from "@remit/mailbox-service";
14
- import { attemptBudget } from "@remit/sqs-client/attempt-budget";
15
- import { isAccountDeleted } from "../account-check.js";
16
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
17
- import { createEmitEvent } from "../emit.js";
18
- import type { MessageCopyEvent } from "../events.js";
19
- import { findMailboxRow } from "../mailbox-row.js";
20
- import type { WorkerQueues } from "../queues.js";
21
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
22
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
23
- import {
24
- emitMoveResync,
25
- searchMailboxForHighestMessageIdUid,
26
- } from "./message-move.js";
27
- import { probeableMessageIdHeader } from "./probe-message-id.js";
28
-
29
- export const getMessageCopyMaxAttempts = (
30
- processEnv: NodeJS.ProcessEnv = process.env,
31
- ): number => attemptBudget("MESSAGE_COPY_MAX_ATTEMPTS", 3, processEnv);
32
-
33
- export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
34
-
35
- /**
36
- * Where the destination says this copy is. `unprobeable` is the row that
37
- * carries no usable Message-ID header (drafts, automated senders and bounces
38
- * do arrive without one): the server was never
39
- * asked, so its silence says nothing.
40
- */
41
- export type CopyProbe =
42
- | { kind: "confirmed"; uid: number }
43
- | { kind: "absent" }
44
- | { kind: "unprobeable" };
45
-
46
- /**
47
- * Handle MESSAGE_COPY events.
48
- * Executes IMAP COPY command and updates local state with new UID.
49
- *
50
- * IMAP COPY is not idempotent and the message queue is FIFO per account, so
51
- * this handler never manufactures a retry to resolve an ambiguous answer: a
52
- * second COPY duplicates the mail, and a record that keeps coming back blocks
53
- * every later move, copy and delete for that account (issues #287, #289,
54
- * #290). Ambiguity is resolved in place, by asking the destination.
55
- *
56
- * A fault the server threw is still retried up to
57
- * {@link MESSAGE_COPY_MAX_ATTEMPTS}, because it may be a dropped connection.
58
- * Two guards make that safe: a redelivery asks the destination BEFORE issuing
59
- * COPY again, and the last attempt settles the row instead of dead-lettering
60
- * it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
61
- * its ThreadMessage row at `uid: 0` before the server has confirmed anything,
62
- * docs/architecture/imap-mutations.md R2) and these terminal settles are its
63
- * reconciliation path: every verdict the server gives an answer for settles the
64
- * row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
65
- * gives no verdict — the record dead-letters with the row untouched, the same
66
- * caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
67
- * ever concluded from an answer.
68
- */
69
- export const handleMessageCopy = async (
70
- event: MessageCopyEvent,
71
- log: Logger,
72
- queues: WorkerQueues,
73
- receiveCount = 1,
74
- createConnectionScope = createConnectionScopeWithCredentials,
75
- ): Promise<void> => {
76
- const emit = createEmitEvent(queues);
77
- const {
78
- account: accountService,
79
- message: messageService,
80
- threadMessage: threadMessageService,
81
- mailbox: mailboxService,
82
- secrets,
83
- } = await getClient();
84
-
85
- const {
86
- accountId,
87
- sourceMessageId,
88
- newMessageId,
89
- sourceMailboxId,
90
- destinationMailboxId,
91
- uid,
92
- } = event;
93
-
94
- log.info(
95
- {
96
- event: event.type,
97
- accountId,
98
- sourceMessageId,
99
- newMessageId,
100
- from: sourceMailboxId,
101
- to: destinationMailboxId,
102
- },
103
- "Handling event",
104
- );
105
-
106
- const account = await accountService.get(accountId);
107
- if (!account) {
108
- throw new Error(`Account ${accountId} not found`);
109
- }
110
-
111
- if (isAccountDeleted(account, log)) {
112
- return;
113
- }
114
-
115
- const [copyRow] = await messageService.get([newMessageId]);
116
-
117
- // The copy row is already gone — a reconciliation path settled it. Nothing
118
- // left to bind a UID to; ack.
119
- if (!copyRow) {
120
- log.warn(
121
- { accountId, sourceMessageId, newMessageId },
122
- "Skipping MESSAGE_COPY: copy row no longer exists",
123
- );
124
- return;
125
- }
126
-
127
- // This copy already settled — `updateUid` cleared `status: moving` when the
128
- // server confirmed it. A redelivery reaching here would COPY the message a
129
- // second time, and COPY has no source-side effect to make that a no-op.
130
- //
131
- // `moving` specifically, not the shared unsettled predicate: the question is
132
- // whether THIS copy is still outstanding, and a `deleting` row is somebody
133
- // else's work — reading it as unsettled is what would let the second COPY
134
- // through.
135
- const copyPlacement = {
136
- status: copyRow.status,
137
- mailboxId: copyRow.mailboxId,
138
- uid: copyRow.uid,
139
- };
140
- if (copyRow.status !== MessageStatus.moving) {
141
- log.info(
142
- { accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
143
- "Skipping MESSAGE_COPY: the copy already settled against confirmed IMAP state",
144
- );
145
- return;
146
- }
147
-
148
- // `failed` is an earlier attempt's mark, and the re-drive's: either may
149
- // have issued a COPY whose answer was lost.
150
- const earlierAttempt =
151
- receiveCount > 1 || copyRow.syncStatus === MessageSyncStatus.failed;
152
-
153
- await withOAuthLifecycle(
154
- buildLifecycleDeps(secrets, accountService),
155
- account,
156
- log,
157
- async (credentials) => {
158
- // The source folder can be deleted between enqueue and this sync, leaving
159
- // a queued event pointing at a gone row. The lookup then throws
160
- // NotFoundError forever, and on the account's per-group FIFO that head
161
- // message stalls the whole pipeline (issues #287, #289, #290). A deleted
162
- // source mailbox makes the copy moot: ack with a WARN.
163
- const source = await findMailboxRow(
164
- mailboxService,
165
- accountId,
166
- sourceMailboxId,
167
- );
168
- if (source.kind === "gone") {
169
- log.warn(
170
- { accountId, sourceMessageId, mailboxId: sourceMailboxId },
171
- "Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)",
172
- );
173
- return;
174
- }
175
- const mailbox = source.mailbox;
176
- const sourceMailboxPath = mailbox.fullPath;
177
-
178
- const destination = await findMailboxRow(
179
- mailboxService,
180
- accountId,
181
- destinationMailboxId,
182
- );
183
- if (destination.kind === "gone") {
184
- const reconciled = await messageService.deleteIfPlacement(
185
- newMessageId,
186
- copyPlacement,
187
- );
188
- log.warn(
189
- {
190
- metric: "message_copy_destination_gone",
191
- accountId,
192
- sourceMessageId,
193
- newMessageId,
194
- mailboxId: destinationMailboxId,
195
- reconciled,
196
- },
197
- "Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away",
198
- );
199
- return;
200
- }
201
- const destinationMailboxPath = destination.mailbox.fullPath;
202
-
203
- // The copy row carries the source's Message-ID header, so the
204
- // destination can be asked where this copy is. Always on the UNGUARDED
205
- // handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
206
- // snapshot alone, and opening the destination through it trips that
207
- // mailbox into a rebuild.
208
- const probeDestination = async (
209
- connection: IImapConnection,
210
- ): Promise<CopyProbe> => {
211
- const messageIdHeader = await probeableMessageIdHeader(
212
- threadMessageService,
213
- account.accountConfigId,
214
- newMessageId,
215
- );
216
- if (!messageIdHeader) return { kind: "unprobeable" };
217
- const probedUid = await searchMailboxForHighestMessageIdUid(
218
- connection,
219
- destinationMailboxPath,
220
- messageIdHeader,
221
- );
222
- return probedUid === null
223
- ? { kind: "absent" }
224
- : { kind: "confirmed", uid: probedUid };
225
- };
226
-
227
- const settleCopied = async (newUid: number): Promise<void> => {
228
- // `updateUid` writes the confirmed uid, `active` and `synced` in one
229
- // statement, so the second write this used to make said nothing the
230
- // first had not — and said it without a predicate.
231
- await messageService.updateUid(
232
- newMessageId,
233
- newUid,
234
- destinationMailboxId,
235
- );
236
-
237
- const threadMessage = await threadMessageService.findByMessageId(
238
- account.accountConfigId,
239
- newMessageId,
240
- );
241
- if (threadMessage) {
242
- await threadMessageService.update(
243
- threadMessage.accountConfigId,
244
- threadMessage.threadMessageId,
245
- { uid: newUid },
246
- {
247
- composites: {
248
- sentDate: threadMessage.sentDate,
249
- mailboxId: threadMessage.mailboxId,
250
- isRead: threadMessage.isRead,
251
- isDeleted: threadMessage.isDeleted,
252
- hasStars: threadMessage.hasStars,
253
- hasAttachment: threadMessage.hasAttachment,
254
- },
255
- },
256
- );
257
- }
258
-
259
- log.info(
260
- {
261
- sourceMessageId,
262
- newMessageId,
263
- oldUid: uid,
264
- newUid,
265
- destination: destinationMailboxPath,
266
- },
267
- "Message copied successfully",
268
- );
269
- };
270
-
271
- // The destination answered that it does not hold this message, so the
272
- // copy never landed and the optimistic row records something the server
273
- // does not have. Deleting it (with its ThreadMessage rows) is the exact
274
- // reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
275
- // calls `reconciled`. Routine: metric, no alarm.
276
- const settleNeverLanded = async (reason: string): Promise<void> => {
277
- const reconciled = await messageService.deleteIfPlacement(
278
- newMessageId,
279
- copyPlacement,
280
- );
281
- log.info(
282
- {
283
- metric: "message_copy_not_landed",
284
- accountId,
285
- sourceMessageId,
286
- newMessageId,
287
- destination: destinationMailboxPath,
288
- reconciled,
289
- reason,
290
- },
291
- "Copy absent from its destination; optimistic copy row reconciled away",
292
- );
293
- };
294
-
295
- // The server never answered where the copy is, so nothing here may
296
- // delete a row that might describe real mail. The row is settled out of
297
- // `moving` instead — `status: deleted` is the state this handler already
298
- // writes for a copy that will not happen, and it keeps `holdsCopyOf`
299
- // answering true so a sighting at the destination cannot drag the SOURCE
300
- // row out of the folder it was copied from.
301
- //
302
- // The cost, paid deliberately: if that COPY did land, this row is the
303
- // only one the copy will ever have — `holdsCopyOf` tests row existence
304
- // alone (`message-sync.ts`), so no sync creates a second one — and
305
- // `deleted` hides it. Real mail then sits at the destination on the
306
- // server and invisible in the product until an operator acts on the
307
- // alert. `settleNeverLanded` has the opposite property: it deletes only
308
- // what the server denied holding, and a later sync re-projects anything
309
- // that turns out to be there. Silence buys the safer half of one pair
310
- // and the worse half of the other.
311
- const settleBroken = async (reason: string): Promise<void> => {
312
- // A transition, not a plain update (imap-mutations R3): the copy row
313
- // is only this handler's to settle while its own placement is still
314
- // unsettled. The row it leaves is `deleted`, so no listing carries it
315
- // and the marker is read by nothing — it is written anyway, because a
316
- // give-up that does not name itself is the gap #1229 came from.
317
- await messageService.transitionPlacement(
318
- newMessageId,
319
- { status: MessageStatus.moving },
320
- {
321
- status: MessageStatus.deleted,
322
- syncStatus: MessageSyncStatus.abandoned,
323
- abandonedMutation: MessageMutation.copy,
324
- },
325
- );
326
- log.error(
327
- {
328
- alert: "message_copy_unconfirmed",
329
- accountId,
330
- sourceMessageId,
331
- newMessageId,
332
- uid,
333
- destination: destinationMailboxPath,
334
- reason,
335
- },
336
- "Copy could not be bound to a destination UID; row settled failed for operator investigation",
337
- );
338
- };
339
-
340
- const scope = createConnectionScope(account, credentials);
341
-
342
- // A paused cursor settles rather than acks: acking leaves the optimistic
343
- // row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
344
- // set for the cursor rebuild to find it in (issue #1203). Like every
345
- // other verdict on this row, it reconciles rather than waits (R2,
346
- // docs/architecture/imap-mutations.md) — the settle below IS the
347
- // reconciliation path, since the destination is not paused and no
348
- // rebuild is coming for it.
349
- //
350
- // What may be concluded from the pause depends on what was issued, and
351
- // `receiveCount` alone does not say: `commandIssued` is what this round
352
- // did, a redelivery is what an earlier one may have done and lost with
353
- // its connection. Where neither says the server may have been asked, the
354
- // row records something the server was never told about and reconciling
355
- // it away is exact. Otherwise this row may be the only record real mail
356
- // will ever have, and deleting it would let the next destination sync
357
- // repoint the SOURCE row out of the folder the server still holds it in.
358
- // The pause is on the source and says nothing about the destination,
359
- // which is probed on the unguarded handle anyway, so the destination is
360
- // asked before anything is deleted and silence settles broken.
361
- //
362
- // A settle is a settle, so both folders re-read their counts from IMAP,
363
- // the same resync the move and delete handlers run: the destination is
364
- // not paused, so nothing else is coming to re-project it.
365
- const settlePausedCopy = async (
366
- commandIssued: boolean,
367
- ): Promise<void> => {
368
- const mayHaveIssued = commandIssued || earlierAttempt;
369
-
370
- if (!mayHaveIssued) {
371
- await settleNeverLanded(
372
- "source mailbox cursor paused before the copy",
373
- );
374
- } else {
375
- const probe = await probeDestination(await scope.getConnection());
376
- if (probe.kind === "confirmed") {
377
- await settleCopied(probe.uid);
378
- } else if (probe.kind === "absent") {
379
- await settleNeverLanded(
380
- "redelivered onto a paused source cursor, absent from destination",
381
- );
382
- } else {
383
- await settleBroken(
384
- "redelivered onto a paused source cursor with no Message-ID to ask the destination with",
385
- );
386
- }
387
- }
388
-
389
- await emitMoveResync(emit, {
390
- accountId,
391
- sourceMailboxId,
392
- destinationMailboxId,
393
- });
394
- };
395
-
396
- // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
397
- // paused never even opens a connection on a first delivery. Optimization
398
- // only — the guardConnectionCursor openBox wrap below is the structural
399
- // guarantee.
400
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
401
- log.info(
402
- { accountId, sourceMessageId, mailboxId: sourceMailboxId },
403
- "Mailbox cursor not normal; pausing outbound copy this round",
404
- );
405
- await settlePausedCopy(false).finally(() => scope.disconnect());
406
- return;
407
- }
408
-
409
- // Flipped the instant the COPY leaves, so every settle downstream of it
410
- // knows the server was asked. IMAP COPY is not idempotent, so this is
411
- // the flag that must never be inferred.
412
- let commandIssued = false;
413
-
414
- await scope
415
- .getConnection()
416
- .then(async (rawConnection) => {
417
- // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
418
- // a fresh mismatch trips the mailbox and throws once the SELECT
419
- // reveals it.
420
- const connection = guardConnectionCursor(
421
- rawConnection,
422
- { mailboxService },
423
- accountId,
424
- mailbox,
425
- );
426
-
427
- // A redelivery means the previous attempt threw, which does not
428
- // mean its COPY failed — the tagged OK can be lost with the
429
- // connection. Ask before copying again, or the retry is what
430
- // duplicates the mail. `absent` is an answer the server gave, so
431
- // copying again is safe; `unprobeable` is silence, and copying on
432
- // silence is the duplicate this guard exists to prevent.
433
- if (earlierAttempt) {
434
- const earlier = await probeDestination(rawConnection);
435
- if (earlier.kind === "confirmed") {
436
- await settleCopied(earlier.uid);
437
- return;
438
- }
439
- if (earlier.kind === "unprobeable") {
440
- await settleBroken(
441
- "redelivered with no Message-ID to ask the destination with",
442
- );
443
- return;
444
- }
445
- }
446
-
447
- // Open source mailbox (read-only is fine for COPY)
448
- await connection.openBox(sourceMailboxPath, true);
449
-
450
- // Execute IMAP COPY
451
- commandIssued = true;
452
- const result = await connection.copyMessages(
453
- [uid],
454
- destinationMailboxPath,
455
- );
456
-
457
- const copiedUid = result.uidMap.get(uid);
458
- if (copiedUid) {
459
- await settleCopied(copiedUid);
460
- return;
461
- }
462
-
463
- // A server without UIDPLUS answers a perfectly successful COPY
464
- // with no COPYUID entry, so an empty map is UNCONFIRMED, never
465
- // evidence the server matched nothing (#1097). The destination is
466
- // asked before any verdict, as `message-move.ts` does (#912, #979).
467
- const probe = await probeDestination(rawConnection);
468
- if (probe.kind === "confirmed") {
469
- await settleCopied(probe.uid);
470
- return;
471
- }
472
- if (probe.kind === "absent") {
473
- await settleNeverLanded(
474
- "no COPYUID entry, absent from destination",
475
- );
476
- return;
477
- }
478
- await settleBroken(
479
- "no COPYUID entry and no Message-ID to probe with",
480
- );
481
- })
482
- .catch(async (error: unknown) => {
483
- if (error instanceof MailboxCursorPausedError) {
484
- log.info(
485
- {
486
- accountId,
487
- sourceMessageId,
488
- mailboxId: sourceMailboxId,
489
- cursorState: error.state,
490
- },
491
- "Mailbox cursor not normal; pausing outbound copy this round",
492
- );
493
- await settlePausedCopy(commandIssued);
494
- return;
495
- }
496
-
497
- const errorMessage =
498
- error instanceof Error ? error.message : String(error);
499
-
500
- // Handle TRYCREATE - destination doesn't exist
501
- if (errorMessage.includes("TRYCREATE")) {
502
- log.info(
503
- { destinationMailboxPath },
504
- "Destination mailbox doesn't exist, creating",
505
- );
506
- const connection = await scope.getConnection();
507
- await connection.createMailbox(destinationMailboxPath);
508
- // Re-throw to let the event be retried against the folder just
509
- // created. Kept unconditional past the attempt budget, as the
510
- // move path does: the destination now exists, so the record is
511
- // worth redriving from the DLQ.
512
- throw error;
513
- }
514
-
515
- // Handle source message not found on IMAP - already deleted (fail the copy)
516
- if (
517
- errorMessage.includes("not found") ||
518
- errorMessage.includes("NONEXISTENT")
519
- ) {
520
- log.info(
521
- { sourceMessageId, uid },
522
- "Source message not found on IMAP, marking copy as failed",
523
- );
524
- await messageService.transitionPlacement(
525
- newMessageId,
526
- { status: MessageStatus.moving },
527
- {
528
- status: MessageStatus.deleted,
529
- syncStatus: MessageSyncStatus.abandoned,
530
- abandonedMutation: MessageMutation.copy,
531
- },
532
- );
533
- return;
534
- }
535
-
536
- if (receiveCount < MESSAGE_COPY_MAX_ATTEMPTS) {
537
- // Transient copy failure — expected (connections drop). Queue
538
- // redelivery retries, and the probe above keeps the retry from
539
- // copying the message twice.
540
- await messageService.transitionPlacement(
541
- newMessageId,
542
- { status: MessageStatus.moving },
543
- { syncStatus: MessageSyncStatus.failed },
544
- );
545
- throw error;
546
- }
547
-
548
- // Redelivery budget exhausted. Dead-lettering here would leave the
549
- // row at `uid: 0`/`moving` with nothing left to settle it and the
550
- // account's FIFO group still holding the record, so the row is
551
- // resolved into one terminal outcome instead — never by inferring
552
- // the server's state from our own failures. A server that cannot be
553
- // reached reaches no verdict: the probe throws and the record
554
- // dead-letters with the row untouched.
555
- const probe = await probeDestination(await scope.getConnection());
556
- if (probe.kind === "confirmed") {
557
- await settleCopied(probe.uid);
558
- return;
559
- }
560
- if (probe.kind === "absent") {
561
- await settleNeverLanded(`retry exhausted: ${errorMessage}`);
562
- return;
563
- }
564
- await settleBroken(`retry exhausted: ${errorMessage}`);
565
- })
566
- .finally(() => scope.disconnect());
567
- },
568
- );
569
- };