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