@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
@@ -0,0 +1,520 @@
1
+ import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
2
+ import { MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
3
+ import { buildThreadMessageTrashUpdate, guardConnectionCursor, isCursorRebuildNeeded, isMessageGoneFromOpenMailbox, MailboxCursorPausedError, restoreSourcePlacement, } from "@remit/mailbox-service";
4
+ import { isAccountDeleted } from "../account-check.js";
5
+ import { emitFrom } from "../emit.js";
6
+ import { findMailboxRow } from "../mailbox-row.js";
7
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
8
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
9
+ import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
10
+ import { emitMailboxResync, emitMoveResync, probePausedPlacement, searchMailboxForHighestMessageIdUid, } from "./message-move.js";
11
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
12
+ /**
13
+ * Delete every ThreadMessage row that points at this messageId.
14
+ *
15
+ * A single Message can have multiple ThreadMessage rows — one per mailbox
16
+ * the message exists in (e.g. INBOX + a label/folder copy). Permanent-delete
17
+ * cleanup must remove ALL of them; using `findByMessageId` (single row) leaves
18
+ * orphan rows in other mailboxes that then leak into their listings. See
19
+ * issue #212.
20
+ */
21
+ export const deleteAllThreadMessagesForMessage = async (threadMessageService, accountConfigId, messageId) => {
22
+ const rows = await threadMessageService.findAllByMessageId(accountConfigId, messageId);
23
+ for (const row of rows) {
24
+ await threadMessageService.delete(row.accountConfigId, row.threadMessageId);
25
+ }
26
+ return rows.length;
27
+ };
28
+ const confirmTrashMoveUid = async (sourceConnection, destinationConnection, messageService, threadMessageService, accountConfigId, messageId, sourceMailboxPath, destinationMailboxPath, uid) => {
29
+ await sourceConnection.openBox(sourceMailboxPath, true);
30
+ if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
31
+ return { outcome: "still-at-source" };
32
+ }
33
+ const [message] = await messageService.get([messageId]);
34
+ if (!message)
35
+ return { outcome: "row-gone" };
36
+ const messageIdHeader = await probeableMessageIdHeader(threadMessageService, accountConfigId, messageId);
37
+ if (!messageIdHeader)
38
+ return { outcome: "unprobeable" };
39
+ const probedUid = await searchMailboxForHighestMessageIdUid(destinationConnection, destinationMailboxPath, messageIdHeader);
40
+ return probedUid === null
41
+ ? { outcome: "unconfirmed" }
42
+ : { outcome: "confirmed", uid: probedUid };
43
+ };
44
+ /**
45
+ * Handle MESSAGE_DELETE events.
46
+ * Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
47
+ *
48
+ * A failing delete retries on redelivery until `receiveCount` reaches
49
+ * the `config.attempts.messageDelete` budget, at which point
50
+ * {@link resolveExhaustedMessageDeleteFailure} asks IMAP where the message
51
+ * actually is and settles the row into one terminal outcome (issue #980).
52
+ */
53
+ export const handleMessageDelete = async (event, ctx, receiveCount) => {
54
+ const { log, pool, metrics, config } = ctx;
55
+ const emit = emitFrom(ctx);
56
+ const { account: accountService, message: messageService, threadMessage: threadMessageService, mailbox: mailboxService, secrets, } = ctx.client;
57
+ const { accountId, messageId, mailboxId, uid, operation, destinationMailboxId, } = event;
58
+ log.info({ event: event.type, accountId, messageId, mailboxId, operation }, "Handling event");
59
+ const account = await accountService.get(accountId);
60
+ if (!account) {
61
+ throw new Error(`Account ${accountId} not found`);
62
+ }
63
+ if (isAccountDeleted(account, log)) {
64
+ return;
65
+ }
66
+ const [message] = await messageService.get([messageId]);
67
+ // The row is gone — a permanent delete that already ran, or a
68
+ // reconciliation path that removed it. Nothing left to delete; ack.
69
+ if (!message) {
70
+ log.warn({ accountId, messageId, operation }, "Skipping MESSAGE_DELETE: message row no longer exists");
71
+ return;
72
+ }
73
+ // This delete already settled. The guard asks for the state THIS delete
74
+ // wrote, the way MESSAGE_MOVE and MESSAGE_COPY ask for theirs: a move to
75
+ // Trash records `moving`, a permanent delete records `deleting`, and a row
76
+ // in the other one is a different mutation's work that this handler must
77
+ // not read as its own to finish.
78
+ //
79
+ // Without it a lost SQS acknowledgement re-runs the delete against a uid
80
+ // the source no longer holds, exhausts, and lets the terminal resolver
81
+ // read the source's honest "gone" as grounds to delete rows that are
82
+ // correct — taking spamReport, classificationState, category and the Undo
83
+ // target's `originalMailboxId` with them, none of which the resync
84
+ // re-projection can rebuild.
85
+ // An operation this build does not recognise gets no verdict here: it falls
86
+ // through to `abandonDelete` below, which is the designed refusal. Skipping
87
+ // it as "already settled" would swallow the very event the refusal exists to
88
+ // report.
89
+ const outstandingStatus = operation === "move_to_trash"
90
+ ? MessageStatus.moving
91
+ : operation === "permanent_delete"
92
+ ? MessageStatus.deleting
93
+ : undefined;
94
+ if (outstandingStatus !== undefined && message.status !== outstandingStatus) {
95
+ log.info({
96
+ accountId,
97
+ messageId,
98
+ operation,
99
+ uid: message.uid,
100
+ status: message.status,
101
+ syncStatus: message.syncStatus,
102
+ }, "Skipping MESSAGE_DELETE: the row no longer carries the mutation this event was enqueued for");
103
+ return;
104
+ }
105
+ /**
106
+ * Re-read the folders this delete touched. A move to trash has two; a
107
+ * permanent delete has only the source, and it is owed one just as much —
108
+ * the local rows are gone while the server copy survives, so with no resync
109
+ * the mail is invisible until the next scheduled sync. SYNC_MESSAGES on a
110
+ * mailbox whose cursor is not normal routes to the rebuild, which is what
111
+ * re-projects the rows this handler removed (`message-sync.ts`).
112
+ */
113
+ const emitSettleResync = async () => {
114
+ if (destinationMailboxId) {
115
+ await emitMoveResync(emit, {
116
+ accountId,
117
+ sourceMailboxId: mailboxId,
118
+ destinationMailboxId,
119
+ });
120
+ return;
121
+ }
122
+ await emitMailboxResync(emit, { accountId, mailboxId });
123
+ };
124
+ /**
125
+ * Undo the optimistic local delete, for every reason this handler has to
126
+ * give one up, and say so exactly once. The verdict differs — a refused
127
+ * event is a failure the row carries, a paused cursor is a mutation the
128
+ * server never heard of — but the way back is one rule, and it is the
129
+ * listing rows that pick it.
130
+ *
131
+ * Rows still there is the ordinary move to trash: the row goes back on the
132
+ * source pair, which is also the set the cursor rebuild adjudicates.
133
+ *
134
+ * No rows left is a permanent delete, which drops them at enqueue; they are
135
+ * denormalized off an envelope only the sync path shapes and cannot be
136
+ * rebuilt from here, so restoring the Message alone would leave mail nothing
137
+ * can list — the silent vanish rather than a visible failure. The local
138
+ * removal finishes instead. Nothing was expunged, so the server copy
139
+ * survives and the mailbox's own sync re-projects it.
140
+ *
141
+ * `alert` is a refusal or a placement the server never settled, both of
142
+ * which an operator adjudicates. `metric` is the routine pause the guard doc
143
+ * calls never-a-fault: the user's delete was silently dropped, worth
144
+ * counting and not worth waking anyone for. Either way `localRowsRemoved`
145
+ * keeps the disappearance of local mail visible.
146
+ */
147
+ const handBackDelete = async (syncStatus, signal, reason) => {
148
+ const threadMessages = await threadMessageService.findAllByMessageId(account.accountConfigId, messageId);
149
+ const localRowsRemoved = threadMessages.length === 0;
150
+ const context = {
151
+ ...signal,
152
+ accountId,
153
+ messageId,
154
+ uid,
155
+ mailboxId,
156
+ operation,
157
+ receiveCount,
158
+ reason,
159
+ localRowsRemoved,
160
+ };
161
+ const summary = localRowsRemoved
162
+ ? "Delete given up with no listing rows left to restore; the local row was removed and the server copy was not expunged"
163
+ : "Delete given up; the row was handed back to its source pair";
164
+ if ("alert" in signal) {
165
+ log.error(context, summary);
166
+ }
167
+ else {
168
+ log.warn(context, summary);
169
+ }
170
+ if (localRowsRemoved) {
171
+ // Removing the Message row is destructive and unpredicated on its own,
172
+ // so it is claimed first: the row is transitioned to `deleted` from a
173
+ // placement this delete actually owns, and removed only if that claim
174
+ // wins (imap-mutations R3). A row nothing is deleting — an unrecognised
175
+ // operation reaches this refusal on whatever the row happens to be —
176
+ // is left where it is rather than destroyed on the way past.
177
+ const claimed = await messageService.transitionPlacement(messageId, { status: [MessageStatus.moving, MessageStatus.deleting] }, { status: MessageStatus.deleted });
178
+ if (!claimed) {
179
+ log.warn({ ...context, status: message.status }, "Delete given up with no listing rows left, and no delete outstanding on the row; the local row was left alone");
180
+ return;
181
+ }
182
+ await messageService.delete(messageId);
183
+ return;
184
+ }
185
+ await restoreSourcePlacement({ messageService, threadMessageService }, {
186
+ accountConfigId: account.accountConfigId,
187
+ messageId,
188
+ sourceMailboxId: mailboxId,
189
+ uid,
190
+ syncStatus,
191
+ // Every hand-back this handler makes is a delete's, so the mutation
192
+ // follows the give-up rather than being passed in beside it.
193
+ abandonedMutation: syncStatus === MessageSyncStatus.abandoned
194
+ ? MessageMutation.delete
195
+ : MessageMutation.none,
196
+ threadMessages,
197
+ });
198
+ };
199
+ // Only an operation that explicitly says so destroys mail. The event is
200
+ // `JSON.parse`d and cast in the queue handler with no validation, so a
201
+ // missing, misspelled or future field must abandon the delete — the
202
+ // "anything that is not move_to_trash is an expunge" inference is the same
203
+ // one that destroyed mail in the service, and an unrecoverable EXPUNGE is
204
+ // not a default. Abandoning hands the row back where the server still has
205
+ // it: an invisible failure on a row the user cannot see is the shape of the
206
+ // incident this whole change is about. The row carries `abandoned` — the
207
+ // give-up value, not the transient one a redelivery follows (R3) — because
208
+ // the product refused something the user asked for and nothing is coming.
209
+ const abandonDelete = (reason, alert) => handBackDelete(MessageSyncStatus.abandoned, { alert }, reason);
210
+ const settleTrashMoveConfirmed = async (newUid, trashMailboxId) => {
211
+ await messageService.updateUid(messageId, newUid, trashMailboxId);
212
+ const threadMessage = await threadMessageService.findByMessageId(account.accountConfigId, messageId);
213
+ if (threadMessage) {
214
+ const args = buildThreadMessageTrashUpdate(threadMessage, newUid, trashMailboxId);
215
+ await threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, args.set, { composites: args.composites });
216
+ }
217
+ log.info({ messageId, newUid }, "Message moved to trash");
218
+ };
219
+ /**
220
+ * Settle the optimistic local delete when a paused cursor stops this round
221
+ * from issuing it. Acking on the optimistic row strands it: nothing
222
+ * re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows by
223
+ * `(accountConfigId, mailboxId)`, so it never even sees a move-to-trash row
224
+ * that already names Trash (issue #1203).
225
+ *
226
+ * The delete therefore reconciles rather than waits (R2,
227
+ * docs/architecture/imap-mutations.md): the row is settled onto whichever
228
+ * pair the server can be shown to hold, and the resync plus the source's own
229
+ * cursor rebuild are its repair path.
230
+ *
231
+ * Undoing the local write claims nothing about the server only where the
232
+ * command provably never left. `commandIssued` says what this round did;
233
+ * `receiveCount` adds what an earlier one may have done and lost with its
234
+ * connection. `receiveCount === 1` alone is not that proof: an unprobeable
235
+ * confirmation reaches {@link settleExhaustedDelete} on the first attempt,
236
+ * with the MOVE already issued and the source already saying it no longer
237
+ * holds the message. Where either says the server may have been asked, it is
238
+ * asked again — {@link probePausedPlacement}, on the unguarded handle. A
239
+ * permanent delete has nothing to ask: its answer is the same either way,
240
+ * since the row is unlistable and goes.
241
+ */
242
+ const settlePausedDelete = async (getRawConnection, commandIssued, paths) => {
243
+ const mayHaveIssued = commandIssued || receiveCount > 1;
244
+ const destinationMailboxPath = paths.destination;
245
+ const isProbeableTrashMove = mayHaveIssued &&
246
+ operation === "move_to_trash" &&
247
+ destinationMailboxId !== undefined &&
248
+ destinationMailboxPath !== undefined;
249
+ const placement = isProbeableTrashMove
250
+ ? await probePausedPlacement(await getRawConnection(), {
251
+ messageIdHeader: await probeableMessageIdHeader(threadMessageService, account.accountConfigId, messageId),
252
+ sourceMailboxPath: paths.source,
253
+ destinationMailboxPath,
254
+ })
255
+ : { kind: "at-source" };
256
+ // A sighting in Trash is not proof this delete ran. The pause means the
257
+ // source's UIDVALIDITY has moved, so its "no" can be a recreated folder
258
+ // answering about mail it never held, and the hit can be an older copy of
259
+ // the same Message-ID — a sieve `fileinto` + `keep`, a resend — that
260
+ // `deriveMessageId` folds into this row; settling `moved` on it hands
261
+ // Empty Trash a stranger's uid. `unprobeable` is silence, and silence
262
+ // settles nothing either (the rule `message-copy.ts` already applies).
263
+ // Both hand the row back on the source pair carrying `failed`: that is the
264
+ // set the source's own cursor rebuild walks and adjudicates by Message-ID,
265
+ // which is the reconcile path (R2) Trash — not paused, no rebuild coming —
266
+ // does not have.
267
+ const unproven = placement.kind === "at-destination" || placement.kind === "unprobeable";
268
+ if (unproven) {
269
+ await handBackDelete(MessageSyncStatus.abandoned, { alert: "message_delete_paused_placement_unproven" }, placement.kind === "at-destination"
270
+ ? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
271
+ : "no Message-ID header to ask either folder with");
272
+ }
273
+ else {
274
+ // `gone` lands here with `at-source`: the source pair is the set the
275
+ // rebuild walks, and a row it cannot match against a fresh envelope
276
+ // snapshot is the one thing it reconciles away.
277
+ await handBackDelete(MessageSyncStatus.synced, { metric: "message_delete_dropped_on_pause" }, `source mailbox cursor paused, placement ${placement.kind}`);
278
+ }
279
+ await emitSettleResync();
280
+ };
281
+ const settleExhaustedDelete = async (accountConfigId, sourceMailboxPath, getConnection, settlePaused) => {
282
+ const settled = await resolveExhaustedMessageDeleteFailure({ messageService, threadMessageService, log }, {
283
+ placement: {
284
+ status: message.status,
285
+ mailboxId: message.mailboxId,
286
+ uid: message.uid,
287
+ },
288
+ accountId,
289
+ accountConfigId,
290
+ messageId,
291
+ sourceMailboxId: mailboxId,
292
+ uid,
293
+ sourceMailboxPath,
294
+ getConnection,
295
+ }).catch(async (settleError) => {
296
+ // The guarded probe found a mailbox whose UIDVALIDITY has moved. Nothing
297
+ // may be settled off a uid on a dead axis, and the pause is the routine
298
+ // skip `guardMailboxCursor` documents, not a fault to re-throw out of
299
+ // the caller's catch. The delete is still settled, on the identity axis
300
+ // instead: the paused settle asks by Message-ID, which a UIDVALIDITY
301
+ // change does not invalidate.
302
+ if (settleError instanceof MailboxCursorPausedError) {
303
+ log.info({ accountId, messageId, mailboxId, cursorState: settleError.state }, "Mailbox cursor not normal; settling the exhausted delete against the server by Message-ID");
304
+ await settlePaused();
305
+ return null;
306
+ }
307
+ throw settleError;
308
+ });
309
+ if (!settled)
310
+ return;
311
+ if (settled.outcome === "broken") {
312
+ metrics.imapFailure("MESSAGE_DELETE_EXHAUSTED", "other", 1);
313
+ }
314
+ // Both verdicts end in a row the server has contradicted, so this delete
315
+ // reconciles rather than waits (R2): whichever folder actually holds the
316
+ // message re-projects it with the server's own uid. RECONCILED, the local
317
+ // rows are gone and the resync rebuilds them. BROKEN, the row has just
318
+ // been put back at the source the server confirmed, and the resync is
319
+ // what carries any drift either folder has picked up since.
320
+ await emitSettleResync();
321
+ };
322
+ /**
323
+ * Decide what an unconfirmed move to Trash does with this delivery. The
324
+ * settle itself is `resolveExhaustedMessageDeleteFailure`, reached through
325
+ * the attempt budget in the catch below — the two verdicts handled here are
326
+ * the ones a redelivery could never answer.
327
+ */
328
+ const settleUnconfirmedTrashMove = async (confirmation, accountConfigId, sourceMailboxPath, getConnection, settlePaused) => {
329
+ const context = {
330
+ accountId,
331
+ accountConfigId,
332
+ messageId,
333
+ uid,
334
+ mailboxId,
335
+ receiveCount,
336
+ confirmation: confirmation.outcome,
337
+ };
338
+ if (confirmation.outcome === "row-gone") {
339
+ log.warn(context, "Move to trash unconfirmed and the local row is already gone; nothing left to settle");
340
+ return;
341
+ }
342
+ if (confirmation.outcome === "unprobeable") {
343
+ metrics.imapFailure("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED", "other", 1);
344
+ log.info(context, "Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone");
345
+ await settleExhaustedDelete(accountConfigId, sourceMailboxPath, getConnection, settlePaused);
346
+ return;
347
+ }
348
+ throw new Error(`Move to trash unconfirmed for message ${messageId} (attempt ${receiveCount}/${config.attempts.messageDelete})`);
349
+ };
350
+ if (!isAcceptedSchemaVersion(event.schemaVersion)) {
351
+ await abandonDelete("Refused to delete: event was minted under an unknown contract", "message_delete_unknown_schema_version");
352
+ return;
353
+ }
354
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
355
+ // The folder can be deleted between enqueue and this sync, leaving a
356
+ // queued event pointing at a gone row. The lookup then throws
357
+ // NotFoundError forever, and on the account's per-group FIFO that head
358
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
359
+ // mailbox makes the delete moot: ack with a WARN.
360
+ const source = await findMailboxRow(mailboxService, accountId, mailboxId);
361
+ if (source.kind === "gone") {
362
+ log.warn({ accountId, messageId, mailboxId }, "Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)");
363
+ return;
364
+ }
365
+ const mailbox = source.mailbox;
366
+ const destination = operation === "move_to_trash" && destinationMailboxId !== undefined
367
+ ? await findMailboxRow(mailboxService, accountId, destinationMailboxId)
368
+ : undefined;
369
+ if (destination?.kind === "gone") {
370
+ await abandonDelete("Refused to delete: the destination mailbox no longer exists (deleted)", "message_delete_destination_gone");
371
+ await emitMailboxResync(emit, { accountId, mailboxId });
372
+ return;
373
+ }
374
+ const mailboxPath = mailbox.fullPath;
375
+ const destinationMailboxPath = destination?.mailbox.fullPath;
376
+ const paths = {
377
+ source: mailboxPath,
378
+ destination: destinationMailboxPath,
379
+ };
380
+ const scope = pool.borrow(account, credentials);
381
+ // The terminal resolver opens the source itself, and its answer now
382
+ // WRITES a placement, so it has to reach IMAP through the same
383
+ // UIDVALIDITY choke point every other outbound operation does. An
384
+ // unguarded probe on a folder that was deleted and recreated finds
385
+ // whatever the server has renumbered onto this uid, reads it as "still
386
+ // at source", and binds the row to a stranger the next permanent delete
387
+ // would expunge.
388
+ const getGuardedConnection = async () => guardConnectionCursor(await scope.getConnection(), { mailboxService }, accountId, mailbox);
389
+ const settlePaused = (commandIssued) => settlePausedDelete(scope.getConnection, commandIssued, paths);
390
+ // Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
391
+ // downstream of it knows the server was asked.
392
+ let commandIssued = false;
393
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
394
+ // paused never opens a connection on a first delivery. Optimization
395
+ // only — the guardConnectionCursor openBox wrap below is the structural
396
+ // guarantee.
397
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
398
+ log.info({ accountId, messageId, mailboxId }, "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server");
399
+ await settlePaused(false).finally(() => scope.release());
400
+ return;
401
+ }
402
+ await scope
403
+ .getConnection()
404
+ .then(async (rawConnection) => {
405
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
406
+ // a fresh mismatch trips the mailbox and throws once the SELECT
407
+ // reveals it. The delete stays applied locally either way.
408
+ const connection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, mailbox);
409
+ await connection.openBox(mailboxPath, false);
410
+ if (operation !== "move_to_trash" &&
411
+ operation !== "permanent_delete") {
412
+ await abandonDelete("Refused to delete: event carries an unrecognized operation", "message_delete_unknown_operation");
413
+ }
414
+ else if (operation === "move_to_trash" &&
415
+ (!destinationMailboxPath || !destinationMailboxId)) {
416
+ // Defence in depth. `MessageMoveService` always sets both fields,
417
+ // so nothing mints such an event today; the guard exists so that
418
+ // a future producer that forgets one cannot reach the expunge.
419
+ await abandonDelete("Refused to delete: move to trash carries no destination mailbox", "message_delete_missing_destination");
420
+ }
421
+ else if (operation === "move_to_trash" &&
422
+ destinationMailboxPath &&
423
+ destinationMailboxId) {
424
+ // Move to Trash
425
+ commandIssued = true;
426
+ const result = await connection.moveMessages([uid], destinationMailboxPath);
427
+ // UIDPLUS is an extension. A server without it answers a
428
+ // perfectly successful MOVE with no COPYUID entry, so an empty
429
+ // map is UNCONFIRMED, never evidence the move failed: the server
430
+ // is asked before any verdict, exactly as `handleMessageMove` and
431
+ // `attemptMove` do (issues #979, #665). A probe the server refused
432
+ // says nothing either way and counts as unconfirmed.
433
+ const copyUid = result.uidMap.get(uid);
434
+ const confirmation = copyUid
435
+ ? { outcome: "confirmed", uid: copyUid }
436
+ : await confirmTrashMoveUid(connection, rawConnection, messageService, threadMessageService, account.accountConfigId, messageId, mailboxPath, destinationMailboxPath, uid).catch((probeError) => {
437
+ log.warn({
438
+ messageId,
439
+ uid,
440
+ mailboxPath,
441
+ destinationMailboxPath,
442
+ probeError,
443
+ }, "Could not confirm the move to trash; keeping local rows");
444
+ return { outcome: "unconfirmed" };
445
+ });
446
+ if (confirmation.outcome === "confirmed") {
447
+ await settleTrashMoveConfirmed(confirmation.uid, destinationMailboxId);
448
+ return;
449
+ }
450
+ await settleUnconfirmedTrashMove(confirmation, account.accountConfigId, mailboxPath, getGuardedConnection, () => settlePaused(true));
451
+ }
452
+ else {
453
+ // Permanent delete — reached only by `operation === "permanent_delete"`.
454
+ commandIssued = true;
455
+ await connection.deleteMessages([uid]);
456
+ // Delete ThreadMessage rows BEFORE the Message row to collapse the
457
+ // visibility window where the inbox lists a row whose backing
458
+ // Message has already been deleted (see issue #212). Multi-mailbox
459
+ // copies are handled by the helper.
460
+ const threadMessagesDeleted = await deleteAllThreadMessagesForMessage(threadMessageService, account.accountConfigId, messageId);
461
+ // Delete local Message entity
462
+ await messageService.delete(messageId);
463
+ log.info({ messageId, threadMessagesDeleted }, "Message permanently deleted");
464
+ }
465
+ })
466
+ .catch(async (error) => {
467
+ if (error instanceof MailboxCursorPausedError) {
468
+ log.info({ accountId, messageId, mailboxId, cursorState: error.state }, "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server");
469
+ await settlePaused(commandIssued);
470
+ return;
471
+ }
472
+ const errorMessage = error instanceof Error ? error.message : String(error);
473
+ // Reconcile a permanent delete the server already applied (#212).
474
+ // The error text only nominates a candidate — the source box
475
+ // decides, since a landed move-to-trash reports that same text from
476
+ // a LOCAL lookup (#845) — and a probe that cannot answer counts as
477
+ // not-confirmed, leaving the original error to the rethrow below.
478
+ const isGoneFromSource = operation === "permanent_delete" &&
479
+ (errorMessage.includes("not found") ||
480
+ errorMessage.includes("NONEXISTENT")) &&
481
+ (await scope
482
+ .getConnection()
483
+ .then((connection) => isMessageGoneFromOpenMailbox(connection, uid))
484
+ .catch((probeError) => {
485
+ log.warn({ messageId, uid, mailboxPath, probeError }, "Could not confirm whether the message is gone; keeping local rows");
486
+ return false;
487
+ }));
488
+ if (isGoneFromSource) {
489
+ log.info({ messageId, uid }, "Message confirmed gone from IMAP, cleaning up local");
490
+ // Clean up local entities. Multi-mailbox copies are handled by the
491
+ // helper so we don't leave orphan rows (see issue #212).
492
+ await messageService.delete(messageId);
493
+ await deleteAllThreadMessagesForMessage(threadMessageService, account.accountConfigId, messageId);
494
+ return;
495
+ }
496
+ // TRYCREATE — the destination this event names is not on the
497
+ // server. Creating it would resurrect an empty `Trash` beside the
498
+ // real one and hand the name-hint rule two folders to choose
499
+ // between; the folder picker offers the ones that exist instead.
500
+ if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
501
+ await abandonDelete("Refused to delete: the destination mailbox does not exist on the server", "message_delete_destination_missing");
502
+ return;
503
+ }
504
+ if (receiveCount < config.attempts.messageDelete) {
505
+ // Transient failure — connections drop. No alarm; redelivery
506
+ // retries, and `failed` marks the row unsettled meanwhile. It is
507
+ // not a terminal signal: only the resolver below settles anything.
508
+ await messageService.transitionPlacement(messageId, { status: [MessageStatus.moving, MessageStatus.deleting] }, { syncStatus: MessageSyncStatus.failed });
509
+ throw error;
510
+ }
511
+ // Budget exhausted. Settling here is what keeps a throwing
512
+ // `moveMessages` inside the ceiling: re-MOVEing a uid the source no
513
+ // longer holds fails identically on every redelivery, which is the
514
+ // failure this budget exists for.
515
+ await settleExhaustedDelete(account.accountConfigId, mailboxPath, getGuardedConnection, () => settlePaused(commandIssued));
516
+ log.error({ accountId, messageId, uid, mailboxPath, error: errorMessage }, "Delete retry exhausted; settled into a terminal outcome");
517
+ })
518
+ .finally(() => scope.release());
519
+ });
520
+ };
@@ -1,50 +1,29 @@
1
- import type {
2
- IMessageRepository,
3
- IThreadMessageRepository,
4
- MessageItem,
5
- } from "@remit/data-ports";
6
- import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
7
- import {
8
- type IImapConnection,
9
- isMessageGoneFromOpenMailbox,
10
- restoreSourcePlacement,
11
- } from "@remit/mailbox-service";
12
-
1
+ import type { IMessageRepository, IThreadMessageRepository, MessageItem } from "@remit/data-ports";
2
+ import { type IImapConnection } from "@remit/mailbox-service";
13
3
  export interface MessageMoveTerminalLogger {
14
- info(obj: Record<string, unknown>, msg: string): void;
15
- error(obj: Record<string, unknown>, msg: string): void;
4
+ info(obj: Record<string, unknown>, msg: string): void;
5
+ error(obj: Record<string, unknown>, msg: string): void;
16
6
  }
