@remit/imap-worker 0.0.90 → 0.0.92

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 (176) 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 +22 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +29 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
  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/dist/scheduler/handler.js +50 -0
  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 +66 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/dist/scheduler/runner.js +102 -0
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/scheduler/tick.d.ts +5 -0
  92. package/dist/scheduler/tick.js +5 -0
  93. package/dist/sqs-queues.d.ts +4 -0
  94. package/dist/sqs-queues.js +22 -0
  95. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  96. package/dist/with-oauth-lifecycle-deps.js +17 -0
  97. package/dist/with-oauth-lifecycle.d.ts +56 -0
  98. package/dist/with-oauth-lifecycle.js +92 -0
  99. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  100. package/dist/worker-version.js +12 -0
  101. package/package.json +79 -69
  102. package/src/account-check.test.ts +0 -149
  103. package/src/account-check.ts +0 -101
  104. package/src/body-sync-gate.test.ts +0 -185
  105. package/src/body-sync-gate.ts +0 -86
  106. package/src/cli.ts +0 -218
  107. package/src/connection-scope.test.ts +0 -266
  108. package/src/connection-scope.ts +0 -314
  109. package/src/credential-less-sync-fence.test.ts +0 -152
  110. package/src/e2e-processor-shim.ts +0 -232
  111. package/src/emit.test.ts +0 -145
  112. package/src/emit.ts +0 -115
  113. package/src/events.ts +0 -252
  114. package/src/failure-kind.test.ts +0 -42
  115. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  116. package/src/handlers/append-sent-message.ts +0 -264
  117. package/src/handlers/delete-account-objects.test.ts +0 -126
  118. package/src/handlers/delete-account-objects.ts +0 -96
  119. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  120. package/src/handlers/empty-trash.ts +0 -352
  121. package/src/handlers/flag-push.ts +0 -285
  122. package/src/handlers/imported-folder.test.ts +0 -63
  123. package/src/handlers/imported-folder.ts +0 -38
  124. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  125. package/src/handlers/mailbox-management.ts +0 -486
  126. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  127. package/src/handlers/mailbox-sync-order.ts +0 -65
  128. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  129. package/src/handlers/message-copy.ts +0 -569
  130. package/src/handlers/message-delete-terminal.ts +0 -147
  131. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  132. package/src/handlers/message-delete.ts +0 -888
  133. package/src/handlers/message-move.sqlite.test.ts +0 -271
  134. package/src/handlers/message-move.test.ts +0 -216
  135. package/src/handlers/message-move.ts +0 -755
  136. package/src/handlers/placement-move-push.test.ts +0 -137
  137. package/src/handlers/placement-move-push.ts +0 -478
  138. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  139. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  140. package/src/handlers/probe-message-id.ts +0 -15
  141. package/src/handlers/sync-mailboxes.test.ts +0 -183
  142. package/src/handlers/sync-mailboxes.ts +0 -425
  143. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  144. package/src/handlers/sync-message-body.test.ts +0 -150
  145. package/src/handlers/sync-message-body.ts +0 -366
  146. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  147. package/src/handlers/sync-messages.ts +0 -537
  148. package/src/index.test.ts +0 -22
  149. package/src/index.ts +0 -82
  150. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  151. package/src/mailbox-row.ts +0 -20
  152. package/src/pending-sync.sqlite.test.ts +0 -165
  153. package/src/poller.ts +0 -55
  154. package/src/processor.ts +0 -62
  155. package/src/queues.ts +0 -40
  156. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  157. package/src/scheduler/attachment-sweep.ts +0 -46
  158. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  159. package/src/scheduler/calendar-subscriptions.ts +0 -103
  160. package/src/scheduler/config.test.ts +0 -40
  161. package/src/scheduler/decide-due.test.ts +0 -44
  162. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  163. package/src/scheduler/handler.ts +0 -69
  164. package/src/scheduler/loop.test.ts +0 -143
  165. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  166. package/src/scheduler/run-tick.ts +0 -281
  167. package/src/scheduler/runner.ts +0 -125
  168. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  169. package/src/stranded-placement.sqlite.test.ts +0 -799
  170. package/src/test-helpers/test-world.ts +0 -425
  171. package/src/test-helpers/worker-queues.ts +0 -11
  172. package/src/thread-join.sqlite.test.ts +0 -106
  173. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  174. package/src/with-oauth-lifecycle-deps.ts +0 -71
  175. package/src/with-oauth-lifecycle.ts +0 -158
  176. package/tsconfig.json +0 -8
