@remit/imap-worker 0.0.89 → 0.0.91

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