@remit/imap-worker 0.0.90 → 0.0.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/account-check.d.ts +27 -0
  2. package/dist/account-check.js +70 -0
  3. package/dist/body-sync-gate.d.ts +12 -0
  4. package/dist/body-sync-gate.js +53 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/config-env.d.ts +5 -0
  8. package/dist/config-env.js +37 -0
  9. package/dist/connection-pool.d.ts +22 -0
  10. package/dist/connection-pool.js +97 -0
  11. package/dist/connection-scope.d.ts +8 -0
  12. package/dist/connection-scope.js +10 -0
  13. package/dist/context.d.ts +46 -0
  14. package/dist/context.js +15 -0
  15. package/dist/e2e-processor-shim.d.ts +2 -0
  16. package/dist/e2e-processor-shim.js +181 -0
  17. package/dist/emit.d.ts +15 -0
  18. package/dist/emit.js +76 -0
  19. package/dist/events.d.ts +203 -0
  20. package/dist/events.js +1 -0
  21. package/dist/failure-kind.d.ts +10 -0
  22. package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
  23. package/dist/handlers/append-sent-message.d.ts +3 -0
  24. package/dist/handlers/append-sent-message.js +152 -0
  25. package/dist/handlers/delete-account-objects.d.ts +3 -0
  26. package/dist/handlers/delete-account-objects.js +35 -0
  27. package/dist/handlers/empty-trash.d.ts +14 -0
  28. package/dist/handlers/empty-trash.js +217 -0
  29. package/dist/handlers/flag-push.d.ts +38 -0
  30. package/dist/handlers/flag-push.js +195 -0
  31. package/dist/handlers/imported-folder.d.ts +4 -0
  32. package/dist/handlers/imported-folder.js +23 -0
  33. package/dist/handlers/mailbox-management.d.ts +6 -0
  34. package/dist/handlers/mailbox-management.js +294 -0
  35. package/dist/handlers/mailbox-sync-order.d.ts +21 -0
  36. package/dist/handlers/mailbox-sync-order.js +50 -0
  37. package/dist/handlers/message-copy.d.ts +40 -0
  38. package/dist/handlers/message-copy.js +371 -0
  39. package/dist/handlers/message-delete-terminal.d.ts +60 -0
  40. package/dist/handlers/message-delete-terminal.js +77 -0
  41. package/dist/handlers/message-delete.d.ts +57 -0
  42. package/dist/handlers/message-delete.js +520 -0
  43. package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
  44. package/dist/handlers/message-move-terminal.js +94 -0
  45. package/dist/handlers/message-move.d.ts +146 -0
  46. package/dist/handlers/message-move.js +490 -0
  47. package/dist/handlers/placement-move-push.d.ts +62 -0
  48. package/dist/handlers/placement-move-push.js +307 -0
  49. package/dist/handlers/probe-message-id.d.ts +2 -0
  50. package/dist/handlers/probe-message-id.js +6 -0
  51. package/dist/handlers/sync-mailboxes.d.ts +45 -0
  52. package/dist/handlers/sync-mailboxes.js +233 -0
  53. package/dist/handlers/sync-message-body.d.ts +23 -0
  54. package/dist/handlers/sync-message-body.js +182 -0
  55. package/dist/handlers/sync-messages.d.ts +29 -0
  56. package/dist/handlers/sync-messages.js +285 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.js +67 -0
  59. package/dist/lambda-context.d.ts +8 -0
  60. package/dist/lambda-context.js +33 -0
  61. package/dist/mailbox-row.d.ts +9 -0
  62. package/dist/mailbox-row.js +6 -0
  63. package/dist/poller.d.ts +1 -0
  64. package/dist/poller.js +51 -0
  65. package/dist/process.d.ts +6 -0
  66. package/dist/process.js +3 -0
  67. package/dist/processor.d.ts +3 -0
  68. package/dist/processor.js +44 -0
  69. package/dist/queues.d.ts +10 -0
  70. package/dist/queues.js +1 -0
  71. package/dist/s3-account-objects.d.ts +3 -0
  72. package/dist/s3-account-objects.js +26 -0
  73. package/dist/scheduler/attachment-sweep.d.ts +11 -0
  74. package/dist/scheduler/attachment-sweep.js +23 -0
  75. package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +27 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
  79. package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
  80. package/dist/scheduler/decide-due.js +17 -0
  81. package/dist/scheduler/handler.d.ts +21 -0
  82. package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +25 -42
  83. package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
  84. package/dist/scheduler/loop.js +8 -0
  85. package/dist/scheduler/run-tick.d.ts +62 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +57 -78
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/sqs-queues.d.ts +4 -0
  92. package/dist/sqs-queues.js +22 -0
  93. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  94. package/dist/with-oauth-lifecycle-deps.js +17 -0
  95. package/dist/with-oauth-lifecycle.d.ts +56 -0
  96. package/dist/with-oauth-lifecycle.js +92 -0
  97. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  98. package/dist/worker-version.js +12 -0
  99. package/package.json +75 -69
  100. package/src/account-check.test.ts +0 -149
  101. package/src/account-check.ts +0 -101
  102. package/src/body-sync-gate.test.ts +0 -185
  103. package/src/body-sync-gate.ts +0 -86
  104. package/src/cli.ts +0 -218
  105. package/src/connection-scope.test.ts +0 -266
  106. package/src/connection-scope.ts +0 -314
  107. package/src/credential-less-sync-fence.test.ts +0 -152
  108. package/src/e2e-processor-shim.ts +0 -232
  109. package/src/emit.test.ts +0 -145
  110. package/src/emit.ts +0 -115
  111. package/src/events.ts +0 -252
  112. package/src/failure-kind.test.ts +0 -42
  113. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  114. package/src/handlers/append-sent-message.ts +0 -264
  115. package/src/handlers/delete-account-objects.test.ts +0 -126
  116. package/src/handlers/delete-account-objects.ts +0 -96
  117. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  118. package/src/handlers/empty-trash.ts +0 -352
  119. package/src/handlers/flag-push.ts +0 -285
  120. package/src/handlers/imported-folder.test.ts +0 -63
  121. package/src/handlers/imported-folder.ts +0 -38
  122. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  123. package/src/handlers/mailbox-management.ts +0 -486
  124. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  125. package/src/handlers/mailbox-sync-order.ts +0 -65
  126. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  127. package/src/handlers/message-copy.ts +0 -569
  128. package/src/handlers/message-delete-terminal.ts +0 -147
  129. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  130. package/src/handlers/message-delete.ts +0 -888
  131. package/src/handlers/message-move.sqlite.test.ts +0 -271
  132. package/src/handlers/message-move.test.ts +0 -216
  133. package/src/handlers/message-move.ts +0 -755
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -478
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  137. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  138. package/src/handlers/probe-message-id.ts +0 -15
  139. package/src/handlers/sync-mailboxes.test.ts +0 -183
  140. package/src/handlers/sync-mailboxes.ts +0 -425
  141. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  142. package/src/handlers/sync-message-body.test.ts +0 -150
  143. package/src/handlers/sync-message-body.ts +0 -366
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  145. package/src/handlers/sync-messages.ts +0 -537
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -82
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -165
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -62
  153. package/src/queues.ts +0 -40
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  155. package/src/scheduler/attachment-sweep.ts +0 -46
  156. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  157. package/src/scheduler/calendar-subscriptions.ts +0 -103
  158. package/src/scheduler/config.test.ts +0 -40
  159. package/src/scheduler/decide-due.test.ts +0 -44
  160. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  161. package/src/scheduler/loop.test.ts +0 -143
  162. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  163. package/src/scheduler/run-tick.ts +0 -281
  164. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  165. package/src/stranded-placement.sqlite.test.ts +0 -799
  166. package/src/test-helpers/test-world.ts +0 -425
  167. package/src/test-helpers/worker-queues.ts +0 -11
  168. package/src/thread-join.sqlite.test.ts +0 -106
  169. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  170. package/src/with-oauth-lifecycle-deps.ts +0 -71
  171. package/src/with-oauth-lifecycle.ts +0 -158
  172. package/tsconfig.json +0 -8