@@ -1,888 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import type {
3
- IMessageRepository,
4
- IThreadMessageRepository,
5
- MessageItem,
6
- } from "@remit/data-ports";
7
- import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
8
- import {
9
- MessageMutation,
10
- MessageStatus,
11
- MessageSyncStatus,
12
- } from "@remit/domain-enums";
13
- import type { Logger } from "@remit/logger";
14
- import { recordImapFailure } from "@remit/logger-lambda/metrics";
15
- import {
16
- buildThreadMessageTrashUpdate,
17
- guardConnectionCursor,
18
- type IImapConnection,
19
- isCursorRebuildNeeded,
20
- isMessageGoneFromOpenMailbox,
21
- MailboxCursorPausedError,
22
- restoreSourcePlacement,
23
- } from "@remit/mailbox-service";
24
- import { attemptBudget } from "@remit/sqs-client/attempt-budget";
25
- import { isAccountDeleted } from "../account-check.js";
26
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
27
- import { createEmitEvent } from "../emit.js";
28
- import type { MessageDeleteEvent } from "../events.js";
29
- import { findMailboxRow } from "../mailbox-row.js";
30
- import type { WorkerQueues } from "../queues.js";
31
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
32
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
33
- import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
34
- import {
35
- emitMailboxResync,
36
- emitMoveResync,
37
- type PausedPlacement,
38
- probePausedPlacement,
39
- searchMailboxForHighestMessageIdUid,
40
- } from "./message-move.js";
41
- import { probeableMessageIdHeader } from "./probe-message-id.js";
42
-
43
- interface FolderPaths {
44
- source: string;
45
- destination: string | undefined;
46
- }
47
-
48
- export const getMessageDeleteMaxAttempts = (
49
- processEnv: NodeJS.ProcessEnv = process.env,
50
- ): number => attemptBudget("MESSAGE_DELETE_MAX_ATTEMPTS", 3, processEnv);
51
-
52
- export const MESSAGE_DELETE_MAX_ATTEMPTS = getMessageDeleteMaxAttempts();
53
-
54
- /**
55
- * Delete every ThreadMessage row that points at this messageId.
56
- *
57
- * A single Message can have multiple ThreadMessage rows — one per mailbox
58
- * the message exists in (e.g. INBOX + a label/folder copy). Permanent-delete
59
- * cleanup must remove ALL of them; using `findByMessageId` (single row) leaves
60
- * orphan rows in other mailboxes that then leak into their listings. See
61
- * issue #212.
62
- */
63
- export const deleteAllThreadMessagesForMessage = async (
64
- threadMessageService: Pick<
65
- IThreadMessageRepository,
66
- "findAllByMessageId" | "delete"
67
- >,
68
- accountConfigId: string,
69
- messageId: string,
70
- ): Promise<number> => {
71
- const rows = await threadMessageService.findAllByMessageId(
72
- accountConfigId,
73
- messageId,
74
- );
75
- for (const row of rows) {
76
- await threadMessageService.delete(row.accountConfigId, row.threadMessageId);
77
- }
78
- return rows.length;
79
- };
80
-
81
- /**
82
- * Settle a move to Trash the server left unconfirmed, by asking it two
83
- * read-only questions instead of one. Both handles wrap the SAME connection but
84
- * are scoped to their own mailbox: a `guardConnectionCursor` wrap binds its
85
- * checks to the ONE mailbox snapshot it was built with, so the destination must
86
- * never be opened through the source's guard.
87
- *
88
- * The source is asked first, and a source that still holds the uid ends it: the
89
- * MOVE did not happen, so nothing at the destination can be this message. The
90
- * gate survives #1122's switch to the highest matching uid and is not made
91
- * redundant by it — highest picks the fresh copy out of several only once a
92
- * fresh copy exists, and where the MOVE never ran every hit at the destination
93
- * is an older copy of the same Message-ID (a sieve `fileinto` + `keep`, a
94
- * multi-label store, a resend) that `deriveMessageId` folds into this one local
95
- * row. An ungated probe hands back that copy's uid, and Empty Trash then
96
- * expunges by it. The gate also closes the second half of #912: an empty
97
- * `uidMap` can mean the MOVE matched nothing at all.
98
- *
99
- * A row with no `messageIdHeader`, and a row that is already deleted, have
100
- * nothing to probe with; they are distinct verdicts because no redelivery can
101
- * change either answer.
102
- */
103
- export type TrashMoveConfirmation =
104
- | { outcome: "confirmed"; uid: number }
105
- | { outcome: "still-at-source" }
106
- | { outcome: "row-gone" }
107
- | { outcome: "unprobeable" }
108
- | { outcome: "unconfirmed" };
109
-
110
- const confirmTrashMoveUid = async (
111
- sourceConnection: IImapConnection,
112
- destinationConnection: IImapConnection,
113
- messageService: Pick<IMessageRepository, "get">,
114
- threadMessageService: Pick<IThreadMessageRepository, "findByMessageId">,
115
- accountConfigId: string,
116
- messageId: string,
117
- sourceMailboxPath: string,
118
- destinationMailboxPath: string,
119
- uid: number,
120
- ): Promise<TrashMoveConfirmation> => {
121
- await sourceConnection.openBox(sourceMailboxPath, true);
122
- if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
123
- return { outcome: "still-at-source" };
124
- }
125
-
126
- const [message] = await messageService.get([messageId]);
127
- if (!message) return { outcome: "row-gone" };
128
- const messageIdHeader = await probeableMessageIdHeader(
129
- threadMessageService,
130
- accountConfigId,
131
- messageId,
132
- );
133
- if (!messageIdHeader) return { outcome: "unprobeable" };
134
-
135
- const probedUid = await searchMailboxForHighestMessageIdUid(
136
- destinationConnection,
137
- destinationMailboxPath,
138
- messageIdHeader,
139
- );
140
- return probedUid === null
141
- ? { outcome: "unconfirmed" }
142
- : { outcome: "confirmed", uid: probedUid };
143
- };
144
-
145
- /**
146
- * Handle MESSAGE_DELETE events.
147
- * Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
148
- *
149
- * A failing delete retries on redelivery until `receiveCount` reaches
150
- * {@link MESSAGE_DELETE_MAX_ATTEMPTS}, at which point
151
- * {@link resolveExhaustedMessageDeleteFailure} asks IMAP where the message
152
- * actually is and settles the row into one terminal outcome (issue #980).
153
- */
154
- export const handleMessageDelete = async (
155
- event: MessageDeleteEvent,
156
- log: Logger,
157
- queues: WorkerQueues,
158
- receiveCount = 1,
159
- createConnectionScope = createConnectionScopeWithCredentials,
160
- ): Promise<void> => {
161
- const emit = createEmitEvent(queues);
162
- const {
163
- account: accountService,
164
- message: messageService,
165
- threadMessage: threadMessageService,
166
- mailbox: mailboxService,
167
- secrets,
168
- } = await getClient();
169
-
170
- const {
171
- accountId,
172
- messageId,
173
- mailboxId,
174
- uid,
175
- operation,
176
- destinationMailboxId,
177
- } = event;
178
-
179
- log.info(
180
- { event: event.type, accountId, messageId, mailboxId, operation },
181
- "Handling event",
182
- );
183
-
184
- const account = await accountService.get(accountId);
185
- if (!account) {
186
- throw new Error(`Account ${accountId} not found`);
187
- }
188
-
189
- if (isAccountDeleted(account, log)) {
190
- return;
191
- }
192
-
193
- const [message] = await messageService.get([messageId]);
194
-
195
- // The row is gone — a permanent delete that already ran, or a
196
- // reconciliation path that removed it. Nothing left to delete; ack.
197
- if (!message) {
198
- log.warn(
199
- { accountId, messageId, operation },
200
- "Skipping MESSAGE_DELETE: message row no longer exists",
201
- );
202
- return;
203
- }
204
-
205
- // This delete already settled. The guard asks for the state THIS delete
206
- // wrote, the way MESSAGE_MOVE and MESSAGE_COPY ask for theirs: a move to
207
- // Trash records `moving`, a permanent delete records `deleting`, and a row
208
- // in the other one is a different mutation's work that this handler must
209
- // not read as its own to finish.
210
- //
211
- // Without it a lost SQS acknowledgement re-runs the delete against a uid
212
- // the source no longer holds, exhausts, and lets the terminal resolver
213
- // read the source's honest "gone" as grounds to delete rows that are
214
- // correct — taking spamReport, classificationState, category and the Undo
215
- // target's `originalMailboxId` with them, none of which the resync
216
- // re-projection can rebuild.
217
- // An operation this build does not recognise gets no verdict here: it falls
218
- // through to `abandonDelete` below, which is the designed refusal. Skipping
219
- // it as "already settled" would swallow the very event the refusal exists to
220
- // report.
221
- const outstandingStatus =
222
- operation === "move_to_trash"
223
- ? MessageStatus.moving
224
- : operation === "permanent_delete"
225
- ? MessageStatus.deleting
226
- : undefined;
227
- if (outstandingStatus !== undefined && message.status !== outstandingStatus) {
228
- log.info(
229
- {
230
- accountId,
231
- messageId,
232
- operation,
233
- uid: message.uid,
234
- status: message.status,
235
- syncStatus: message.syncStatus,
236
- },
237
- "Skipping MESSAGE_DELETE: the row no longer carries the mutation this event was enqueued for",
238
- );
239
- return;
240
- }
241
-
242
- /**
243
- * Re-read the folders this delete touched. A move to trash has two; a
244
- * permanent delete has only the source, and it is owed one just as much —
245
- * the local rows are gone while the server copy survives, so with no resync
246
- * the mail is invisible until the next scheduled sync. SYNC_MESSAGES on a
247
- * mailbox whose cursor is not normal routes to the rebuild, which is what
248
- * re-projects the rows this handler removed (`message-sync.ts`).
249
- */
250
- const emitSettleResync = async (): Promise<void> => {
251
- if (destinationMailboxId) {
252
- await emitMoveResync(emit, {
253
- accountId,
254
- sourceMailboxId: mailboxId,
255
- destinationMailboxId,
256
- });
257
- return;
258
- }
259
- await emitMailboxResync(emit, { accountId, mailboxId });
260
- };
261
-
262
- /**
263
- * Undo the optimistic local delete, for every reason this handler has to
264
- * give one up, and say so exactly once. The verdict differs — a refused
265
- * event is a failure the row carries, a paused cursor is a mutation the
266
- * server never heard of — but the way back is one rule, and it is the
267
- * listing rows that pick it.
268
- *
269
- * Rows still there is the ordinary move to trash: the row goes back on the
270
- * source pair, which is also the set the cursor rebuild adjudicates.
271
- *
272
- * No rows left is a permanent delete, which drops them at enqueue; they are
273
- * denormalized off an envelope only the sync path shapes and cannot be
274
- * rebuilt from here, so restoring the Message alone would leave mail nothing
275
- * can list — the silent vanish rather than a visible failure. The local
276
- * removal finishes instead. Nothing was expunged, so the server copy
277
- * survives and the mailbox's own sync re-projects it.
278
- *
279
- * `alert` is a refusal or a placement the server never settled, both of
280
- * which an operator adjudicates. `metric` is the routine pause the guard doc
281
- * calls never-a-fault: the user's delete was silently dropped, worth
282
- * counting and not worth waking anyone for. Either way `localRowsRemoved`
283
- * keeps the disappearance of local mail visible.
284
- */
285
- const handBackDelete = async (
286
- syncStatus: MessageItem["syncStatus"],
287
- signal: { alert: string } | { metric: string },
288
- reason: string,
289
- ): Promise<void> => {
290
- const threadMessages = await threadMessageService.findAllByMessageId(
291
- account.accountConfigId,
292
- messageId,
293
- );
294
- const localRowsRemoved = threadMessages.length === 0;
295
-
296
- const context = {
297
- ...signal,
298
- accountId,
299
- messageId,
300
- uid,
301
- mailboxId,
302
- operation,
303
- receiveCount,
304
- reason,
305
- localRowsRemoved,
306
- };
307
- const summary = localRowsRemoved
308
- ? "Delete given up with no listing rows left to restore; the local row was removed and the server copy was not expunged"
309
- : "Delete given up; the row was handed back to its source pair";
310
- if ("alert" in signal) {
311
- log.error(context, summary);
312
- } else {
313
- log.warn(context, summary);
314
- }
315
-
316
- if (localRowsRemoved) {
317
- // Removing the Message row is destructive and unpredicated on its own,
318
- // so it is claimed first: the row is transitioned to `deleted` from a
319
- // placement this delete actually owns, and removed only if that claim
320
- // wins (imap-mutations R3). A row nothing is deleting — an unrecognised
321
- // operation reaches this refusal on whatever the row happens to be —
322
- // is left where it is rather than destroyed on the way past.
323
- const claimed = await messageService.transitionPlacement(
324
- messageId,
325
- { status: [MessageStatus.moving, MessageStatus.deleting] },
326
- { status: MessageStatus.deleted },
327
- );
328
- if (!claimed) {
329
- log.warn(
330
- { ...context, status: message.status },
331
- "Delete given up with no listing rows left, and no delete outstanding on the row; the local row was left alone",
332
- );
333
- return;
334
- }
335
- await messageService.delete(messageId);
336
- return;
337
- }
338
-
339
- await restoreSourcePlacement(
340
- { messageService, threadMessageService },
341
- {
342
- accountConfigId: account.accountConfigId,
343
- messageId,
344
- sourceMailboxId: mailboxId,
345
- uid,
346
- syncStatus,
347
- // Every hand-back this handler makes is a delete's, so the mutation
348
- // follows the give-up rather than being passed in beside it.
349
- abandonedMutation:
350
- syncStatus === MessageSyncStatus.abandoned
351
- ? MessageMutation.delete
352
- : MessageMutation.none,
353
- threadMessages,
354
- },
355
- );
356
- };
357
-
358
- // Only an operation that explicitly says so destroys mail. The event is
359
- // `JSON.parse`d and cast in the queue handler with no validation, so a
360
- // missing, misspelled or future field must abandon the delete — the
361
- // "anything that is not move_to_trash is an expunge" inference is the same
362
- // one that destroyed mail in the service, and an unrecoverable EXPUNGE is
363
- // not a default. Abandoning hands the row back where the server still has
364
- // it: an invisible failure on a row the user cannot see is the shape of the
365
- // incident this whole change is about. The row carries `abandoned` — the
366
- // give-up value, not the transient one a redelivery follows (R3) — because
367
- // the product refused something the user asked for and nothing is coming.
368
- const abandonDelete = (reason: string, alert: string): Promise<void> =>
369
- handBackDelete(MessageSyncStatus.abandoned, { alert }, reason);
370
-
371
- const settleTrashMoveConfirmed = async (
372
- newUid: number,
373
- trashMailboxId: string,
374
- ): Promise<void> => {
375
- await messageService.updateUid(messageId, newUid, trashMailboxId);
376
-
377
- const threadMessage = await threadMessageService.findByMessageId(
378
- account.accountConfigId,
379
- messageId,
380
- );
381
- if (threadMessage) {
382
- const args = buildThreadMessageTrashUpdate(
383
- threadMessage,
384
- newUid,
385
- trashMailboxId,
386
- );
387
- await threadMessageService.update(
388
- threadMessage.accountConfigId,
389
- threadMessage.threadMessageId,
390
- args.set,
391
- { composites: args.composites },
392
- );
393
- }
394
-
395
- log.info({ messageId, newUid }, "Message moved to trash");
396
- };
397
-
398
- /**
399
- * Settle the optimistic local delete when a paused cursor stops this round
400
- * from issuing it. Acking on the optimistic row strands it: nothing
401
- * re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows by
402
- * `(accountConfigId, mailboxId)`, so it never even sees a move-to-trash row
403
- * that already names Trash (issue #1203).
404
- *
405
- * The delete therefore reconciles rather than waits (R2,
406
- * docs/architecture/imap-mutations.md): the row is settled onto whichever
407
- * pair the server can be shown to hold, and the resync plus the source's own
408
- * cursor rebuild are its repair path.
409
- *
410
- * Undoing the local write claims nothing about the server only where the
411
- * command provably never left. `commandIssued` says what this round did;
412
- * `receiveCount` adds what an earlier one may have done and lost with its
413
- * connection. `receiveCount === 1` alone is not that proof: an unprobeable
414
- * confirmation reaches {@link settleExhaustedDelete} on the first attempt,
415
- * with the MOVE already issued and the source already saying it no longer
416
- * holds the message. Where either says the server may have been asked, it is
417
- * asked again — {@link probePausedPlacement}, on the unguarded handle. A
418
- * permanent delete has nothing to ask: its answer is the same either way,
419
- * since the row is unlistable and goes.
420
- */
421
- const settlePausedDelete = async (
422
- getRawConnection: () => Promise<IImapConnection>,
423
- commandIssued: boolean,
424
- paths: FolderPaths,
425
- ): Promise<void> => {
426
- const mayHaveIssued = commandIssued || receiveCount > 1;
427
- const destinationMailboxPath = paths.destination;
428
- const isProbeableTrashMove =
429
- mayHaveIssued &&
430
- operation === "move_to_trash" &&
431
- destinationMailboxId !== undefined &&
432
- destinationMailboxPath !== undefined;
433
-
434
- const placement: PausedPlacement = isProbeableTrashMove
435
- ? await probePausedPlacement(await getRawConnection(), {
436
- messageIdHeader: await probeableMessageIdHeader(
437
- threadMessageService,
438
- account.accountConfigId,
439
- messageId,
440
- ),
441
- sourceMailboxPath: paths.source,
442
- destinationMailboxPath,
443
- })
444
- : { kind: "at-source" };
445
-
446
- // A sighting in Trash is not proof this delete ran. The pause means the
447
- // source's UIDVALIDITY has moved, so its "no" can be a recreated folder
448
- // answering about mail it never held, and the hit can be an older copy of
449
- // the same Message-ID — a sieve `fileinto` + `keep`, a resend — that
450
- // `deriveMessageId` folds into this row; settling `moved` on it hands
451
- // Empty Trash a stranger's uid. `unprobeable` is silence, and silence
452
- // settles nothing either (the rule `message-copy.ts` already applies).
453
- // Both hand the row back on the source pair carrying `failed`: that is the
454
- // set the source's own cursor rebuild walks and adjudicates by Message-ID,
455
- // which is the reconcile path (R2) Trash — not paused, no rebuild coming —
456
- // does not have.
457
- const unproven =
458
- placement.kind === "at-destination" || placement.kind === "unprobeable";
459
-
460
- if (unproven) {
461
- await handBackDelete(
462
- MessageSyncStatus.abandoned,
463
- { alert: "message_delete_paused_placement_unproven" },
464
- placement.kind === "at-destination"
465
- ? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
466
- : "no Message-ID header to ask either folder with",
467
- );
468
- } else {
469
- // `gone` lands here with `at-source`: the source pair is the set the
470
- // rebuild walks, and a row it cannot match against a fresh envelope
471
- // snapshot is the one thing it reconciles away.
472
- await handBackDelete(
473
- MessageSyncStatus.synced,
474
- { metric: "message_delete_dropped_on_pause" },
475
- `source mailbox cursor paused, placement ${placement.kind}`,
476
- );
477
- }
478
-
479
- await emitSettleResync();
480
- };
481
-
482
- const settleExhaustedDelete = async (
483
- accountConfigId: string,
484
- sourceMailboxPath: string,
485
- getConnection: () => Promise<IImapConnection>,
486
- settlePaused: () => Promise<void>,
487
- ): Promise<void> => {
488
- const settled = await resolveExhaustedMessageDeleteFailure(
489
- { messageService, threadMessageService, log },
490
- {
491
- placement: {
492
- status: message.status,
493
- mailboxId: message.mailboxId,
494
- uid: message.uid,
495
- },
496
- accountId,
497
- accountConfigId,
498
- messageId,
499
- sourceMailboxId: mailboxId,
500
- uid,
501
- sourceMailboxPath,
502
- getConnection,
503
- },
504
- ).catch(async (settleError: unknown) => {
505
- // The guarded probe found a mailbox whose UIDVALIDITY has moved. Nothing
506
- // may be settled off a uid on a dead axis, and the pause is the routine
507
- // skip `guardMailboxCursor` documents, not a fault to re-throw out of
508
- // the caller's catch. The delete is still settled, on the identity axis
509
- // instead: the paused settle asks by Message-ID, which a UIDVALIDITY
510
- // change does not invalidate.
511
- if (settleError instanceof MailboxCursorPausedError) {
512
- log.info(
513
- { accountId, messageId, mailboxId, cursorState: settleError.state },
514
- "Mailbox cursor not normal; settling the exhausted delete against the server by Message-ID",
515
- );
516
- await settlePaused();
517
- return null;
518
- }
519
- throw settleError;
520
- });
521
-
522
- if (!settled) return;
523
-
524
- if (settled.outcome === "broken") {
525
- recordImapFailure("MESSAGE_DELETE_EXHAUSTED", "other");
526
- }
527
-
528
- // Both verdicts end in a row the server has contradicted, so this delete
529
- // reconciles rather than waits (R2): whichever folder actually holds the
530
- // message re-projects it with the server's own uid. RECONCILED, the local
531
- // rows are gone and the resync rebuilds them. BROKEN, the row has just
532
- // been put back at the source the server confirmed, and the resync is
533
- // what carries any drift either folder has picked up since.
534
- await emitSettleResync();
535
- };
536
-
537
- /**
538
- * Decide what an unconfirmed move to Trash does with this delivery. The
539
- * settle itself is `resolveExhaustedMessageDeleteFailure`, reached through
540
- * the attempt budget in the catch below — the two verdicts handled here are
541
- * the ones a redelivery could never answer.
542
- */
543
- const settleUnconfirmedTrashMove = async (
544
- confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
545
- accountConfigId: string,
546
- sourceMailboxPath: string,
547
- getConnection: () => Promise<IImapConnection>,
548
- settlePaused: () => Promise<void>,
549
- ): Promise<void> => {
550
- const context = {
551
- accountId,
552
- accountConfigId,
553
- messageId,
554
- uid,
555
- mailboxId,
556
- receiveCount,
557
- confirmation: confirmation.outcome,
558
- };
559
-
560
- if (confirmation.outcome === "row-gone") {
561
- log.warn(
562
- context,
563
- "Move to trash unconfirmed and the local row is already gone; nothing left to settle",
564
- );
565
- return;
566
- }
567
-
568
- if (confirmation.outcome === "unprobeable") {
569
- recordImapFailure("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED", "other");
570
- log.info(
571
- context,
572
- "Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
573
- );
574
- await settleExhaustedDelete(
575
- accountConfigId,
576
- sourceMailboxPath,
577
- getConnection,
578
- settlePaused,
579
- );
580
- return;
581
- }
582
-
583
- throw new Error(
584
- `Move to trash unconfirmed for message ${messageId} (attempt ${receiveCount}/${MESSAGE_DELETE_MAX_ATTEMPTS})`,
585
- );
586
- };
587
-
588
- if (!isAcceptedSchemaVersion(event.schemaVersion)) {
589
- await abandonDelete(
590
- "Refused to delete: event was minted under an unknown contract",
591
- "message_delete_unknown_schema_version",
592
- );
593
- return;
594
- }
595
-
596
- await withOAuthLifecycle(
597
- buildLifecycleDeps(secrets, accountService),
598
- account,
599
- log,
600
- async (credentials) => {
601
- // The folder can be deleted between enqueue and this sync, leaving a
602
- // queued event pointing at a gone row. The lookup then throws
603
- // NotFoundError forever, and on the account's per-group FIFO that head
604
- // message stalls the whole pipeline (issues #287, #289, #290). A deleted
605
- // mailbox makes the delete moot: ack with a WARN.
606
- const source = await findMailboxRow(mailboxService, accountId, mailboxId);
607
- if (source.kind === "gone") {
608
- log.warn(
609
- { accountId, messageId, mailboxId },
610
- "Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)",
611
- );
612
- return;
613
- }
614
- const mailbox = source.mailbox;
615
-
616
- const destination =
617
- operation === "move_to_trash" && destinationMailboxId !== undefined
618
- ? await findMailboxRow(
619
- mailboxService,
620
- accountId,
621
- destinationMailboxId,
622
- )
623
- : undefined;
624
- if (destination?.kind === "gone") {
625
- await abandonDelete(
626
- "Refused to delete: the destination mailbox no longer exists (deleted)",
627
- "message_delete_destination_gone",
628
- );
629
- await emitMailboxResync(emit, { accountId, mailboxId });
630
- return;
631
- }
632
-
633
- const mailboxPath = mailbox.fullPath;
634
- const destinationMailboxPath = destination?.mailbox.fullPath;
635
- const paths: FolderPaths = {
636
- source: mailboxPath,
637
- destination: destinationMailboxPath,
638
- };
639
-
640
- const scope = createConnectionScope(account, credentials);
641
-
642
- // The terminal resolver opens the source itself, and its answer now
643
- // WRITES a placement, so it has to reach IMAP through the same
644
- // UIDVALIDITY choke point every other outbound operation does. An
645
- // unguarded probe on a folder that was deleted and recreated finds
646
- // whatever the server has renumbered onto this uid, reads it as "still
647
- // at source", and binds the row to a stranger the next permanent delete
648
- // would expunge.
649
- const getGuardedConnection = async (): Promise<IImapConnection> =>
650
- guardConnectionCursor(
651
- await scope.getConnection(),
652
- { mailboxService },
653
- accountId,
654
- mailbox,
655
- );
656
-
657
- const settlePaused = (commandIssued: boolean): Promise<void> =>
658
- settlePausedDelete(scope.getConnection, commandIssued, paths);
659
-
660
- // Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
661
- // downstream of it knows the server was asked.
662
- let commandIssued = false;
663
-
664
- // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
665
- // paused never opens a connection on a first delivery. Optimization
666
- // only — the guardConnectionCursor openBox wrap below is the structural
667
- // guarantee.
668
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
669
- log.info(
670
- { accountId, messageId, mailboxId },
671
- "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
672
- );
673
- await settlePaused(false).finally(() => scope.disconnect());
674
- return;
675
- }
676
-
677
- await scope
678
- .getConnection()
679
- .then(async (rawConnection) => {
680
- // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
681
- // a fresh mismatch trips the mailbox and throws once the SELECT
682
- // reveals it. The delete stays applied locally either way.
683
- const connection = guardConnectionCursor(
684
- rawConnection,
685
- { mailboxService },
686
- accountId,
687
- mailbox,
688
- );
689
- await connection.openBox(mailboxPath, false);
690
-
691
- if (
692
- operation !== "move_to_trash" &&
693
- operation !== "permanent_delete"
694
- ) {
695
- await abandonDelete(
696
- "Refused to delete: event carries an unrecognized operation",
697
- "message_delete_unknown_operation",
698
- );
699
- } else if (
700
- operation === "move_to_trash" &&
701
- (!destinationMailboxPath || !destinationMailboxId)
702
- ) {
703
- // Defence in depth. `MessageMoveService` always sets both fields,
704
- // so nothing mints such an event today; the guard exists so that
705
- // a future producer that forgets one cannot reach the expunge.
706
- await abandonDelete(
707
- "Refused to delete: move to trash carries no destination mailbox",
708
- "message_delete_missing_destination",
709
- );
710
- } else if (
711
- operation === "move_to_trash" &&
712
- destinationMailboxPath &&
713
- destinationMailboxId
714
- ) {
715
- // Move to Trash
716
- commandIssued = true;
717
- const result = await connection.moveMessages(
718
- [uid],
719
- destinationMailboxPath,
720
- );
721
-
722
- // UIDPLUS is an extension. A server without it answers a
723
- // perfectly successful MOVE with no COPYUID entry, so an empty
724
- // map is UNCONFIRMED, never evidence the move failed: the server
725
- // is asked before any verdict, exactly as `handleMessageMove` and
726
- // `attemptMove` do (issues #979, #665). A probe the server refused
727
- // says nothing either way and counts as unconfirmed.
728
- const copyUid = result.uidMap.get(uid);
729
- const confirmation: TrashMoveConfirmation = copyUid
730
- ? { outcome: "confirmed", uid: copyUid }
731
- : await confirmTrashMoveUid(
732
- connection,
733
- rawConnection,
734
- messageService,
735
- threadMessageService,
736
- account.accountConfigId,
737
- messageId,
738
- mailboxPath,
739
- destinationMailboxPath,
740
- uid,
741
- ).catch((probeError: unknown) => {
742
- log.warn(
743
- {
744
- messageId,
745
- uid,
746
- mailboxPath,
747
- destinationMailboxPath,
748
- probeError,
749
- },
750
- "Could not confirm the move to trash; keeping local rows",
751
- );
752
- return { outcome: "unconfirmed" } as const;
753
- });
754
-
755
- if (confirmation.outcome === "confirmed") {
756
- await settleTrashMoveConfirmed(
757
- confirmation.uid,
758
- destinationMailboxId,
759
- );
760
- return;
761
- }
762
-
763
- await settleUnconfirmedTrashMove(
764
- confirmation,
765
- account.accountConfigId,
766
- mailboxPath,
767
- getGuardedConnection,
768
- () => settlePaused(true),
769
- );
770
- } else {
771
- // Permanent delete — reached only by `operation === "permanent_delete"`.
772
- commandIssued = true;
773
- await connection.deleteMessages([uid]);
774
-
775
- // Delete ThreadMessage rows BEFORE the Message row to collapse the
776
- // visibility window where the inbox lists a row whose backing
777
- // Message has already been deleted (see issue #212). Multi-mailbox
778
- // copies are handled by the helper.
779
- const threadMessagesDeleted =
780
- await deleteAllThreadMessagesForMessage(
781
- threadMessageService,
782
- account.accountConfigId,
783
- messageId,
784
- );
785
-
786
- // Delete local Message entity
787
- await messageService.delete(messageId);
788
-
789
- log.info(
790
- { messageId, threadMessagesDeleted },
791
- "Message permanently deleted",
792
- );
793
- }
794
- })
795
- .catch(async (error: unknown) => {
796
- if (error instanceof MailboxCursorPausedError) {
797
- log.info(
798
- { accountId, messageId, mailboxId, cursorState: error.state },
799
- "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
800
- );
801
- await settlePaused(commandIssued);
802
- return;
803
- }
804
-
805
- const errorMessage =
806
- error instanceof Error ? error.message : String(error);
807
-
808
- // Reconcile a permanent delete the server already applied (#212).
809
- // The error text only nominates a candidate — the source box
810
- // decides, since a landed move-to-trash reports that same text from
811
- // a LOCAL lookup (#845) — and a probe that cannot answer counts as
812
- // not-confirmed, leaving the original error to the rethrow below.
813
- const isGoneFromSource =
814
- operation === "permanent_delete" &&
815
- (errorMessage.includes("not found") ||
816
- errorMessage.includes("NONEXISTENT")) &&
817
- (await scope
818
- .getConnection()
819
- .then((connection) =>
820
- isMessageGoneFromOpenMailbox(connection, uid),
821
- )
822
- .catch((probeError: unknown) => {
823
- log.warn(
824
- { messageId, uid, mailboxPath, probeError },
825
- "Could not confirm whether the message is gone; keeping local rows",
826
- );
827
- return false;
828
- }));
829
-
830
- if (isGoneFromSource) {
831
- log.info(
832
- { messageId, uid },
833
- "Message confirmed gone from IMAP, cleaning up local",
834
- );
835
- // Clean up local entities. Multi-mailbox copies are handled by the
836
- // helper so we don't leave orphan rows (see issue #212).
837
- await messageService.delete(messageId);
838
- await deleteAllThreadMessagesForMessage(
839
- threadMessageService,
840
- account.accountConfigId,
841
- messageId,
842
- );
843
- return;
844
- }
845
-
846
- // TRYCREATE — the destination this event names is not on the
847
- // server. Creating it would resurrect an empty `Trash` beside the
848
- // real one and hand the name-hint rule two folders to choose
849
- // between; the folder picker offers the ones that exist instead.
850
- if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
851
- await abandonDelete(
852
- "Refused to delete: the destination mailbox does not exist on the server",
853
- "message_delete_destination_missing",
854
- );
855
- return;
856
- }
857
-
858
- if (receiveCount < MESSAGE_DELETE_MAX_ATTEMPTS) {
859
- // Transient failure — connections drop. No alarm; redelivery
860
- // retries, and `failed` marks the row unsettled meanwhile. It is
861
- // not a terminal signal: only the resolver below settles anything.
862
- await messageService.transitionPlacement(
863
- messageId,
864
- { status: [MessageStatus.moving, MessageStatus.deleting] },
865
- { syncStatus: MessageSyncStatus.failed },
866
- );
867
- throw error;
868
- }
869
-
870
- // Budget exhausted. Settling here is what keeps a throwing
871
- // `moveMessages` inside the ceiling: re-MOVEing a uid the source no
872
- // longer holds fails identically on every redelivery, which is the
873
- // failure this budget exists for.
874
- await settleExhaustedDelete(
875
- account.accountConfigId,
876
- mailboxPath,
877
- getGuardedConnection,
878
- () => settlePaused(commandIssued),
879
- );
880
- log.error(
881
- { accountId, messageId, uid, mailboxPath, error: errorMessage },
882
- "Delete retry exhausted; settled into a terminal outcome",
883
- );
884
- })
885
- .finally(() => scope.disconnect());
886
- },
887
- );
888
- };