@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,371 @@
1
+ import { MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
2
+ import { guardConnectionCursor, isCursorRebuildNeeded, MailboxCursorPausedError, } from "@remit/mailbox-service";
3
+ import { isAccountDeleted } from "../account-check.js";
4
+ import { emitFrom } from "../emit.js";
5
+ import { findMailboxRow } from "../mailbox-row.js";
6
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
7
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
8
+ import { emitMoveResync, searchMailboxForHighestMessageIdUid, } from "./message-move.js";
9
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
10
+ /**
11
+ * Handle MESSAGE_COPY events.
12
+ * Executes IMAP COPY command and updates local state with new UID.
13
+ *
14
+ * IMAP COPY is not idempotent and the message queue is FIFO per account, so
15
+ * this handler never manufactures a retry to resolve an ambiguous answer: a
16
+ * second COPY duplicates the mail, and a record that keeps coming back blocks
17
+ * every later move, copy and delete for that account (issues #287, #289,
18
+ * #290). Ambiguity is resolved in place, by asking the destination.
19
+ *
20
+ * A fault the server threw is still retried up to
21
+ * the `config.attempts.messageCopy` budget, because it may be a dropped connection.
22
+ * Two guards make that safe: a redelivery asks the destination BEFORE issuing
23
+ * COPY again, and the last attempt settles the row instead of dead-lettering
24
+ * it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
25
+ * its ThreadMessage row at `uid: 0` before the server has confirmed anything,
26
+ * docs/architecture/imap-mutations.md R2) and these terminal settles are its
27
+ * reconciliation path: every verdict the server gives an answer for settles the
28
+ * row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
29
+ * gives no verdict — the record dead-letters with the row untouched, the same
30
+ * caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
31
+ * ever concluded from an answer.
32
+ */
33
+ export const handleMessageCopy = async (event, ctx, receiveCount) => {
34
+ const { log, pool, config } = ctx;
35
+ const emit = emitFrom(ctx);
36
+ const { account: accountService, message: messageService, threadMessage: threadMessageService, mailbox: mailboxService, secrets, } = ctx.client;
37
+ const { accountId, sourceMessageId, newMessageId, sourceMailboxId, destinationMailboxId, uid, } = event;
38
+ log.info({
39
+ event: event.type,
40
+ accountId,
41
+ sourceMessageId,
42
+ newMessageId,
43
+ from: sourceMailboxId,
44
+ to: destinationMailboxId,
45
+ }, "Handling event");
46
+ const account = await accountService.get(accountId);
47
+ if (!account) {
48
+ throw new Error(`Account ${accountId} not found`);
49
+ }
50
+ if (isAccountDeleted(account, log)) {
51
+ return;
52
+ }
53
+ const [copyRow] = await messageService.get([newMessageId]);
54
+ // The copy row is already gone — a reconciliation path settled it. Nothing
55
+ // left to bind a UID to; ack.
56
+ if (!copyRow) {
57
+ log.warn({ accountId, sourceMessageId, newMessageId }, "Skipping MESSAGE_COPY: copy row no longer exists");
58
+ return;
59
+ }
60
+ // This copy already settled — `updateUid` cleared `status: moving` when the
61
+ // server confirmed it. A redelivery reaching here would COPY the message a
62
+ // second time, and COPY has no source-side effect to make that a no-op.
63
+ //
64
+ // `moving` specifically, not the shared unsettled predicate: the question is
65
+ // whether THIS copy is still outstanding, and a `deleting` row is somebody
66
+ // else's work — reading it as unsettled is what would let the second COPY
67
+ // through.
68
+ const copyPlacement = {
69
+ status: copyRow.status,
70
+ mailboxId: copyRow.mailboxId,
71
+ uid: copyRow.uid,
72
+ };
73
+ if (copyRow.status !== MessageStatus.moving) {
74
+ log.info({ accountId, newMessageId, uid: copyRow.uid, status: copyRow.status }, "Skipping MESSAGE_COPY: the copy already settled against confirmed IMAP state");
75
+ return;
76
+ }
77
+ // `failed` is an earlier attempt's mark, and the re-drive's: either may
78
+ // have issued a COPY whose answer was lost.
79
+ const earlierAttempt = receiveCount > 1 || copyRow.syncStatus === MessageSyncStatus.failed;
80
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
81
+ // The source folder can be deleted between enqueue and this sync, leaving
82
+ // a queued event pointing at a gone row. The lookup then throws
83
+ // NotFoundError forever, and on the account's per-group FIFO that head
84
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
85
+ // source mailbox makes the copy moot: ack with a WARN.
86
+ const source = await findMailboxRow(mailboxService, accountId, sourceMailboxId);
87
+ if (source.kind === "gone") {
88
+ log.warn({ accountId, sourceMessageId, mailboxId: sourceMailboxId }, "Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)");
89
+ return;
90
+ }
91
+ const mailbox = source.mailbox;
92
+ const sourceMailboxPath = mailbox.fullPath;
93
+ const destination = await findMailboxRow(mailboxService, accountId, destinationMailboxId);
94
+ if (destination.kind === "gone") {
95
+ const reconciled = await messageService.deleteIfPlacement(newMessageId, copyPlacement);
96
+ log.warn({
97
+ metric: "message_copy_destination_gone",
98
+ accountId,
99
+ sourceMessageId,
100
+ newMessageId,
101
+ mailboxId: destinationMailboxId,
102
+ reconciled,
103
+ }, "Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away");
104
+ return;
105
+ }
106
+ const destinationMailboxPath = destination.mailbox.fullPath;
107
+ // The copy row carries the source's Message-ID header, so the
108
+ // destination can be asked where this copy is. Always on the UNGUARDED
109
+ // handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
110
+ // snapshot alone, and opening the destination through it trips that
111
+ // mailbox into a rebuild.
112
+ const probeDestination = async (connection) => {
113
+ const messageIdHeader = await probeableMessageIdHeader(threadMessageService, account.accountConfigId, newMessageId);
114
+ if (!messageIdHeader)
115
+ return { kind: "unprobeable" };
116
+ const probedUid = await searchMailboxForHighestMessageIdUid(connection, destinationMailboxPath, messageIdHeader);
117
+ return probedUid === null
118
+ ? { kind: "absent" }
119
+ : { kind: "confirmed", uid: probedUid };
120
+ };
121
+ const settleCopied = async (newUid) => {
122
+ // `updateUid` writes the confirmed uid, `active` and `synced` in one
123
+ // statement, so the second write this used to make said nothing the
124
+ // first had not — and said it without a predicate.
125
+ await messageService.updateUid(newMessageId, newUid, destinationMailboxId);
126
+ const threadMessage = await threadMessageService.findByMessageId(account.accountConfigId, newMessageId);
127
+ if (threadMessage) {
128
+ await threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { uid: newUid }, {
129
+ composites: {
130
+ sentDate: threadMessage.sentDate,
131
+ mailboxId: threadMessage.mailboxId,
132
+ isRead: threadMessage.isRead,
133
+ isDeleted: threadMessage.isDeleted,
134
+ hasStars: threadMessage.hasStars,
135
+ hasAttachment: threadMessage.hasAttachment,
136
+ },
137
+ });
138
+ }
139
+ log.info({
140
+ sourceMessageId,
141
+ newMessageId,
142
+ oldUid: uid,
143
+ newUid,
144
+ destination: destinationMailboxPath,
145
+ }, "Message copied successfully");
146
+ };
147
+ // The destination answered that it does not hold this message, so the
148
+ // copy never landed and the optimistic row records something the server
149
+ // does not have. Deleting it (with its ThreadMessage rows) is the exact
150
+ // reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
151
+ // calls `reconciled`. Routine: metric, no alarm.
152
+ const settleNeverLanded = async (reason) => {
153
+ const reconciled = await messageService.deleteIfPlacement(newMessageId, copyPlacement);
154
+ log.info({
155
+ metric: "message_copy_not_landed",
156
+ accountId,
157
+ sourceMessageId,
158
+ newMessageId,
159
+ destination: destinationMailboxPath,
160
+ reconciled,
161
+ reason,
162
+ }, "Copy absent from its destination; optimistic copy row reconciled away");
163
+ };
164
+ // The server never answered where the copy is, so nothing here may
165
+ // delete a row that might describe real mail. The row is settled out of
166
+ // `moving` instead — `status: deleted` is the state this handler already
167
+ // writes for a copy that will not happen, and it keeps `holdsCopyOf`
168
+ // answering true so a sighting at the destination cannot drag the SOURCE
169
+ // row out of the folder it was copied from.
170
+ //
171
+ // The cost, paid deliberately: if that COPY did land, this row is the
172
+ // only one the copy will ever have — `holdsCopyOf` tests row existence
173
+ // alone (`message-sync.ts`), so no sync creates a second one — and
174
+ // `deleted` hides it. Real mail then sits at the destination on the
175
+ // server and invisible in the product until an operator acts on the
176
+ // alert. `settleNeverLanded` has the opposite property: it deletes only
177
+ // what the server denied holding, and a later sync re-projects anything
178
+ // that turns out to be there. Silence buys the safer half of one pair
179
+ // and the worse half of the other.
180
+ const settleBroken = async (reason) => {
181
+ // A transition, not a plain update (imap-mutations R3): the copy row
182
+ // is only this handler's to settle while its own placement is still
183
+ // unsettled. The row it leaves is `deleted`, so no listing carries it
184
+ // and the marker is read by nothing — it is written anyway, because a
185
+ // give-up that does not name itself is the gap #1229 came from.
186
+ await messageService.transitionPlacement(newMessageId, { status: MessageStatus.moving }, {
187
+ status: MessageStatus.deleted,
188
+ syncStatus: MessageSyncStatus.abandoned,
189
+ abandonedMutation: MessageMutation.copy,
190
+ });
191
+ log.error({
192
+ alert: "message_copy_unconfirmed",
193
+ accountId,
194
+ sourceMessageId,
195
+ newMessageId,
196
+ uid,
197
+ destination: destinationMailboxPath,
198
+ reason,
199
+ }, "Copy could not be bound to a destination UID; row settled failed for operator investigation");
200
+ };
201
+ const scope = pool.borrow(account, credentials);
202
+ // A paused cursor settles rather than acks: acking leaves the optimistic
203
+ // row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
204
+ // set for the cursor rebuild to find it in (issue #1203). Like every
205
+ // other verdict on this row, it reconciles rather than waits (R2,
206
+ // docs/architecture/imap-mutations.md) — the settle below IS the
207
+ // reconciliation path, since the destination is not paused and no
208
+ // rebuild is coming for it.
209
+ //
210
+ // What may be concluded from the pause depends on what was issued, and
211
+ // `receiveCount` alone does not say: `commandIssued` is what this round
212
+ // did, a redelivery is what an earlier one may have done and lost with
213
+ // its connection. Where neither says the server may have been asked, the
214
+ // row records something the server was never told about and reconciling
215
+ // it away is exact. Otherwise this row may be the only record real mail
216
+ // will ever have, and deleting it would let the next destination sync
217
+ // repoint the SOURCE row out of the folder the server still holds it in.
218
+ // The pause is on the source and says nothing about the destination,
219
+ // which is probed on the unguarded handle anyway, so the destination is
220
+ // asked before anything is deleted and silence settles broken.
221
+ //
222
+ // A settle is a settle, so both folders re-read their counts from IMAP,
223
+ // the same resync the move and delete handlers run: the destination is
224
+ // not paused, so nothing else is coming to re-project it.
225
+ const settlePausedCopy = async (commandIssued) => {
226
+ const mayHaveIssued = commandIssued || earlierAttempt;
227
+ if (!mayHaveIssued) {
228
+ await settleNeverLanded("source mailbox cursor paused before the copy");
229
+ }
230
+ else {
231
+ const probe = await probeDestination(await scope.getConnection());
232
+ if (probe.kind === "confirmed") {
233
+ await settleCopied(probe.uid);
234
+ }
235
+ else if (probe.kind === "absent") {
236
+ await settleNeverLanded("redelivered onto a paused source cursor, absent from destination");
237
+ }
238
+ else {
239
+ await settleBroken("redelivered onto a paused source cursor with no Message-ID to ask the destination with");
240
+ }
241
+ }
242
+ await emitMoveResync(emit, {
243
+ accountId,
244
+ sourceMailboxId,
245
+ destinationMailboxId,
246
+ });
247
+ };
248
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
249
+ // paused never even opens a connection on a first delivery. Optimization
250
+ // only — the guardConnectionCursor openBox wrap below is the structural
251
+ // guarantee.
252
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
253
+ log.info({ accountId, sourceMessageId, mailboxId: sourceMailboxId }, "Mailbox cursor not normal; pausing outbound copy this round");
254
+ await settlePausedCopy(false).finally(() => scope.release());
255
+ return;
256
+ }
257
+ // Flipped the instant the COPY leaves, so every settle downstream of it
258
+ // knows the server was asked. IMAP COPY is not idempotent, so this is
259
+ // the flag that must never be inferred.
260
+ let commandIssued = false;
261
+ await scope
262
+ .getConnection()
263
+ .then(async (rawConnection) => {
264
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
265
+ // a fresh mismatch trips the mailbox and throws once the SELECT
266
+ // reveals it.
267
+ const connection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, mailbox);
268
+ // A redelivery means the previous attempt threw, which does not
269
+ // mean its COPY failed — the tagged OK can be lost with the
270
+ // connection. Ask before copying again, or the retry is what
271
+ // duplicates the mail. `absent` is an answer the server gave, so
272
+ // copying again is safe; `unprobeable` is silence, and copying on
273
+ // silence is the duplicate this guard exists to prevent.
274
+ if (earlierAttempt) {
275
+ const earlier = await probeDestination(rawConnection);
276
+ if (earlier.kind === "confirmed") {
277
+ await settleCopied(earlier.uid);
278
+ return;
279
+ }
280
+ if (earlier.kind === "unprobeable") {
281
+ await settleBroken("redelivered with no Message-ID to ask the destination with");
282
+ return;
283
+ }
284
+ }
285
+ // Open source mailbox (read-only is fine for COPY)
286
+ await connection.openBox(sourceMailboxPath, true);
287
+ // Execute IMAP COPY
288
+ commandIssued = true;
289
+ const result = await connection.copyMessages([uid], destinationMailboxPath);
290
+ const copiedUid = result.uidMap.get(uid);
291
+ if (copiedUid) {
292
+ await settleCopied(copiedUid);
293
+ return;
294
+ }
295
+ // A server without UIDPLUS answers a perfectly successful COPY
296
+ // with no COPYUID entry, so an empty map is UNCONFIRMED, never
297
+ // evidence the server matched nothing (#1097). The destination is
298
+ // asked before any verdict, as `message-move.ts` does (#912, #979).
299
+ const probe = await probeDestination(rawConnection);
300
+ if (probe.kind === "confirmed") {
301
+ await settleCopied(probe.uid);
302
+ return;
303
+ }
304
+ if (probe.kind === "absent") {
305
+ await settleNeverLanded("no COPYUID entry, absent from destination");
306
+ return;
307
+ }
308
+ await settleBroken("no COPYUID entry and no Message-ID to probe with");
309
+ })
310
+ .catch(async (error) => {
311
+ if (error instanceof MailboxCursorPausedError) {
312
+ log.info({
313
+ accountId,
314
+ sourceMessageId,
315
+ mailboxId: sourceMailboxId,
316
+ cursorState: error.state,
317
+ }, "Mailbox cursor not normal; pausing outbound copy this round");
318
+ await settlePausedCopy(commandIssued);
319
+ return;
320
+ }
321
+ const errorMessage = error instanceof Error ? error.message : String(error);
322
+ // Handle TRYCREATE - destination doesn't exist
323
+ if (errorMessage.includes("TRYCREATE")) {
324
+ log.info({ destinationMailboxPath }, "Destination mailbox doesn't exist, creating");
325
+ const connection = await scope.getConnection();
326
+ await connection.createMailbox(destinationMailboxPath);
327
+ // Re-throw to let the event be retried against the folder just
328
+ // created. Kept unconditional past the attempt budget, as the
329
+ // move path does: the destination now exists, so the record is
330
+ // worth redriving from the DLQ.
331
+ throw error;
332
+ }
333
+ // Handle source message not found on IMAP - already deleted (fail the copy)
334
+ if (errorMessage.includes("not found") ||
335
+ errorMessage.includes("NONEXISTENT")) {
336
+ log.info({ sourceMessageId, uid }, "Source message not found on IMAP, marking copy as failed");
337
+ await messageService.transitionPlacement(newMessageId, { status: MessageStatus.moving }, {
338
+ status: MessageStatus.deleted,
339
+ syncStatus: MessageSyncStatus.abandoned,
340
+ abandonedMutation: MessageMutation.copy,
341
+ });
342
+ return;
343
+ }
344
+ if (receiveCount < config.attempts.messageCopy) {
345
+ // Transient copy failure — expected (connections drop). Queue
346
+ // redelivery retries, and the probe above keeps the retry from
347
+ // copying the message twice.
348
+ await messageService.transitionPlacement(newMessageId, { status: MessageStatus.moving }, { syncStatus: MessageSyncStatus.failed });
349
+ throw error;
350
+ }
351
+ // Redelivery budget exhausted. Dead-lettering here would leave the
352
+ // row at `uid: 0`/`moving` with nothing left to settle it and the
353
+ // account's FIFO group still holding the record, so the row is
354
+ // resolved into one terminal outcome instead — never by inferring
355
+ // the server's state from our own failures. A server that cannot be
356
+ // reached reaches no verdict: the probe throws and the record
357
+ // dead-letters with the row untouched.
358
+ const probe = await probeDestination(await scope.getConnection());
359
+ if (probe.kind === "confirmed") {
360
+ await settleCopied(probe.uid);
361
+ return;
362
+ }
363
+ if (probe.kind === "absent") {
364
+ await settleNeverLanded(`retry exhausted: ${errorMessage}`);
365
+ return;
366
+ }
367
+ await settleBroken(`retry exhausted: ${errorMessage}`);
368
+ })
369
+ .finally(() => scope.release());
370
+ });
371
+ };
@@ -0,0 +1,60 @@
1
+ import type { IMessageRepository, IThreadMessageRepository, MessageItem } from "@remit/data-ports";
2
+ import { type IImapConnection } from "@remit/mailbox-service";
3
+ export interface MessageDeleteTerminalLogger {
4
+ info(obj: Record<string, unknown>, msg: string): void;
5
+ error(obj: Record<string, unknown>, msg: string): void;
6
+ }
7
+ export interface ResolveExhaustedMessageDeleteDeps {
8
+ messageService: Pick<IMessageRepository, "deleteIfPlacement" | "transitionPlacement">;
9
+ threadMessageService: Pick<IThreadMessageRepository, "findAllByMessageId" | "deleteMany" | "update">;
10
+ log: MessageDeleteTerminalLogger;
11
+ }
12
+ export interface ResolveExhaustedMessageDeleteInput {
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>;
22
+ }
23
+ export type MessageDeleteTerminalOutcome = "reconciled" | "broken";
24
+ export interface ResolveExhaustedMessageDeleteResult {
25
+ outcome: MessageDeleteTerminalOutcome;
26
+ }
27
+ /**
28
+ * Resolve a MESSAGE_DELETE failure that has exhausted its redelivery budget
29
+ * into exactly one of two terminal outcomes, mirroring
30
+ * `resolveExhaustedMessageMoveFailure` for the same failure taxonomy (issue
31
+ * #655) — no third, softer outcome.
32
+ *
33
+ * 1. RECONCILED (expected) — the message no longer exists at the delete's
34
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
35
+ * than by a FETCH coming back empty. The move to Trash landed server-side,
36
+ * or a foreign client moved or expunged the message; from here those are
37
+ * indistinguishable and have the same answer. The stale rows are deleted via
38
+ * `deleteIfPlacement` and the caller resyncs the affected folders,
39
+ * so whichever folder actually holds the message re-projects it with the
40
+ * server's own UID. Metric only, no alarm — routine.
41
+ * 2. BROKEN — the message is still at the source, so the delete never took
42
+ * effect, but it keeps failing: broken code or a broken account, not a
43
+ * transient blip. The row is put back where the server has just said the
44
+ * message is: `mailboxId` and `uid` return to the delete's source pair, and
45
+ * the thread rows lose the deletion mark the optimistic transition
46
+ * wrote (issue #1098).
47
+ *
48
+ * Settling `status` to `active` while leaving `mailboxId` on Trash is what
49
+ * this replaces. `bindsForeignUid` refuses a mismatched pair only while
50
+ * `status` is `moving`, so clearing it read as consistent, and the UI then
51
+ * offered "Delete permanently" on a Trash mailbox with the source folder's
52
+ * uid — an expunge naming a different message. Restoring the pair is not a
53
+ * revert on ambiguity (what PR #652 was pulled for): the presence probe
54
+ * above has just answered, so this writes a placement the server confirmed.
55
+ *
56
+ * A server that cannot be reached at exhaustion time never reaches either
57
+ * verdict: the probe throws and the record dead-letters with the row untouched.
58
+ * Absence is only ever concluded from an answer the server gave.
59
+ */
60
+ export declare const resolveExhaustedMessageDeleteFailure: (deps: ResolveExhaustedMessageDeleteDeps, input: ResolveExhaustedMessageDeleteInput) => Promise<ResolveExhaustedMessageDeleteResult>;
@@ -0,0 +1,77 @@
1
+ import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
2
+ import { isMessageGoneFromOpenMailbox, restoreSourcePlacement, } from "@remit/mailbox-service";
3
+ /**
4
+ * Resolve a MESSAGE_DELETE failure that has exhausted its redelivery budget
5
+ * into exactly one of two terminal outcomes, mirroring
6
+ * `resolveExhaustedMessageMoveFailure` for the same failure taxonomy (issue
7
+ * #655) — no third, softer outcome.
8
+ *
9
+ * 1. RECONCILED (expected) — the message no longer exists at the delete's
10
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
11
+ * than by a FETCH coming back empty. The move to Trash landed server-side,
12
+ * or a foreign client moved or expunged the message; from here those are
13
+ * indistinguishable and have the same answer. The stale rows are deleted via
14
+ * `deleteIfPlacement` and the caller resyncs the affected folders,
15
+ * so whichever folder actually holds the message re-projects it with the
16
+ * server's own UID. Metric only, no alarm — routine.
17
+ * 2. BROKEN — the message is still at the source, so the delete never took
18
+ * effect, but it keeps failing: broken code or a broken account, not a
19
+ * transient blip. The row is put back where the server has just said the
20
+ * message is: `mailboxId` and `uid` return to the delete's source pair, and
21
+ * the thread rows lose the deletion mark the optimistic transition
22
+ * wrote (issue #1098).
23
+ *
24
+ * Settling `status` to `active` while leaving `mailboxId` on Trash is what
25
+ * this replaces. `bindsForeignUid` refuses a mismatched pair only while
26
+ * `status` is `moving`, so clearing it read as consistent, and the UI then
27
+ * offered "Delete permanently" on a Trash mailbox with the source folder's
28
+ * uid — an expunge naming a different message. Restoring the pair is not a
29
+ * revert on ambiguity (what PR #652 was pulled for): the presence probe
30
+ * above has just answered, so this writes a placement the server confirmed.
31
+ *
32
+ * A server that cannot be reached at exhaustion time never reaches either
33
+ * verdict: the probe throws and the record dead-letters with the row untouched.
34
+ * Absence is only ever concluded from an answer the server gave.
35
+ */
36
+ export const resolveExhaustedMessageDeleteFailure = async (deps, input) => {
37
+ const { accountId, accountConfigId, messageId, sourceMailboxId, uid, sourceMailboxPath, getConnection, } = input;
38
+ const connection = await getConnection();
39
+ await connection.openBox(sourceMailboxPath, true);
40
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
41
+ const reconciled = await deps.messageService.deleteIfPlacement(messageId, input.placement);
42
+ deps.log.info({
43
+ metric: "message_delete_stale_row_reconciled",
44
+ accountId,
45
+ accountConfigId,
46
+ messageId,
47
+ uid,
48
+ sourceMailboxPath,
49
+ reconciled,
50
+ }, "Message no longer at its delete source after retry exhaustion (the delete landed server-side, or an external delete or move); stale row reconciled");
51
+ return { outcome: "reconciled" };
52
+ }
53
+ deps.log.error({
54
+ alert: "message_delete_failed",
55
+ accountId,
56
+ accountConfigId,
57
+ messageId,
58
+ uid,
59
+ sourceMailboxId,
60
+ sourceMailboxPath,
61
+ }, "Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder");
62
+ await restoreSourcePlacement(deps, {
63
+ accountConfigId,
64
+ messageId,
65
+ sourceMailboxId,
66
+ uid,
67
+ // The probe above just confirmed the pair, so the row is a faithful
68
+ // projection of the source again — and the delete the user asked for did
69
+ // not happen, with nothing left to try it. `abandoned` is the value that
70
+ // says so without claiming a retry is coming (imap-mutations R3); the
71
+ // alert stays for the operator, and the mutation names itself so the
72
+ // reading pane offers the right way out (issue #1229).
73
+ syncStatus: MessageSyncStatus.abandoned,
74
+ abandonedMutation: MessageMutation.delete,
75
+ });
76
+ return { outcome: "broken" };
77
+ };
@@ -0,0 +1,57 @@
1
+ import type { IThreadMessageRepository } from "@remit/data-ports";
2
+ import type { ProcessContext } from "../context.js";
3
+ import type { MessageDeleteEvent } from "../events.js";
4
+ /**
5
+ * Delete every ThreadMessage row that points at this messageId.
6
+ *
7
+ * A single Message can have multiple ThreadMessage rows — one per mailbox
8
+ * the message exists in (e.g. INBOX + a label/folder copy). Permanent-delete
9
+ * cleanup must remove ALL of them; using `findByMessageId` (single row) leaves
10
+ * orphan rows in other mailboxes that then leak into their listings. See
11
+ * issue #212.
12
+ */
13
+ export declare const deleteAllThreadMessagesForMessage: (threadMessageService: Pick<IThreadMessageRepository, "findAllByMessageId" | "delete">, accountConfigId: string, messageId: string) => Promise<number>;
14
+ /**
15
+ * Settle a move to Trash the server left unconfirmed, by asking it two
16
+ * read-only questions instead of one. Both handles wrap the SAME connection but
17
+ * are scoped to their own mailbox: a `guardConnectionCursor` wrap binds its
18
+ * checks to the ONE mailbox snapshot it was built with, so the destination must
19
+ * never be opened through the source's guard.
20
+ *
21
+ * The source is asked first, and a source that still holds the uid ends it: the
22
+ * MOVE did not happen, so nothing at the destination can be this message. The
23
+ * gate survives #1122's switch to the highest matching uid and is not made
24
+ * redundant by it — highest picks the fresh copy out of several only once a
25
+ * fresh copy exists, and where the MOVE never ran every hit at the destination
26
+ * is an older copy of the same Message-ID (a sieve `fileinto` + `keep`, a
27
+ * multi-label store, a resend) that `deriveMessageId` folds into this one local
28
+ * row. An ungated probe hands back that copy's uid, and Empty Trash then
29
+ * expunges by it. The gate also closes the second half of #912: an empty
30
+ * `uidMap` can mean the MOVE matched nothing at all.
31
+ *
32
+ * A row with no `messageIdHeader`, and a row that is already deleted, have
33
+ * nothing to probe with; they are distinct verdicts because no redelivery can
34
+ * change either answer.
35
+ */
36
+ export type TrashMoveConfirmation = {
37
+ outcome: "confirmed";
38
+ uid: number;
39
+ } | {
40
+ outcome: "still-at-source";
41
+ } | {
42
+ outcome: "row-gone";
43
+ } | {
44
+ outcome: "unprobeable";
45
+ } | {
46
+ outcome: "unconfirmed";
47
+ };
48
+ /**
49
+ * Handle MESSAGE_DELETE events.
50
+ * Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
51
+ *
52
+ * A failing delete retries on redelivery until `receiveCount` reaches
53
+ * the `config.attempts.messageDelete` budget, at which point
54
+ * {@link resolveExhaustedMessageDeleteFailure} asks IMAP where the message
55
+ * actually is and settles the row into one terminal outcome (issue #980).
56
+ */
57
+ export declare const handleMessageDelete: (event: MessageDeleteEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;