17
-
18
7
  export interface ResolveExhaustedMessageMoveDeps {
19
- messageService: Pick<
20
- IMessageRepository,
21
- "deleteIfPlacement" | "transitionPlacement"
22
- >;
23
- threadMessageService: Pick<
24
- IThreadMessageRepository,
25
- "findAllByMessageId" | "deleteMany" | "update"
26
- >;
27
- log: MessageMoveTerminalLogger;
8
+ messageService: Pick<IMessageRepository, "deleteIfPlacement" | "transitionPlacement">;
9
+ threadMessageService: Pick<IThreadMessageRepository, "findAllByMessageId" | "deleteMany" | "update">;
10
+ log: MessageMoveTerminalLogger;
28
11
  }
29
-
30
12
  export interface ResolveExhaustedMessageMoveInput {
31
- /** Where the row stood when the caller read it; the delete holds only while it still does. */
32
- placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
33
- accountId: string;
34
- accountConfigId: string;
35
- messageId: string;
36
- sourceMailboxId: string;
37
- uid: number;
38
- sourceMailboxPath: string;
39
- getConnection: () => Promise<IImapConnection>;
13
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
14
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
15
+ accountId: string;
16
+ accountConfigId: string;
17
+ messageId: string;
18
+ sourceMailboxId: string;
19
+ uid: number;
20
+ sourceMailboxPath: string;
21
+ getConnection: () => Promise<IImapConnection>;
40
22
  }