@@ -1,352 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import type { MessageItem } from "@remit/data-ports";
3
- import { trashMailboxAt } from "@remit/data-ports/folder-role";
4
- import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
5
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
6
- import type { Logger } from "@remit/logger";
7
- import {
8
- buildThreadMessageUndelete,
9
- carriesForeignUid,
10
- guardConnectionCursor,
11
- isCursorRebuildNeeded,
12
- MailboxCursorPausedError,
13
- } from "@remit/mailbox-service";
14
- import { isAccountDeleted } from "../account-check.js";
15
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
16
- import type { EmptyTrashEvent } from "../events.js";
17
- import { findMailboxRow } from "../mailbox-row.js";
18
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
-
21
- /**
22
- * Handle EMPTY_TRASH events.
23
- * Permanently deletes all messages in the Trash mailbox.
24
- *
25
- * The only unrecoverable mutation reader issues, so the folder's identity is
26
- * confirmed here, on the connection that does the expunging: the role still
27
- * names this mailbox on confirmed evidence, and the UIDVALIDITY the SELECT
28
- * serves still matches the one the user consented to. Every refusal reverts
29
- * the optimistic local marks and acks — a throw would stall the account's FIFO
30
- * group behind an event no retry can fix (issues #287, #289, #290).
31
- */
32
- export const handleEmptyTrash = async (
33
- event: EmptyTrashEvent,
34
- log: Logger,
35
- createConnectionScope = createConnectionScopeWithCredentials,
36
- ): Promise<void> => {
37
- const {
38
- account: accountService,
39
- message: messageService,
40
- threadMessage: threadMessageService,
41
- mailbox: mailboxService,
42
- mailboxSpecialUse: mailboxSpecialUseService,
43
- secrets,
44
- } = await getClient();
45
-
46
- const { accountId, trashMailboxId } = event;
47
-
48
- log.info({ event: event.type, accountId, trashMailboxId }, "Handling event");
49
-
50
- const account = await accountService.get(accountId);
51
- if (!account) {
52
- throw new Error(`Account ${accountId} not found`);
53
- }
54
-
55
- if (isAccountDeleted(account, log)) {
56
- return;
57
- }
58
-
59
- // `emptyTrash` marks every row in the folder `deleting` + `isDeleted` before
60
- // enqueueing, so any row this expunge does not carry through is hidden from
61
- // every listing with nothing else to unhide it: no sync clears it, a repeat
62
- // Empty Trash skips it again, and listings filter `isDeleted`. Handing it
63
- // back is right whichever way it got left behind — mail already gone from
64
- // the server becomes visible and the next cursor rebuild reconciles it, and
65
- // an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
66
- // the mail is intact, and saying otherwise about a whole folder is a lie.
67
- const handBackMarkedRows = async (
68
- rows: readonly Pick<
69
- MessageItem,
70
- "messageId" | "status" | "mailboxId" | "uid"
71
- >[],
72
- ): Promise<number> => {
73
- let revertedCount = 0;
74
- for (const message of rows) {
75
- if (message.status !== MessageStatus.deleting) continue;
76
-
77
- // Empty Trash only flips `isDeleted` on the listing rows; a permanent
78
- // delete removes them up front. So a `deleting` row with no listing row
79
- // was marked by some other in-flight operation, and reverting the
80
- // Message under it would resurrect what that one is about to remove.
81
- const threadMessages = await threadMessageService.findAllByMessageId(
82
- account.accountConfigId,
83
- message.messageId,
84
- );
85
- if (threadMessages.length === 0) continue;
86
-
87
- // The hand-back is a transition off the row as this sweep read it
88
- // (imap-mutations R3), because the snapshot it walks holds nothing:
89
- // between the listing and here another lane can settle the row onto a
90
- // placement of its own, and writing `active` + `synced` over that would
91
- // declare a mutation settled that nobody confirmed.
92
- const restored = await messageService.transitionPlacement(
93
- message.messageId,
94
- {
95
- status: MessageStatus.deleting,
96
- mailboxId: message.mailboxId,
97
- uid: message.uid,
98
- },
99
- {
100
- status: MessageStatus.active,
101
- syncStatus: MessageSyncStatus.synced,
102
- },
103
- );
104
- if (!restored) {
105
- log.info(
106
- { accountId, messageId: message.messageId },
107
- "Empty Trash left a row alone: its placement changed after the sweep read it",
108
- );
109
- continue;
110
- }
111
- for (const threadMessage of threadMessages) {
112
- const args = buildThreadMessageUndelete(threadMessage);
113
- await threadMessageService.update(
114
- threadMessage.accountConfigId,
115
- threadMessage.threadMessageId,
116
- args.set,
117
- { composites: args.composites },
118
- );
119
- }
120
- revertedCount += 1;
121
- }
122
- return revertedCount;
123
- };
124
-
125
- const abandonEmptyTrash = async (
126
- reason: string,
127
- alert: string,
128
- context: Record<string, unknown> = {},
129
- ): Promise<void> => {
130
- log.error({ alert, accountId, trashMailboxId, ...context }, reason);
131
- await handBackMarkedRows(
132
- await messageService.listAllByMailbox(trashMailboxId),
133
- );
134
- };
135
-
136
- if (!isAcceptedSchemaVersion(event.schemaVersion)) {
137
- await abandonEmptyTrash(
138
- "Refused to empty trash: event was minted under an unknown contract",
139
- "empty_trash_unknown_schema_version",
140
- { schemaVersion: event.schemaVersion },
141
- );
142
- return;
143
- }
144
-
145
- // The role can be re-appointed between consent and this run, and a queued
146
- // event then expunges the folder the user just stopped using as Trash.
147
- // Confirmed evidence only: what the user appointed, or what the server
148
- // flagged.
149
- const resolution = await mailboxSpecialUseService.resolveTrashRole(accountId);
150
- const trashGate = trashMailboxAt(resolution, "confirmed");
151
- if (!trashGate.allowed || trashGate.mailbox.mailboxId !== trashMailboxId) {
152
- await abandonEmptyTrash(
153
- "Refused to empty trash: this account's Trash is no longer that folder",
154
- "empty_trash_role_moved",
155
- {
156
- resolvedKind: resolution.kind,
157
- resolvedMailboxId: trashGate.allowed
158
- ? trashGate.mailbox.mailboxId
159
- : undefined,
160
- },
161
- );
162
- return;
163
- }
164
-
165
- await withOAuthLifecycle(
166
- buildLifecycleDeps(secrets, accountService),
167
- account,
168
- log,
169
- async (credentials) => {
170
- // The Trash folder can be deleted between enqueue and this sync, leaving a
171
- // queued event pointing at a gone row. The lookup then throws
172
- // NotFoundError forever, and on the account's per-group FIFO that head
173
- // message stalls the whole pipeline (issues #287, #289, #290). A deleted
174
- // Trash makes the empty moot: ack with a WARN.
175
- const trash = await findMailboxRow(
176
- mailboxService,
177
- accountId,
178
- trashMailboxId,
179
- );
180
- if (trash.kind === "gone") {
181
- log.warn(
182
- { accountId, mailboxId: trashMailboxId },
183
- "Skipping EMPTY_TRASH: mailbox no longer exists (deleted)",
184
- );
185
- return;
186
- }
187
- const mailbox = trash.mailbox;
188
-
189
- // A paused cursor is not a wait here: this return acks the event and
190
- // nothing re-issues it, so the marks have to come back or the folder
191
- // stays hidden forever. The user retries.
192
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
193
- await abandonEmptyTrash(
194
- "Abandoned empty trash: mailbox cursor is not normal",
195
- "empty_trash_cursor_paused",
196
- { cursorState: mailbox.cursorState },
197
- );
198
- return;
199
- }
200
-
201
- const scope = createConnectionScope(account, credentials);
202
-
203
- await scope
204
- .getConnection()
205
- .then(async (rawConnection) => {
206
- // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
207
- // a fresh mismatch trips the mailbox and throws once the SELECT
208
- // reveals it.
209
- const connection = guardConnectionCursor(
210
- rawConnection,
211
- { mailboxService },
212
- accountId,
213
- mailbox,
214
- );
215
- const boxStatus = await connection.openBox(mailbox.fullPath, false);
216
-
217
- // The path is not the folder. A third-party client that renames
218
- // Trash and creates a fresh one leaves this event pointing at a
219
- // path now served by a folder the user never consented to empty;
220
- // UIDVALIDITY is what tells them apart (RFC 9051 2.3.1.1).
221
- if (boxStatus.uidvalidity !== event.trashUidValidity) {
222
- await abandonEmptyTrash(
223
- "Refused to empty trash: the folder at this path is not the one the user emptied",
224
- "empty_trash_uidvalidity_mismatch",
225
- {
226
- servedUidValidity: boxStatus.uidvalidity,
227
- consentedUidValidity: event.trashUidValidity,
228
- },
229
- );
230
- return;
231
- }
232
-
233
- // One read, before the expunge, decides both what is destroyed on
234
- // the server and what is removed locally — so the two sets are the
235
- // same set by construction and no message can be expunged that the
236
- // sweep then declines to remove (issue #1230). Reading afterwards
237
- // put the exclusion downstream of the destruction: a refused row
238
- // had already lost its server copy, could not be refused into
239
- // existence again, and nothing routine repaired it.
240
- //
241
- // Reconciles, never waits (imap-mutations R2). A row whose move
242
- // into Trash has not settled names this folder while still
243
- // carrying the SOURCE folder's uid, so matching it against the
244
- // expunge answers for whatever Trash held at that uid — another
245
- // message, deleted here in both its rows. Waiting is not open to
246
- // this handler the way it is to the API-side mutators: a move on
247
- // this account's own FIFO group cannot run until this returns, so
248
- // the ceiling would be spent to reach the same answer. It buys
249
- // nothing against the other lane either — PLACEMENT_MOVE_PUSH
250
- // rides a standard queue with no group at all
251
- // (`deploy/vps/queues.json`), so it is ordered against nothing
252
- // here and can settle a row mid-sweep. That is why the writes
253
- // below are transitions rather than snapshot writes (R3).
254
- //
255
- // `carriesForeignUid`, not the placement binding: the binding reads
256
- // `status`, and `status` is exactly what an operation marking this
257
- // folder overwrites. This is the same predicate `emptyTrash`
258
- // applies before it marks anything — one gate, one answer, now
259
- // asked once and honoured on both sides of the expunge.
260
- const localMessages =
261
- await messageService.listAllByMailbox(trashMailboxId);
262
- const sweepable = localMessages.filter(
263
- (message) => !carriesForeignUid(message),
264
- );
265
-
266
- // A refused row spares its uid only where no row the sweep will
267
- // carry claims the same number. The two cases the predicate cannot
268
- // tell apart part here: a row genuinely mid-move borrows a uid that
269
- // belongs to a different message in this folder, and sparing that
270
- // number would strand the message that owns it; a settled row whose
271
- // `originalUid` a pre-#1217 build left behind owns its uid outright,
272
- // and expunging it destroys mail the sweep then refuses to remove.
273
- const sweepableUids = new Set(
274
- sweepable.map((message) => message.uid),
275
- );
276
- const refusedUids = new Set(
277
- localMessages
278
- .filter(
279
- (message) =>
280
- carriesForeignUid(message) && !sweepableUids.has(message.uid),
281
- )
282
- .map((message) => message.uid),
283
- );
284
-
285
- const uids = (await connection.search(["ALL"])).filter(
286
- (uid) => !refusedUids.has(uid),
287
- );
288
-
289
- if (uids.length > 0) {
290
- await connection.deleteMessages(uids);
291
- log.info(
292
- { count: uids.length, refused: refusedUids.size },
293
- "Deleted messages from IMAP trash",
294
- );
295
- }
296
-
297
- // What was expunged is a fact this connection observed, and only
298
- // those rows go.
299
- const expunged = new Set(uids);
300
- const swept = sweepable.filter((message) =>
301
- expunged.has(message.uid),
302
- );
303
- const sweptIds = new Set(swept.map((message) => message.messageId));
304
- const deletedCount = swept.length;
305
-
306
- for (const message of swept) {
307
- await messageService.delete(message.messageId);
308
-
309
- const threadMessage = await threadMessageService.findByMessageId(
310
- account.accountConfigId,
311
- message.messageId,
312
- );
313
- if (threadMessage) {
314
- await threadMessageService.delete(
315
- threadMessage.accountConfigId,
316
- threadMessage.threadMessageId,
317
- );
318
- }
319
- }
320
-
321
- // Everything this sweep did not carry through is still marked for a
322
- // deletion that will never come: mail another client already
323
- // emptied, mail that reached Trash after the SEARCH, a redelivery
324
- // finishing a partial sweep, and a row whose uid the sweep would
325
- // not bind. All of them must come back rather than sit invisible —
326
- // the last one especially, since nothing else is coming to clear
327
- // its mark, and a row hidden in a folder it never left is worse
328
- // than one the next sync re-projects.
329
- const revertedCount = await handBackMarkedRows(
330
- localMessages.filter((message) => !sweptIds.has(message.messageId)),
331
- );
332
-
333
- log.info(
334
- { accountId, deletedCount, revertedCount },
335
- "Trash emptied successfully",
336
- );
337
- })
338
- .catch(async (error: unknown) => {
339
- if (error instanceof MailboxCursorPausedError) {
340
- await abandonEmptyTrash(
341
- "Abandoned empty trash: mailbox cursor is not normal",
342
- "empty_trash_cursor_paused",
343
- { cursorState: error.state },
344
- );
345
- return;
346
- }
347
- throw error;
348
- })
349
- .finally(() => scope.disconnect());
350
- },
351
- );
352
- };
@@ -1,285 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import { isNotFoundError } from "@remit/data-ports/errors";
3
- import type { Logger } from "@remit/logger";
4
- import { recordImapFailure } from "@remit/logger-lambda/metrics";
5
- import {
6
- guardConnectionCursor,
7
- isCursorRebuildNeeded,
8
- isPlacementUnsettled,
9
- MailboxCursorPausedError,
10
- resolveExhaustedFlagPushFailure,
11
- } from "@remit/mailbox-service";
12
- import { attemptBudget } from "@remit/sqs-client/attempt-budget";
13
- import { isAccountDeleted } from "../account-check.js";
14
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
- import type { FlagPushEvent } from "../events.js";
16
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
-
19
- export const getFlagPushMaxAttempts = (
20
- processEnv: NodeJS.ProcessEnv = process.env,
21
- ): number => attemptBudget("FLAG_PUSH_MAX_ATTEMPTS", 3, processEnv);
22
-
23
- export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
24
-
25
- /**
26
- * How long a marker may sit deferred behind a move before it is dropped
27
- * outright. A move that settles takes seconds to low minutes; one stuck past
28
- * this window has almost certainly already exhausted its own retries and been
29
- * resolved as broken by its own terminal resolver, so deferring further would
30
- * cycle one SQS round trip per sync tick forever instead of surfacing the
31
- * stall.
32
- */
33
- export const FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
34
-
35
- /**
36
- * Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
37
- * flag-push marker: resolves the message's UID and CURRENT mailbox fresh
38
- * from the Message row (never a value captured at enqueue — invariant 1),
39
- * pushes the IMAP STORE (add or remove, per the marker's `operation`), and
40
- * clears the marker ONLY on confirmed success.
41
- *
42
- * Precedence (epic invariant 2):
43
- * - While pending, resync never reverts the flag — nothing in this handler
44
- * (or anywhere else) reads flags FROM IMAP back into `MessageFlag`/
45
- * `ThreadMessage` for an existing row; the only flag-state writes happen
46
- * here (confirmed push) or in `FlagQueueService` (the user's own local
47
- * flip, which already applied before this marker existed).
48
- * - A later flip of the SAME field already replaced this marker (`put`) by
49
- * the time this event is processed, OR advanced it past `pending` — either
50
- * way `markerService.find` returns the CURRENT marker, so this handler
51
- * always drives the freshest intent, never a stale one.
52
- * - An external delete supersedes the marker entirely — handled by
53
- * `resolveExhaustedFlagPushFailure`'s `reconciled` outcome.
54
- *
55
- * Cursor-guarded (#1272, epic #1281 invariant 5): the connection is wrapped
56
- * via `guardConnectionCursor` around the mailbox's current `openBox` choke
57
- * point, so no stored UID touches the server while the mailbox's axis is
58
- * being rebuilt. A trip pauses the push (routine, no alarm) — the marker
59
- * stays durable and pushes again on the next event or sync tick.
60
- */
61
- export const handleFlagPush = async (
62
- event: FlagPushEvent,
63
- log: Logger,
64
- receiveCount = 1,
65
- createConnectionScope = createConnectionScopeWithCredentials,
66
- ): Promise<void> => {
67
- const {
68
- account: accountService,
69
- mailbox: mailboxService,
70
- message: messageService,
71
- threadMessage: threadMessageService,
72
- flagPush: markerService,
73
- secrets,
74
- } = await getClient();
75
-
76
- const { accountId, accountConfigId, messageId, flagName } = event;
77
-
78
- const marker = await markerService.find(messageId, flagName);
79
- if (!marker) {
80
- log.info(
81
- { messageId, flagName, accountId },
82
- "No pending flag-push marker (already confirmed or superseded); nothing to push",
83
- );
84
- return;
85
- }
86
-
87
- const account = await accountService.get(accountId);
88
- if (!account) {
89
- throw new Error(`Account ${accountId} not found`);
90
- }
91
- if (isAccountDeleted(account, log)) {
92
- return;
93
- }
94
-
95
- const [message] = await messageService.get([messageId]);
96
-
97
- // The message row is already gone — some other reconciliation path (body
98
- // sync, placement move, a prior flag-push exhaustion) already deleted it.
99
- // The marker is orphaned; drop it without touching IMAP.
100
- if (!message) {
101
- await markerService.delete(messageId, flagName);
102
- log.info(
103
- { messageId, flagName, accountId },
104
- "Message row no longer exists; flag-push marker dropped without pushing",
105
- );
106
- return;
107
- }
108
-
109
- // The message carries a move still in flight — `MessageMoveService`'s or
110
- // `PlacementMoveService`'s local optimistic write, not yet confirmed by the
111
- // server. A STORE resolved against this row addresses the destination
112
- // folder at the SOURCE folder's uid: a different message (issue #496). This
113
- // is the wait half of the wait-or-reconcile decision
114
- // (docs/architecture/imap-mutations.md R2) — the push blocks until the move
115
- // it depends on settles.
116
- if (isPlacementUnsettled(message)) {
117
- const deferredForMs = Date.now() - marker.createdAt;
118
-
119
- if (deferredForMs > FLAG_PUSH_DEFER_MAX_MS) {
120
- // The move this push was waiting on never settled within any
121
- // reasonable window — deferring further would cycle one SQS round
122
- // trip per sync tick forever. Drop the stale marker loudly rather
123
- // than push against state nobody has confirmed.
124
- await markerService.delete(messageId, flagName);
125
- recordImapFailure("FLAG_PUSH_MOVE_NEVER_SETTLED", "other");
126
- log.error(
127
- {
128
- alert: "flag_push_move_never_settled",
129
- messageId,
130
- flagName,
131
- accountId,
132
- deferredForMs,
133
- },
134
- "Message move never settled; dropping the flag-push marker that was waiting on it",
135
- );
136
- return;
137
- }
138
-
139
- // Reset to `pending` rather than advancing: `drainPendingFlagPushes`
140
- // only re-arms markers in that state, scoped by the marker's own
141
- // `mailboxId` (the push destination), so the next periodic sync tick
142
- // of that mailbox picks this back up once the move has settled.
143
- await markerService.updateState(messageId, flagName, "pending");
144
- log.info(
145
- { messageId, flagName, accountId, deferredForMs },
146
- "Message has a move in flight; pausing outbound flag push until it settles",
147
- );
148
- return;
149
- }
150
-
151
- // The worker has picked up the event and is about to actually attempt the
152
- // IMAP STORE — advance the state engine (pending/queued -> processing).
153
- // Idempotent to call again on a redelivered event (a prior attempt that
154
- // died mid-flight already left it here).
155
- await markerService.updateState(messageId, flagName, "processing");
156
-
157
- // The folder can be deleted between enqueue and this push, leaving a marker
158
- // pointing at a gone row. The lookup then throws NotFoundError forever, and
159
- // on the account's per-group FIFO that head message stalls the whole pipeline
160
- // (issues #287, #289, #290). The push is moot — drop the orphaned marker (as
161
- // the message-gone branch above already does) and ack with a WARN.
162
- const mailbox = await mailboxService
163
- .get(accountId, message.mailboxId)
164
- .catch((error: unknown) => {
165
- if (isNotFoundError(error)) return null;
166
- throw error;
167
- });
168
- if (!mailbox) {
169
- await markerService.delete(messageId, flagName);
170
- log.warn(
171
- { messageId, flagName, accountId, mailboxId: message.mailboxId },
172
- "Skipping FLAG_PUSH: mailbox no longer exists (deleted); marker dropped",
173
- );
174
- return;
175
- }
176
-
177
- // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
178
- // paused never even borrows a connection. Optimization only — the
179
- // guardConnectionCursor wrap below is the structural guarantee (#1272).
180
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
181
- log.info(
182
- { messageId, flagName, accountId, cursorState: mailbox.cursorState },
183
- "Mailbox cursor not normal; pausing outbound flag push this round",
184
- );
185
- return;
186
- }
187
-
188
- await withOAuthLifecycle(
189
- buildLifecycleDeps(secrets, accountService),
190
- account,
191
- log,
192
- async (credentials) => {
193
- const scope = createConnectionScope(account, credentials);
194
-
195
- await scope
196
- .getConnection()
197
- .then(async (rawConnection) => {
198
- const connection = guardConnectionCursor(
199
- rawConnection,
200
- { mailboxService },
201
- accountId,
202
- mailbox,
203
- );
204
-
205
- await connection.openBox(mailbox.fullPath, false);
206
-
207
- if (marker.operation === "add") {
208
- await connection.addFlags([message.uid], [flagName]);
209
- } else {
210
- await connection.removeFlags([message.uid], [flagName]);
211
- }
212
-
213
- // Confirmed IMAP acknowledgement — clears ONLY here, never on
214
- // attempt (the defect issue #1273 fixes).
215
- await markerService.delete(messageId, flagName);
216
-
217
- log.info(
218
- {
219
- messageId,
220
- flagName,
221
- accountId,
222
- operation: marker.operation,
223
- uid: message.uid,
224
- mailboxPath: mailbox.fullPath,
225
- },
226
- "Flag push confirmed on IMAP; marker cleared",
227
- );
228
- })
229
- .catch(async (error: unknown) => {
230
- // Expected pause (epic #1281 invariant 3), not a fault: ack and
231
- // skip rather than propagating into queue retry/DLQ. The marker
232
- // stays durable; the push resumes once the mailbox returns to
233
- // normal.
234
- if (error instanceof MailboxCursorPausedError) {
235
- log.info(
236
- { messageId, flagName, accountId, cursorState: error.state },
237
- "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound flag push",
238
- );
239
- return;
240
- }
241
-
242
- if (receiveCount < FLAG_PUSH_MAX_ATTEMPTS) {
243
- // Transient push failure — expected (connections drop). No
244
- // alarm; queue redelivery retries from the still-durable marker.
245
- throw error;
246
- }
247
-
248
- // Redelivery budget exhausted: resolve into exactly one of the
249
- // two terminal outcomes (epic invariant 3) instead of
250
- // dead-lettering with no diagnosis.
251
- const { outcome } = await resolveExhaustedFlagPushFailure(
252
- { markerService, messageService, log },
253
- {
254
- placement: {
255
- status: message.status,
256
- mailboxId: message.mailboxId,
257
- uid: message.uid,
258
- },
259
- accountId,
260
- accountConfigId,
261
- messageId,
262
- flagName,
263
- uid: message.uid,
264
- mailboxPath: mailbox.fullPath,
265
- getConnection: scope.getConnection,
266
- },
267
- );
268
-
269
- if (outcome === "reconciled") {
270
- return;
271
- }
272
-
273
- // Terminal and never re-thrown, so the handler-outcome series
274
- // records this record as a success. Counted here or it is invisible.
275
- recordImapFailure("FLAG_PUSH_EXHAUSTED", "other");
276
- log.error(
277
- { error: error instanceof Error ? error.message : String(error) },
278
- "Flag push retry exhausted; message still exists at its mailbox",
279
- );
280
- // Terminal — never re-thrown, so the caller acks either way.
281
- })
282
- .finally(() => scope.disconnect());
283
- },
284
- );
285
- };
@@ -1,63 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, it } from "node:test";
3
- import type { MailboxItem } from "@remit/data-ports";
4
- import type { CreateMailboxQueueInput } from "@remit/mailbox-service";
5
- import { createImportedFolder } from "./imported-folder.js";
6
-
7
- const inbox = {
8
- mailboxId: "mbx-inbox",
9
- accountId: "acc-1",
10
- fullPath: "INBOX",
11
- namespaceType: "personal",
12
- namespacePrefix: "INBOX.",
13
- hierarchyDelimiter: ".",
14
- } as MailboxItem;
15
-
16
- describe("createImportedFolder (#1103)", () => {
17
- it("creates the folder through the pending folder-create path, in the INBOX namespace", async () => {
18
- const calls: [CreateMailboxQueueInput, string, boolean | undefined][] = [];
19
- const create = createImportedFolder(
20
- { findByPath: async () => inbox },
21
- {
22
- createMailbox: async (input, accountId, subscribe) => {
23
- calls.push([input, accountId, subscribe]);
24
- return {
25
- outcome: "Created" as const,
26
- mailbox: {
27
- ...inbox,
28
- mailboxId: "mbx-new",
29
- fullPath: input.fullPath,
30
- },
31
- };
32
- },
33
- },
34
- );
35
-
36
- const created = await create("acc-1", "INBOX.Facturen");
37
-
38
- assert.equal(
39
- created.outcome === "Created" && created.mailbox.mailboxId,
40
- "mbx-new",
41
- );
42
- assert.equal(calls.length, 1);
43
- const [input, accountId, subscribe] = calls[0];
44
- assert.equal(accountId, "acc-1");
45
- assert.equal(subscribe, true);
46
- assert.equal(input.fullPath, "INBOX.Facturen");
47
- assert.equal(input.namespacePrefix, "INBOX.");
48
- assert.equal(input.hierarchyDelimiter, ".");
49
- });
50
-
51
- it("refuses to guess a namespace for an account with no INBOX", async () => {
52
- const create = createImportedFolder(
53
- { findByPath: async () => null },
54
- {
55
- createMailbox: async () => {
56
- throw new Error("must not be called");
57
- },
58
- },
59
- );
60
-
61
- await assert.rejects(create("acc-1", "Facturen"), /no INBOX/);
62
- });
63
- });