@remit/imap-worker 0.0.90 → 0.0.92

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 (176) 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 +22 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +29 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
  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/dist/scheduler/handler.js +50 -0
  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 +66 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/dist/scheduler/runner.js +102 -0
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/scheduler/tick.d.ts +5 -0
  92. package/dist/scheduler/tick.js +5 -0
  93. package/dist/sqs-queues.d.ts +4 -0
  94. package/dist/sqs-queues.js +22 -0
  95. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  96. package/dist/with-oauth-lifecycle-deps.js +17 -0
  97. package/dist/with-oauth-lifecycle.d.ts +56 -0
  98. package/dist/with-oauth-lifecycle.js +92 -0
  99. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  100. package/dist/worker-version.js +12 -0
  101. package/package.json +79 -69
  102. package/src/account-check.test.ts +0 -149
  103. package/src/account-check.ts +0 -101
  104. package/src/body-sync-gate.test.ts +0 -185
  105. package/src/body-sync-gate.ts +0 -86
  106. package/src/cli.ts +0 -218
  107. package/src/connection-scope.test.ts +0 -266
  108. package/src/connection-scope.ts +0 -314
  109. package/src/credential-less-sync-fence.test.ts +0 -152
  110. package/src/e2e-processor-shim.ts +0 -232
  111. package/src/emit.test.ts +0 -145
  112. package/src/emit.ts +0 -115
  113. package/src/events.ts +0 -252
  114. package/src/failure-kind.test.ts +0 -42
  115. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  116. package/src/handlers/append-sent-message.ts +0 -264
  117. package/src/handlers/delete-account-objects.test.ts +0 -126
  118. package/src/handlers/delete-account-objects.ts +0 -96
  119. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  120. package/src/handlers/empty-trash.ts +0 -352
  121. package/src/handlers/flag-push.ts +0 -285
  122. package/src/handlers/imported-folder.test.ts +0 -63
  123. package/src/handlers/imported-folder.ts +0 -38
  124. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  125. package/src/handlers/mailbox-management.ts +0 -486
  126. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  127. package/src/handlers/mailbox-sync-order.ts +0 -65
  128. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  129. package/src/handlers/message-copy.ts +0 -569
  130. package/src/handlers/message-delete-terminal.ts +0 -147
  131. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  132. package/src/handlers/message-delete.ts +0 -888
  133. package/src/handlers/message-move.sqlite.test.ts +0 -271
  134. package/src/handlers/message-move.test.ts +0 -216
  135. package/src/handlers/message-move.ts +0 -755
  136. package/src/handlers/placement-move-push.test.ts +0 -137
  137. package/src/handlers/placement-move-push.ts +0 -478
  138. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  139. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  140. package/src/handlers/probe-message-id.ts +0 -15
  141. package/src/handlers/sync-mailboxes.test.ts +0 -183
  142. package/src/handlers/sync-mailboxes.ts +0 -425
  143. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  144. package/src/handlers/sync-message-body.test.ts +0 -150
  145. package/src/handlers/sync-message-body.ts +0 -366
  146. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  147. package/src/handlers/sync-messages.ts +0 -537
  148. package/src/index.test.ts +0 -22
  149. package/src/index.ts +0 -82
  150. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  151. package/src/mailbox-row.ts +0 -20
  152. package/src/pending-sync.sqlite.test.ts +0 -165
  153. package/src/poller.ts +0 -55
  154. package/src/processor.ts +0 -62
  155. package/src/queues.ts +0 -40
  156. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  157. package/src/scheduler/attachment-sweep.ts +0 -46
  158. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  159. package/src/scheduler/calendar-subscriptions.ts +0 -103
  160. package/src/scheduler/config.test.ts +0 -40
  161. package/src/scheduler/decide-due.test.ts +0 -44
  162. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  163. package/src/scheduler/handler.ts +0 -69
  164. package/src/scheduler/loop.test.ts +0 -143
  165. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  166. package/src/scheduler/run-tick.ts +0 -281
  167. package/src/scheduler/runner.ts +0 -125
  168. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  169. package/src/stranded-placement.sqlite.test.ts +0 -799
  170. package/src/test-helpers/test-world.ts +0 -425
  171. package/src/test-helpers/worker-queues.ts +0 -11
  172. package/src/thread-join.sqlite.test.ts +0 -106
  173. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  174. package/src/with-oauth-lifecycle-deps.ts +0 -71
  175. package/src/with-oauth-lifecycle.ts +0 -158
  176. package/tsconfig.json +0 -8