41
-
42
23
  export type MessageMoveTerminalOutcome = "reconciled" | "broken";
43
-
44
24
  export interface ResolveExhaustedMessageMoveResult {
45
- outcome: MessageMoveTerminalOutcome;
25
+ outcome: MessageMoveTerminalOutcome;
46
26
  }
47
-
48
27
  /**
49
28
  * Resolve a MESSAGE_MOVE failure that has exhausted the message queue's
50
29
  * redelivery budget into exactly one of two terminal outcomes, mirroring
@@ -95,70 +74,4 @@ export interface ResolveExhaustedMessageMoveResult {
95
74
  * reverse mistake discards the row for live mail, so the cost is paid
96
75
  * deliberately.
97
76
  */
98
- export const resolveExhaustedMessageMoveFailure = async (
99
- deps: ResolveExhaustedMessageMoveDeps,
100
- input: ResolveExhaustedMessageMoveInput,
101
- ): Promise<ResolveExhaustedMessageMoveResult> => {
102
- const {
103
- accountId,
104
- accountConfigId,
105
- messageId,
106
- sourceMailboxId,
107
- uid,
108
- sourceMailboxPath,
109
- getConnection,
110
- } = input;
111
-
112
- const connection = await getConnection();
113
- await connection.openBox(sourceMailboxPath, true);
114
-
115
- if (await isMessageGoneFromOpenMailbox(connection, uid)) {
116
- const reconciled = await deps.messageService.deleteIfPlacement(
117
- messageId,
118
- input.placement,
119
- );
120
- deps.log.info(
121
- {
122
- metric: "message_move_stale_row_reconciled",
123
- accountId,
124
- accountConfigId,
125
- messageId,
126
- uid,
127
- sourceMailboxPath,
128
- reconciled,
129
- },
130
- "Message no longer at its move source after retry exhaustion (move landed server-side, or an external delete or move); stale row reconciled",
131
- );
132
- return { outcome: "reconciled" };
133
- }
134
-
135
- deps.log.error(
136
- {
137
- alert: "message_move_failed",
138
- accountId,
139
- accountConfigId,
140
- messageId,
141
- uid,
142
- sourceMailboxId,
143
- sourceMailboxPath,
144
- },
145
- "Message move could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
146
- );
147
-
148
- await restoreSourcePlacement(deps, {
149
- accountConfigId,
150
- messageId,
151
- sourceMailboxId,
152
- uid,
153
- // The probe above just confirmed the pair, so the row is a faithful
154
- // projection of the source again — and the move the user asked for did
155
- // not happen, with nothing left to try it. `abandoned` is the value that
156
- // says so without claiming a retry is coming (imap-mutations R3); the
157
- // alert stays for the operator, and the mutation names itself so the
158
- // reading pane offers the right way out (issue #1229).
159
- syncStatus: MessageSyncStatus.abandoned,
160
- abandonedMutation: MessageMutation.move,
161
- });
162
-
163
- return { outcome: "broken" };
164
- };
77
+ export declare const resolveExhaustedMessageMoveFailure: (deps: ResolveExhaustedMessageMoveDeps, input: ResolveExhaustedMessageMoveInput) => Promise<ResolveExhaustedMessageMoveResult>;