@@ -1,137 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import {
4
- eml,
5
- FakeImapServer,
6
- } from "@remit/mailbox-service/test-helpers/fake-imap";
7
- import { attemptMove } from "./placement-move-push.js";
8
-
9
- const HEADER = "<abc@example.com>";
10
-
11
- /** Junk holds the message at uid 42; INBOX is where the placement sends it. */
12
- const arrange = (
13
- options: { inJunk?: boolean; inInbox?: boolean; inboxExists?: boolean } = {},
14
- ) => {
15
- const server = new FakeImapServer();
16
- server.addFolder("Junk");
17
- if (options.inboxExists !== false) server.addFolder("INBOX");
18
- if (options.inJunk !== false)
19
- server.deliver("Junk", eml({ "Message-ID": HEADER }), { uid: 42 });
20
- if (options.inInbox) server.deliver("INBOX", eml({ "Message-ID": HEADER }));
21
- return server;
22
- };
23
-
24
- const move = (
25
- server: FakeImapServer,
26
- header: string | undefined = HEADER,
27
- source = server.connection(),
28
- ) => attemptMove(source, server.connection(), "Junk", "INBOX", 42, header);
29
-
30
- describe("attemptMove — the IMAP push (issue #1271)", () => {
31
- it("moves the message and returns the uid from the COPYUID map", async () => {
32
- const server = arrange();
33
-
34
- const outcome = await move(server);
35
-
36
- assert.deepEqual(outcome, {
37
- kind: "moved",
38
- newUid: server.uids("INBOX", HEADER)[0],
39
- });
40
- assert.deepEqual(server.uids("Junk"), []);
41
- });
42
-
43
- it("answers trycreate when the destination does not exist yet", async () => {
44
- const server = arrange({ inboxExists: false });
45
-
46
- assert.equal((await move(server)).kind, "trycreate");
47
- });
48
-
49
- it("propagates any other failure untouched for the caller to retry", async () => {
50
- const server = arrange();
51
- server.failNext.MOVE = new Error("ECONNRESET");
52
-
53
- await assert.rejects(move(server), /ECONNRESET/);
54
- });
55
-
56
- // PR #1289 review finding 2: neither a missing COPYUID entry nor an explicit
57
- // not-found is trusted without asking the server.
58
- it("confirms a move by Message-ID when the server sends no COPYUID", async () => {
59
- const server = arrange();
60
- server.uidplus = false;
61
-
62
- const outcome = await move(server);
63
-
64
- assert.deepEqual(outcome, {
65
- kind: "moved",
66
- newUid: server.uids("INBOX", HEADER)[0],
67
- });
68
- });
69
-
70
- it("confirms a move by Message-ID when the server answers NONEXISTENT for one that landed", async () => {
71
- const server = arrange({ inJunk: false, inInbox: true });
72
- server.failNext.MOVE = new Error(
73
- "Command failed: NONEXISTENT no such message",
74
- );
75
-
76
- const outcome = await move(server);
77
-
78
- assert.deepEqual(outcome, {
79
- kind: "moved",
80
- newUid: server.uids("INBOX", HEADER)[0],
81
- });
82
- });
83
-
84
- // Issue #1122: the destination already held an older copy, and the row must
85
- // bind to the one this MOVE delivered.
86
- it("binds to the newest copy when the destination already held an older one", async () => {
87
- const server = arrange({ inInbox: true });
88
- server.uidplus = false;
89
-
90
- const outcome = await move(server);
91
-
92
- assert.deepEqual(outcome, {
93
- kind: "moved",
94
- newUid: Math.max(...server.uids("INBOX", HEADER)),
95
- });
96
- });
97
-
98
- it("never settles a move the destination does not confirm while the source still holds it", async () => {
99
- const server = arrange();
100
- server.uidplus = false;
101
- server.ignoreNext.add("MOVE");
102
-
103
- await assert.rejects(move(server), /unresolved/);
104
- });
105
-
106
- it("never settles when the source FETCH drops the row for a message the server still lists", async () => {
107
- const server = arrange();
108
- server.uidplus = false;
109
- server.ignoreNext.add("MOVE");
110
- const source = server.connection();
111
- source.fetchMessages = async () => [];
112
-
113
- await assert.rejects(move(server, HEADER, source), /unresolved/);
114
- });
115
-
116
- it("answers not-found once neither folder holds the message", async () => {
117
- const server = arrange({ inJunk: false });
118
- server.uidplus = false;
119
-
120
- assert.equal((await move(server)).kind, "not-found");
121
- });
122
-
123
- it("falls back to the source alone without a Message-ID, and still never settles while it holds the message", async () => {
124
- const server = arrange();
125
- server.uidplus = false;
126
- server.ignoreNext.add("MOVE");
127
-
128
- await assert.rejects(move(server, undefined));
129
- });
130
-
131
- it("answers not-found without a Message-ID once the source no longer holds the message", async () => {
132
- const server = arrange({ inJunk: false });
133
- server.uidplus = false;
134
-
135
- assert.equal((await move(server, undefined)).kind, "not-found");
136
- });
137
- });
@@ -1,478 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
3
- import type { Logger } from "@remit/logger";
4
- import { recordImapFailure } from "@remit/logger-lambda/metrics";
5
- import {
6
- guardConnectionCursor,
7
- type IImapConnection,
8
- isCursorRebuildNeeded,
9
- isMessageGoneFromOpenMailbox,
10
- MailboxCursorPausedError,
11
- resolveExhaustedPlacementMoveFailure,
12
- restoreSourcePlacement,
13
- } from "@remit/mailbox-service";
14
- import { attemptBudget } from "@remit/sqs-client/attempt-budget";
15
- import { isAccountDeleted } from "../account-check.js";
16
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
17
- import { createEmitEvent } from "../emit.js";
18
- import type { PlacementMovePushEvent } from "../events.js";
19
- import { findMailboxRow } from "../mailbox-row.js";
20
- import type { WorkerQueues } from "../queues.js";
21
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
22
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
23
- import {
24
- buildThreadMessageMoveUpdate,
25
- emitMailboxResync,
26
- emitMoveResync,
27
- searchMailboxForHighestMessageIdUid,
28
- } from "./message-move.js";
29
- import { probeableMessageIdHeader } from "./probe-message-id.js";
30
-
31
- export const getPlacementMoveMaxAttempts = (
32
- processEnv: NodeJS.ProcessEnv = process.env,
33
- ): number => attemptBudget("PLACEMENT_MOVE_MAX_ATTEMPTS", 3, processEnv);
34
-
35
- export const PLACEMENT_MOVE_MAX_ATTEMPTS = getPlacementMoveMaxAttempts();
36
-
37
- interface MoveOutcome {
38
- kind: "moved" | "not-found" | "trycreate";
39
- newUid?: number;
40
- }
41
-
42
- /**
43
- * Attempt the IMAP MOVE.
44
- *
45
- * Takes two connection handles rather than one: `sourceConnection` and
46
- * `destinationConnection` wrap the SAME underlying connection but are each
47
- * cursor-guarded (#1272) against their OWN mailbox — a `guardConnectionCursor`
48
- * wrap binds `openBox` checks to whichever ONE mailbox snapshot it was built
49
- * with, so verifying at the destination must never run through the source's
50
- * guard (it would compare the destination's served UIDVALIDITY against the
51
- * source's stored one and misfire).
52
- *
53
- * - `moved` with a `newUid` — confirmed, either via COPYUID or (see below)
54
- * an explicit destination verification.
55
- * - `trycreate` — the destination mailbox doesn't exist yet; the caller
56
- * creates it and retries.
57
- * - `not-found` — confirmed absent from BOTH source and destination. Only
58
- * this outcome supersedes the pending-move marker (drops it, reconciles
59
- * the row as an external delete).
60
- * - Any other case throws (propagates for SQS retry, or as
61
- * `MailboxCursorPausedError` for the caller's cursor-pause handling) —
62
- * including an UNCONFIRMED move (no COPYUID, or an explicit "not
63
- * found"/NONEXISTENT from the server) whose message is still sitting at
64
- * the source. A MOVE that resolves without a uidMap entry on a
65
- * non-UIDPLUS server is a plausible SUCCESS, not evidence the message is
66
- * gone (PR #1289 review finding 2) — deleting the local row on that
67
- * ambiguity would be data loss. `not-found` is only returned once BOTH a
68
- * destination Message-ID search (when the header is known) misses AND
69
- * {@link isMessageGoneFromOpenMailbox} confirms the message absent from the
70
- * source — a FETCH returning no row does not, on its own, confirm anything.
71
- */
72
- export const attemptMove = async (
73
- sourceConnection: IImapConnection,
74
- destinationConnection: IImapConnection,
75
- sourceMailboxPath: string,
76
- destinationMailboxPath: string,
77
- uid: number,
78
- messageIdHeader: string | undefined,
79
- ): Promise<MoveOutcome> => {
80
- await sourceConnection.openBox(sourceMailboxPath, false);
81
-
82
- const resolved = await sourceConnection
83
- .moveMessages([uid], destinationMailboxPath)
84
- .then((result) => ({ ok: true as const, newUid: result.uidMap.get(uid) }))
85
- .catch((error: unknown) => {
86
- const message = error instanceof Error ? error.message : String(error);
87
- if (message.includes("TRYCREATE")) {
88
- return { ok: false as const, trycreate: true as const };
89
- }
90
- if (message.includes("not found") || message.includes("NONEXISTENT")) {
91
- return { ok: false as const, trycreate: false as const };
92
- }
93
- throw error;
94
- });
95
-
96
- if (resolved.ok && resolved.newUid) {
97
- return { kind: "moved", newUid: resolved.newUid };
98
- }
99
- if (!resolved.ok && resolved.trycreate) {
100
- return { kind: "trycreate" };
101
- }
102
-
103
- // Unconfirmed: either no COPYUID entry, or the server explicitly claimed
104
- // "not found" — never trust either without independent verification. A
105
- // message this MOVE delivered is the newest copy of that Message-ID at the
106
- // destination, so the probe binds to the highest matching uid (#1122).
107
- // Unlike `confirmTrashMoveUid`, this probe is ungated: where the MOVE never
108
- // ran and an older copy filed there earlier is the only match, the marker
109
- // binds to that unrelated mail. Pre-existing, and not narrowed by taking the
110
- // highest — the source is not asked first here, so nothing rules the case
111
- // out.
112
- if (messageIdHeader) {
113
- const destinationUid = await searchMailboxForHighestMessageIdUid(
114
- destinationConnection,
115
- destinationMailboxPath,
116
- messageIdHeader,
117
- );
118
- if (destinationUid) return { kind: "moved", newUid: destinationUid };
119
- }
120
-
121
- await sourceConnection.openBox(sourceMailboxPath, true);
122
- if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
123
- throw new Error(
124
- "Placement move unresolved (unconfirmed at destination, still present at source) — retrying",
125
- );
126
- }
127
-
128
- return { kind: "not-found" };
129
- };
130
-
131
- /**
132
- * Handle PLACEMENT_MOVE_PUSH events (issue #1271, epic #1281). Drains ONE
133
- * pending placement-move marker: resolves the UID fresh from the Message row
134
- * (never trusts a captured value — invariant 1), pushes the IMAP MOVE, and
135
- * clears the marker ONLY on confirmed success. Every precedence rule (epic
136
- * invariant 2) is enforced here:
137
- * - Pending Remit move wins on location: nothing in this handler ever
138
- * corrects `message.mailboxId` back toward the source — a resync elsewhere
139
- * (message-sync.ts) never overwrites an existing row's `mailboxId` either
140
- * (create-only-if-not-exists semantics), so the local move is never undone.
141
- * - A NEWER local action superseding this marker (drop without pushing) —
142
- * see the `message.mailboxId !== marker.destinationMailboxId` check below.
143
- * - An external delete supersedes the marker entirely — the `not-found`
144
- * outcome drops the marker and reconciles the stale row.
145
- *
146
- * Cursor-guarded (#1272, epic #1281 invariant 5): both the source and
147
- * destination mailbox are checked for a non-`normal` `cursorState` before
148
- * connecting, and the connection is wrapped per-mailbox via
149
- * `guardConnectionCursor` so no stored UID touches the server while either
150
- * mailbox's axis is being rebuilt. A trip pauses the push (routine, no
151
- * alarm) — the marker stays durable and pushes again on the next event.
152
- */
153
- export const handlePlacementMovePush = async (
154
- event: PlacementMovePushEvent,
155
- log: Logger,
156
- queues: WorkerQueues,
157
- receiveCount = 1,
158
- createConnectionScope = createConnectionScopeWithCredentials,
159
- ): Promise<void> => {
160
- const emit = createEmitEvent(queues);
161
- const {
162
- account: accountService,
163
- mailbox: mailboxService,
164
- message: messageService,
165
- threadMessage: threadMessageService,
166
- placementMove: markerService,
167
- secrets,
168
- } = await getClient();
169
-
170
- const { accountId, accountConfigId, messageId } = event;
171
-
172
- const marker = await markerService.find(messageId);
173
- if (!marker) {
174
- log.info(
175
- { messageId, accountId },
176
- "No pending placement-move marker (already confirmed or superseded); nothing to push",
177
- );
178
- return;
179
- }
180
-
181
- const account = await accountService.get(accountId);
182
- if (!account) {
183
- throw new Error(`Account ${accountId} not found`);
184
- }
185
- if (isAccountDeleted(account, log)) {
186
- return;
187
- }
188
-
189
- const message = await messageService.get(messageId);
190
- const placementRead = {
191
- status: message.status,
192
- mailboxId: message.mailboxId,
193
- uid: message.uid,
194
- };
195
-
196
- // A newer local action (drag-and-drop move, auto-moved Undo) already
197
- // changed the message's location away from what this marker still
198
- // promises — that later intent wins locally. Drop the now-stale marker
199
- // without touching IMAP.
200
- if (message.mailboxId !== marker.destinationMailboxId) {
201
- await markerService.delete(messageId);
202
- log.info(
203
- {
204
- messageId,
205
- accountId,
206
- markerDestination: marker.destinationMailboxId,
207
- currentMailboxId: message.mailboxId,
208
- },
209
- "Placement move superseded by a newer local action; marker dropped without pushing",
210
- );
211
- return;
212
- }
213
-
214
- // The worker has picked up the event and is about to actually attempt the
215
- // IMAP MOVE — advance the state engine (pending/queued -> processing).
216
- // Idempotent to call again on a redelivered event (a prior attempt that
217
- // died mid-flight already left it here).
218
- await markerService.updateState(messageId, "processing");
219
-
220
- // No MOVE has left for this marker, so the row is settled, not just unmarked.
221
- const [source, destination] = await Promise.all([
222
- findMailboxRow(mailboxService, accountId, marker.sourceMailboxId),
223
- findMailboxRow(mailboxService, accountId, marker.destinationMailboxId),
224
- ]);
225
- if (source.kind === "gone" || destination.kind === "gone") {
226
- const context = {
227
- alert: "placement_move_mailbox_unreachable",
228
- messageId,
229
- accountId,
230
- sourceMailboxId: marker.sourceMailboxId,
231
- destinationMailboxId: marker.destinationMailboxId,
232
- };
233
- if (source.kind === "gone") {
234
- const reconciled = await messageService.deleteIfPlacement(
235
- messageId,
236
- placementRead,
237
- );
238
- await markerService.delete(messageId);
239
- log.error(
240
- { ...context, reconciled },
241
- "Placement move not pushed: its source folder is gone; the stale row was reconciled",
242
- );
243
- if (destination.kind === "present") {
244
- await emitMailboxResync(emit, {
245
- accountId,
246
- mailboxId: marker.destinationMailboxId,
247
- });
248
- }
249
- return;
250
- }
251
- await restoreSourcePlacement(
252
- { messageService, threadMessageService },
253
- {
254
- accountConfigId,
255
- messageId,
256
- sourceMailboxId: marker.sourceMailboxId,
257
- uid: message.uid,
258
- syncStatus: MessageSyncStatus.synced,
259
- abandonedMutation: MessageMutation.none,
260
- },
261
- );
262
- await markerService.delete(messageId);
263
- log.error(
264
- context,
265
- "Placement move not pushed: its destination folder is gone; the row was handed back to its source",
266
- );
267
- await emitMailboxResync(emit, {
268
- accountId,
269
- mailboxId: marker.sourceMailboxId,
270
- });
271
- return;
272
- }
273
- const sourceMailbox = source.mailbox;
274
- const destinationMailbox = destination.mailbox;
275
-
276
- // Cheap frugal skip (epic #1281 invariant 6): either mailbox already known
277
- // paused never even borrows a connection. Optimization only — the
278
- // guardConnectionCursor wraps below are the structural guarantee (#1272).
279
- if (
280
- isCursorRebuildNeeded(sourceMailbox.cursorState) ||
281
- isCursorRebuildNeeded(destinationMailbox.cursorState)
282
- ) {
283
- log.info(
284
- {
285
- messageId,
286
- accountId,
287
- sourceCursorState: sourceMailbox.cursorState,
288
- destinationCursorState: destinationMailbox.cursorState,
289
- },
290
- "Mailbox cursor not normal; pausing outbound placement-move push this round",
291
- );
292
- return;
293
- }
294
-
295
- await withOAuthLifecycle(
296
- buildLifecycleDeps(secrets, accountService),
297
- account,
298
- log,
299
- async (credentials) => {
300
- const scope = createConnectionScope(account, credentials);
301
-
302
- await scope
303
- .getConnection()
304
- .then(async (rawConnection) => {
305
- // Guard at the openBox choke point (epic #1281 invariants 3 & 5).
306
- // Two independently-scoped wraps around the SAME connection — see
307
- // the attemptMove doc comment for why one guard per mailbox matters.
308
- const sourceConnection = guardConnectionCursor(
309
- rawConnection,
310
- { mailboxService },
311
- accountId,
312
- sourceMailbox,
313
- );
314
- const destinationConnection = guardConnectionCursor(
315
- rawConnection,
316
- { mailboxService },
317
- accountId,
318
- destinationMailbox,
319
- );
320
-
321
- const outcome = await attemptMove(
322
- sourceConnection,
323
- destinationConnection,
324
- sourceMailbox.fullPath,
325
- destinationMailbox.fullPath,
326
- message.uid,
327
- await probeableMessageIdHeader(
328
- threadMessageService,
329
- accountConfigId,
330
- messageId,
331
- ),
332
- );
333
-
334
- if (outcome.kind === "trycreate") {
335
- await destinationConnection.createMailbox(
336
- destinationMailbox.fullPath,
337
- );
338
- throw new Error(
339
- `Destination mailbox ${destinationMailbox.fullPath} did not exist; created, retrying`,
340
- );
341
- }
342
-
343
- if (outcome.kind === "not-found") {
344
- // External delete supersedes the marker entirely (epic invariant 2).
345
- await markerService.delete(messageId);
346
- const reconciled = await messageService.deleteIfPlacement(
347
- messageId,
348
- placementRead,
349
- );
350
- log.info(
351
- {
352
- messageId,
353
- accountId,
354
- uid: message.uid,
355
- sourceMailboxPath: sourceMailbox.fullPath,
356
- reconciled,
357
- },
358
- "Message no longer at its pending-move source (external delete or moved away); marker dropped, stale row reconciled",
359
- );
360
- await emitMoveResync(emit, {
361
- accountId,
362
- sourceMailboxId: marker.sourceMailboxId,
363
- destinationMailboxId: marker.destinationMailboxId,
364
- });
365
- return;
366
- }
367
-
368
- const newUid = outcome.newUid as number;
369
- await messageService.updateUid(
370
- messageId,
371
- newUid,
372
- marker.destinationMailboxId,
373
- );
374
-
375
- const threadMessage = await threadMessageService.findByMessageId(
376
- accountConfigId,
377
- messageId,
378
- );
379
- if (threadMessage) {
380
- const args = buildThreadMessageMoveUpdate(
381
- threadMessage,
382
- newUid,
383
- marker.destinationMailboxId,
384
- );
385
- await threadMessageService.update(
386
- threadMessage.accountConfigId,
387
- threadMessage.threadMessageId,
388
- args.set,
389
- { composites: args.composites },
390
- );
391
- }
392
-
393
- // Confirmed IMAP acknowledgement — the "processed" terminal state.
394
- // Deleted immediately rather than persisted first (epic invariant
395
- // 4: no lingering row once the move is no longer pending) — clears
396
- // ONLY here, never on attempt (the defect issue #1271 fixes).
397
- await markerService.delete(messageId);
398
-
399
- log.info(
400
- {
401
- messageId,
402
- accountId,
403
- oldUid: message.uid,
404
- newUid,
405
- destination: destinationMailbox.fullPath,
406
- },
407
- "Placement move confirmed on IMAP; marker cleared",
408
- );
409
-
410
- await emitMoveResync(emit, {
411
- accountId,
412
- sourceMailboxId: marker.sourceMailboxId,
413
- destinationMailboxId: marker.destinationMailboxId,
414
- });
415
- })
416
- .catch(async (error: unknown) => {
417
- // Expected pause (epic #1281 invariant 3), not a fault: ack and
418
- // skip rather than propagating into SQS retry/DLQ. The marker
419
- // stays durable; the push resumes once the mailbox returns to
420
- // normal (driven by the next PLACEMENT_MOVE_PUSH for this message,
421
- // or the surviving-marker re-enqueue in PlacementMoveService).
422
- if (error instanceof MailboxCursorPausedError) {
423
- log.info(
424
- { messageId, accountId, cursorState: error.state },
425
- "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound placement-move push",
426
- );
427
- return;
428
- }
429
-
430
- if (receiveCount < PLACEMENT_MOVE_MAX_ATTEMPTS) {
431
- // Transient push failure — expected (connections drop). No alarm;
432
- // SQS redelivery retries from the still-durable marker.
433
- throw error;
434
- }
435
-
436
- // Redelivery budget exhausted: resolve into exactly one of the two
437
- // terminal outcomes (epic invariant 3) instead of dead-lettering
438
- // with no diagnosis.
439
- const { outcome } = await resolveExhaustedPlacementMoveFailure(
440
- { markerService, messageService, threadMessageService, log },
441
- {
442
- placement: {
443
- status: message.status,
444
- mailboxId: message.mailboxId,
445
- uid: message.uid,
446
- },
447
- accountId,
448
- accountConfigId,
449
- messageId,
450
- uid: message.uid,
451
- sourceMailboxId: marker.sourceMailboxId,
452
- sourceMailboxPath: sourceMailbox.fullPath,
453
- getConnection: scope.getConnection,
454
- },
455
- );
456
-
457
- if (outcome === "reconciled") {
458
- await emitMoveResync(emit, {
459
- accountId,
460
- sourceMailboxId: marker.sourceMailboxId,
461
- destinationMailboxId: marker.destinationMailboxId,
462
- });
463
- return;
464
- }
465
-
466
- // Terminal and never re-thrown, so the handler-outcome series
467
- // records this record as a success. Counted here or it is invisible.
468
- recordImapFailure("PLACEMENT_MOVE_EXHAUSTED", "other");
469
- log.error(
470
- { error: error instanceof Error ? error.message : String(error) },
471
- "Placement move retry exhausted; message still exists at its source",
472
- );
473
- // Terminal — never re-thrown, so the caller acks either way.
474
- })
475
- .finally(() => scope.disconnect());
476
- },
477
- );
478
- };