@remit/imap-worker 0.0.1

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 (54) hide show
  1. package/README.md +100 -0
  2. package/build.mjs +17 -0
  3. package/package.json +50 -0
  4. package/src/account-check.test.ts +122 -0
  5. package/src/account-check.ts +88 -0
  6. package/src/body-sync-gate.test.ts +185 -0
  7. package/src/body-sync-gate.ts +86 -0
  8. package/src/cli.ts +211 -0
  9. package/src/connection-scope.test.ts +266 -0
  10. package/src/connection-scope.ts +335 -0
  11. package/src/e2e-processor-shim.ts +248 -0
  12. package/src/emit.test.ts +44 -0
  13. package/src/emit.ts +142 -0
  14. package/src/events.ts +221 -0
  15. package/src/handlers/append-sent-message.ts +163 -0
  16. package/src/handlers/delete-account-objects.test.ts +81 -0
  17. package/src/handlers/delete-account-objects.ts +116 -0
  18. package/src/handlers/empty-trash.ts +136 -0
  19. package/src/handlers/flag-push.test.ts +25 -0
  20. package/src/handlers/flag-push.ts +224 -0
  21. package/src/handlers/mailbox-management.ts +266 -0
  22. package/src/handlers/mailbox-sync-order.test.ts +93 -0
  23. package/src/handlers/mailbox-sync-order.ts +65 -0
  24. package/src/handlers/message-copy.ts +219 -0
  25. package/src/handlers/message-delete.test.ts +176 -0
  26. package/src/handlers/message-delete.ts +283 -0
  27. package/src/handlers/message-move.test.ts +168 -0
  28. package/src/handlers/message-move.ts +298 -0
  29. package/src/handlers/placement-move-push.test.ts +234 -0
  30. package/src/handlers/placement-move-push.ts +434 -0
  31. package/src/handlers/sync-mailboxes.ts +241 -0
  32. package/src/handlers/sync-message-body.test.ts +375 -0
  33. package/src/handlers/sync-message-body.ts +337 -0
  34. package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
  35. package/src/handlers/sync-messages.test.ts +204 -0
  36. package/src/handlers/sync-messages.ts +412 -0
  37. package/src/handlers/sync-reserved-host.test.ts +97 -0
  38. package/src/index.test.ts +22 -0
  39. package/src/index.ts +70 -0
  40. package/src/poller.ts +49 -0
  41. package/src/processor.test.ts +58 -0
  42. package/src/processor.ts +66 -0
  43. package/src/scheduler/config.test.ts +40 -0
  44. package/src/scheduler/config.ts +52 -0
  45. package/src/scheduler/decide-due.test.ts +44 -0
  46. package/src/scheduler/decide-due.ts +26 -0
  47. package/src/scheduler/handler.ts +52 -0
  48. package/src/scheduler/local-runner.ts +76 -0
  49. package/src/scheduler/run-tick.test.ts +248 -0
  50. package/src/scheduler/run-tick.ts +141 -0
  51. package/src/with-oauth-lifecycle-deps.ts +62 -0
  52. package/src/with-oauth-lifecycle.test.ts +227 -0
  53. package/src/with-oauth-lifecycle.ts +125 -0
  54. package/tsconfig.json +8 -0
@@ -0,0 +1,434 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+ import { MetricUnit, metrics } from "@remit/logger-lambda";
4
+ import {
5
+ guardConnectionCursor,
6
+ type IImapConnection,
7
+ isCursorRebuildNeeded,
8
+ MailboxCursorPausedError,
9
+ reconcileStaleMessage,
10
+ resolveExhaustedPlacementMoveFailure,
11
+ } from "@remit/mailbox-service";
12
+ import { isAccountDeleted } from "../account-check.js";
13
+ import { createConnectionScopeWithCredentials } from "../connection-scope.js";
14
+ import { emitEvent } from "../emit.js";
15
+ import type { PlacementMovePushEvent } from "../events.js";
16
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
+ import {
19
+ buildThreadMessageMoveUpdate,
20
+ emitMoveResync,
21
+ } from "./message-move.js";
22
+
23
+ /**
24
+ * Fallback when `PLACEMENT_MOVE_MAX_ATTEMPTS` is unset (local dev, unit
25
+ * tests). Matches the placement-move queue's own `MAX_RECEIVE_COUNT` default
26
+ * (`infra/stacks/dev/stacks/remit-queue-stack.ts`), same pattern as
27
+ * `BODY_SYNC_MAX_ATTEMPTS` (#1270).
28
+ */
29
+ const DEFAULT_PLACEMENT_MOVE_MAX_ATTEMPTS = 3;
30
+
31
+ export const getPlacementMoveMaxAttempts = (
32
+ processEnv: NodeJS.ProcessEnv = process.env,
33
+ ): number => {
34
+ const raw = processEnv.PLACEMENT_MOVE_MAX_ATTEMPTS;
35
+ if (!raw) return DEFAULT_PLACEMENT_MOVE_MAX_ATTEMPTS;
36
+ const parsed = Number.parseInt(raw, 10);
37
+ return Number.isFinite(parsed) && parsed > 0
38
+ ? parsed
39
+ : DEFAULT_PLACEMENT_MOVE_MAX_ATTEMPTS;
40
+ };
41
+
42
+ export const PLACEMENT_MOVE_MAX_ATTEMPTS = getPlacementMoveMaxAttempts();
43
+
44
+ interface MoveOutcome {
45
+ kind: "moved" | "not-found" | "trycreate";
46
+ newUid?: number;
47
+ }
48
+
49
+ /**
50
+ * SEARCH a mailbox for a message by its RFC822 Message-ID header. Read-only
51
+ * (EXAMINE, not SELECT) — this is a verification probe, never a write.
52
+ * Returns the first matching UID, or `null` if nothing matched.
53
+ */
54
+ const searchMailboxByMessageId = async (
55
+ connection: IImapConnection,
56
+ mailboxPath: string,
57
+ messageIdHeader: string,
58
+ ): Promise<number | null> => {
59
+ await connection.openBox(mailboxPath, true);
60
+ const uids = await connection.search([
61
+ `HEADER Message-ID "${messageIdHeader}"`,
62
+ ]);
63
+ return uids[0] ?? null;
64
+ };
65
+
66
+ /**
67
+ * Attempt the IMAP MOVE.
68
+ *
69
+ * Takes two connection handles rather than one: `sourceConnection` and
70
+ * `destinationConnection` wrap the SAME underlying connection but are each
71
+ * cursor-guarded (#1272) against their OWN mailbox — a `guardConnectionCursor`
72
+ * wrap binds `openBox` checks to whichever ONE mailbox snapshot it was built
73
+ * with, so verifying at the destination must never run through the source's
74
+ * guard (it would compare the destination's served UIDVALIDITY against the
75
+ * source's stored one and misfire).
76
+ *
77
+ * - `moved` with a `newUid` — confirmed, either via COPYUID or (see below)
78
+ * an explicit destination verification.
79
+ * - `trycreate` — the destination mailbox doesn't exist yet; the caller
80
+ * creates it and retries.
81
+ * - `not-found` — confirmed absent from BOTH source and destination. Only
82
+ * this outcome supersedes the pending-move marker (drops it, reconciles
83
+ * the row as an external delete).
84
+ * - Any other case throws (propagates for SQS retry, or as
85
+ * `MailboxCursorPausedError` for the caller's cursor-pause handling) —
86
+ * including an UNCONFIRMED move (no COPYUID, or an explicit "not
87
+ * found"/NONEXISTENT from the server) whose message is still sitting at
88
+ * the source. A MOVE that resolves without a uidMap entry on a
89
+ * non-UIDPLUS server is a plausible SUCCESS, not evidence the message is
90
+ * gone (PR #1289 review finding 2) — deleting the local row on that
91
+ * ambiguity would be data loss. `not-found` is only returned once BOTH a
92
+ * destination Message-ID search (when the header is known) misses AND
93
+ * the message is confirmed absent from the source.
94
+ */
95
+ export const attemptMove = async (
96
+ sourceConnection: IImapConnection,
97
+ destinationConnection: IImapConnection,
98
+ sourceMailboxPath: string,
99
+ destinationMailboxPath: string,
100
+ uid: number,
101
+ messageIdHeader: string | undefined,
102
+ ): Promise<MoveOutcome> => {
103
+ await sourceConnection.openBox(sourceMailboxPath, false);
104
+
105
+ const resolved = await sourceConnection
106
+ .moveMessages([uid], destinationMailboxPath)
107
+ .then((result) => ({ ok: true as const, newUid: result.uidMap.get(uid) }))
108
+ .catch((error: unknown) => {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ if (message.includes("TRYCREATE")) {
111
+ return { ok: false as const, trycreate: true as const };
112
+ }
113
+ if (message.includes("not found") || message.includes("NONEXISTENT")) {
114
+ return { ok: false as const, trycreate: false as const };
115
+ }
116
+ throw error;
117
+ });
118
+
119
+ if (resolved.ok && resolved.newUid) {
120
+ return { kind: "moved", newUid: resolved.newUid };
121
+ }
122
+ if (!resolved.ok && resolved.trycreate) {
123
+ return { kind: "trycreate" };
124
+ }
125
+
126
+ // Unconfirmed: either no COPYUID entry, or the server explicitly claimed
127
+ // "not found" — never trust either without independent verification.
128
+ if (messageIdHeader) {
129
+ const destinationUid = await searchMailboxByMessageId(
130
+ destinationConnection,
131
+ destinationMailboxPath,
132
+ messageIdHeader,
133
+ );
134
+ if (destinationUid) return { kind: "moved", newUid: destinationUid };
135
+ }
136
+
137
+ await sourceConnection.openBox(sourceMailboxPath, true);
138
+ const stillAtSource = await sourceConnection.fetchMessages([uid]);
139
+ if (stillAtSource.length > 0) {
140
+ throw new Error(
141
+ "Placement move unresolved (unconfirmed at destination, still present at source) — retrying",
142
+ );
143
+ }
144
+
145
+ return { kind: "not-found" };
146
+ };
147
+
148
+ /**
149
+ * Handle PLACEMENT_MOVE_PUSH events (issue #1271, epic #1281). Drains ONE
150
+ * pending placement-move marker: resolves the UID fresh from the Message row
151
+ * (never trusts a captured value — invariant 1), pushes the IMAP MOVE, and
152
+ * clears the marker ONLY on confirmed success. Every precedence rule (epic
153
+ * invariant 2) is enforced here:
154
+ * - Pending Remit move wins on location: nothing in this handler ever
155
+ * corrects `message.mailboxId` back toward the source — a resync elsewhere
156
+ * (message-sync.ts) never overwrites an existing row's `mailboxId` either
157
+ * (create-only-if-not-exists semantics), so the local move is never undone.
158
+ * - A NEWER local action superseding this marker (drop without pushing) —
159
+ * see the `message.mailboxId !== marker.destinationMailboxId` check below.
160
+ * - An external delete supersedes the marker entirely — the `not-found`
161
+ * outcome drops the marker and reconciles the stale row.
162
+ *
163
+ * Cursor-guarded (#1272, epic #1281 invariant 5): both the source and
164
+ * destination mailbox are checked for a non-`normal` `cursorState` before
165
+ * connecting, and the connection is wrapped per-mailbox via
166
+ * `guardConnectionCursor` so no stored UID touches the server while either
167
+ * mailbox's axis is being rebuilt. A trip pauses the push (routine, no
168
+ * alarm) — the marker stays durable and pushes again on the next event.
169
+ */
170
+ export const handlePlacementMovePush = async (
171
+ event: PlacementMovePushEvent,
172
+ log: Logger,
173
+ receiveCount = 1,
174
+ ): Promise<void> => {
175
+ const {
176
+ account: accountService,
177
+ mailbox: mailboxService,
178
+ message: messageService,
179
+ threadMessage: threadMessageService,
180
+ placementMove: markerService,
181
+ secrets,
182
+ } = await getClient();
183
+
184
+ const { accountId, accountConfigId, messageId } = event;
185
+
186
+ const marker = await markerService.find(messageId);
187
+ if (!marker) {
188
+ log.info(
189
+ { messageId, accountId },
190
+ "No pending placement-move marker (already confirmed or superseded); nothing to push",
191
+ );
192
+ return;
193
+ }
194
+
195
+ const account = await accountService.get(accountId);
196
+ if (!account) {
197
+ throw new Error(`Account ${accountId} not found`);
198
+ }
199
+ if (isAccountDeleted(account, log)) {
200
+ return;
201
+ }
202
+
203
+ const message = await messageService.get(messageId);
204
+
205
+ // A newer local action (drag-and-drop move, auto-moved Undo) already
206
+ // changed the message's location away from what this marker still
207
+ // promises — that later intent wins locally. Drop the now-stale marker
208
+ // without touching IMAP.
209
+ if (message.mailboxId !== marker.destinationMailboxId) {
210
+ await markerService.delete(messageId);
211
+ log.info(
212
+ {
213
+ messageId,
214
+ accountId,
215
+ markerDestination: marker.destinationMailboxId,
216
+ currentMailboxId: message.mailboxId,
217
+ },
218
+ "Placement move superseded by a newer local action; marker dropped without pushing",
219
+ );
220
+ return;
221
+ }
222
+
223
+ // The worker has picked up the event and is about to actually attempt the
224
+ // IMAP MOVE — advance the state engine (pending/queued -> processing).
225
+ // Idempotent to call again on a redelivered event (a prior attempt that
226
+ // died mid-flight already left it here).
227
+ await markerService.updateState(messageId, "processing");
228
+
229
+ const sourceMailbox = await mailboxService.get(
230
+ accountId,
231
+ marker.sourceMailboxId,
232
+ );
233
+ const destinationMailbox = await mailboxService.get(
234
+ accountId,
235
+ marker.destinationMailboxId,
236
+ );
237
+
238
+ // Cheap frugal skip (epic #1281 invariant 6): either mailbox already known
239
+ // paused never even borrows a connection. Optimization only — the
240
+ // guardConnectionCursor wraps below are the structural guarantee (#1272).
241
+ if (
242
+ isCursorRebuildNeeded(sourceMailbox.cursorState) ||
243
+ isCursorRebuildNeeded(destinationMailbox.cursorState)
244
+ ) {
245
+ log.info(
246
+ {
247
+ messageId,
248
+ accountId,
249
+ sourceCursorState: sourceMailbox.cursorState,
250
+ destinationCursorState: destinationMailbox.cursorState,
251
+ },
252
+ "Mailbox cursor not normal; pausing outbound placement-move push this round",
253
+ );
254
+ return;
255
+ }
256
+
257
+ await withOAuthLifecycle(
258
+ buildLifecycleDeps(secrets, accountService),
259
+ account,
260
+ log,
261
+ async (credentials) => {
262
+ const scope = createConnectionScopeWithCredentials(account, credentials);
263
+
264
+ await scope
265
+ .getConnection()
266
+ .then(async (rawConnection) => {
267
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5).
268
+ // Two independently-scoped wraps around the SAME connection — see
269
+ // the attemptMove doc comment for why one guard per mailbox matters.
270
+ const sourceConnection = guardConnectionCursor(
271
+ rawConnection,
272
+ { mailboxService },
273
+ accountId,
274
+ sourceMailbox,
275
+ );
276
+ const destinationConnection = guardConnectionCursor(
277
+ rawConnection,
278
+ { mailboxService },
279
+ accountId,
280
+ destinationMailbox,
281
+ );
282
+
283
+ const outcome = await attemptMove(
284
+ sourceConnection,
285
+ destinationConnection,
286
+ sourceMailbox.fullPath,
287
+ destinationMailbox.fullPath,
288
+ message.uid,
289
+ message.messageIdHeader,
290
+ );
291
+
292
+ if (outcome.kind === "trycreate") {
293
+ await destinationConnection.createMailbox(
294
+ destinationMailbox.fullPath,
295
+ );
296
+ throw new Error(
297
+ `Destination mailbox ${destinationMailbox.fullPath} did not exist; created, retrying`,
298
+ );
299
+ }
300
+
301
+ if (outcome.kind === "not-found") {
302
+ // External delete supersedes the marker entirely (epic invariant 2).
303
+ await markerService.delete(messageId);
304
+ const { threadMessagesDeleted } = await reconcileStaleMessage(
305
+ { messageService, threadMessageService },
306
+ accountConfigId,
307
+ messageId,
308
+ );
309
+ log.info(
310
+ {
311
+ messageId,
312
+ accountId,
313
+ uid: message.uid,
314
+ sourceMailboxPath: sourceMailbox.fullPath,
315
+ threadMessagesDeleted,
316
+ },
317
+ "Message no longer at its pending-move source (external delete or moved away); marker dropped, stale row reconciled",
318
+ );
319
+ await emitMoveResync(emitEvent, {
320
+ accountId,
321
+ sourceMailboxId: marker.sourceMailboxId,
322
+ destinationMailboxId: marker.destinationMailboxId,
323
+ });
324
+ return;
325
+ }
326
+
327
+ const newUid = outcome.newUid as number;
328
+ await messageService.updateUid(
329
+ messageId,
330
+ newUid,
331
+ marker.destinationMailboxId,
332
+ );
333
+
334
+ const threadMessage = await threadMessageService.findByMessageId(
335
+ accountConfigId,
336
+ messageId,
337
+ );
338
+ if (threadMessage) {
339
+ const args = buildThreadMessageMoveUpdate(
340
+ threadMessage,
341
+ newUid,
342
+ marker.destinationMailboxId,
343
+ );
344
+ await threadMessageService.update(
345
+ threadMessage.accountConfigId,
346
+ threadMessage.threadMessageId,
347
+ args.set,
348
+ { composites: args.composites },
349
+ );
350
+ }
351
+
352
+ // Confirmed IMAP acknowledgement — the "processed" terminal state.
353
+ // Deleted immediately rather than persisted first (epic invariant
354
+ // 4: no lingering row once the move is no longer pending) — clears
355
+ // ONLY here, never on attempt (the defect issue #1271 fixes).
356
+ await markerService.delete(messageId);
357
+
358
+ log.info(
359
+ {
360
+ messageId,
361
+ accountId,
362
+ oldUid: message.uid,
363
+ newUid,
364
+ destination: destinationMailbox.fullPath,
365
+ },
366
+ "Placement move confirmed on IMAP; marker cleared",
367
+ );
368
+
369
+ await emitMoveResync(emitEvent, {
370
+ accountId,
371
+ sourceMailboxId: marker.sourceMailboxId,
372
+ destinationMailboxId: marker.destinationMailboxId,
373
+ });
374
+ })
375
+ .catch(async (error: unknown) => {
376
+ // Expected pause (epic #1281 invariant 3), not a fault: ack and
377
+ // skip rather than propagating into SQS retry/DLQ. The marker
378
+ // stays durable; the push resumes once the mailbox returns to
379
+ // normal (driven by the next PLACEMENT_MOVE_PUSH for this message,
380
+ // or the surviving-marker re-enqueue in PlacementMoveService).
381
+ if (error instanceof MailboxCursorPausedError) {
382
+ log.info(
383
+ { messageId, accountId, cursorState: error.state },
384
+ "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound placement-move push",
385
+ );
386
+ return;
387
+ }
388
+
389
+ if (receiveCount < PLACEMENT_MOVE_MAX_ATTEMPTS) {
390
+ // Transient push failure — expected (connections drop). No alarm;
391
+ // SQS redelivery retries from the still-durable marker.
392
+ throw error;
393
+ }
394
+
395
+ // Redelivery budget exhausted: resolve into exactly one of the two
396
+ // terminal outcomes (epic invariant 3) instead of dead-lettering
397
+ // with no diagnosis.
398
+ const { outcome } = await resolveExhaustedPlacementMoveFailure(
399
+ { markerService, messageService, threadMessageService, log },
400
+ {
401
+ accountId,
402
+ accountConfigId,
403
+ messageId,
404
+ uid: message.uid,
405
+ sourceMailboxPath: sourceMailbox.fullPath,
406
+ getConnection: scope.getConnection,
407
+ },
408
+ );
409
+
410
+ if (outcome === "reconciled") {
411
+ metrics.addMetric(
412
+ "placementMoveStaleRowReconciled",
413
+ MetricUnit.Count,
414
+ 1,
415
+ );
416
+ await emitMoveResync(emitEvent, {
417
+ accountId,
418
+ sourceMailboxId: marker.sourceMailboxId,
419
+ destinationMailboxId: marker.destinationMailboxId,
420
+ });
421
+ return;
422
+ }
423
+
424
+ metrics.addMetric("placementMoveFailed", MetricUnit.Count, 1);
425
+ log.error(
426
+ { error: error instanceof Error ? error.message : String(error) },
427
+ "Placement move retry exhausted; message still exists at its source",
428
+ );
429
+ // Terminal — never re-thrown, so the caller acks either way.
430
+ })
431
+ .finally(() => scope.disconnect());
432
+ },
433
+ );
434
+ };
@@ -0,0 +1,241 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type {
3
+ AccountItem,
4
+ IAccountRepository,
5
+ IMailboxRepository,
6
+ IMailboxSpecialUseRepository,
7
+ } from "@remit/data-ports";
8
+ import { SyncPhase } from "@remit/domain-enums";
9
+ import type { Logger } from "@remit/logger-lambda";
10
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
11
+ import {
12
+ createConnectionWithCredentials,
13
+ MailboxSyncService,
14
+ MailConnectionError,
15
+ type MailCredentials,
16
+ } from "@remit/mailbox-service";
17
+ import pMap from "p-map";
18
+ import { isAccountDeleted, isUnsyncableHost } from "../account-check.js";
19
+ import { emitEvent } from "../emit.js";
20
+ import type { SyncMailboxesEvent, SyncMessagesEvent } from "../events.js";
21
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
22
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
23
+ import { orderMailboxesForSync } from "./mailbox-sync-order.js";
24
+
25
+ const EVENT_EMIT_CONCURRENCY = 20;
26
+ const SYNC_COOLDOWN_MS = 30_000; // 30 seconds
27
+
28
+ export const syncMailboxes = async (
29
+ event: SyncMailboxesEvent,
30
+ log: Logger,
31
+ ): Promise<void> => {
32
+ const {
33
+ account: accountService,
34
+ mailbox: mailboxService,
35
+ mailboxSpecialUse: mailboxSpecialUseService,
36
+ secrets,
37
+ } = await getClient();
38
+
39
+ const { accountId } = event;
40
+ log.info({ event: event.type, accountId }, "Handling event");
41
+
42
+ const account = await accountService.get(accountId);
43
+ if (!account) {
44
+ throw new Error(`Account ${accountId} not found`);
45
+ }
46
+
47
+ if (isAccountDeleted(account, log)) {
48
+ return;
49
+ }
50
+
51
+ // A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
52
+ // sync attempt would retry and dead-letter forever. Skip cleanly — ack the
53
+ // event without connecting or throwing.
54
+ if (isUnsyncableHost(account, log)) {
55
+ return;
56
+ }
57
+
58
+ // withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, flip on
59
+ // terminal auth failure, rethrow transient). The inner try/catch only
60
+ // records the terminal non-auth error phase before letting the wrapper
61
+ // rethrow for SQS retry/DLQ.
62
+ await withOAuthLifecycle(
63
+ buildLifecycleDeps(secrets, accountService),
64
+ account,
65
+ log,
66
+ async (credentials) => {
67
+ try {
68
+ await syncMailboxesForAccount(
69
+ account,
70
+ credentials,
71
+ mailboxService,
72
+ mailboxSpecialUseService,
73
+ accountService,
74
+ log,
75
+ );
76
+ } catch (err) {
77
+ // Auth failures are handled by the wrapper — rethrow untouched so it
78
+ // flips the account to reauth_required rather than recording an error
79
+ // phase.
80
+ if (
81
+ err instanceof RefreshTokenError ||
82
+ (err instanceof MailConnectionError && err.kind === "auth")
83
+ ) {
84
+ throw err;
85
+ }
86
+ // Record the terminal error phase before crashing (let-it-crash:
87
+ // record state, then rethrow so the event is retried/DLQ'd).
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ await accountService.update(accountId, {
90
+ syncPhase: SyncPhase.error,
91
+ lastError: message,
92
+ });
93
+ throw err;
94
+ }
95
+ },
96
+ );
97
+ };
98
+
99
+ const syncMailboxesForAccount = async (
100
+ account: AccountItem,
101
+ credentials: MailCredentials,
102
+ mailboxService: IMailboxRepository,
103
+ mailboxSpecialUseService: IMailboxSpecialUseRepository,
104
+ accountService: IAccountRepository,
105
+ log: Logger,
106
+ ): Promise<void> => {
107
+ const { accountId } = account;
108
+
109
+ const connection = createConnectionWithCredentials(
110
+ {
111
+ username: account.username,
112
+ imapHost: account.imapHost,
113
+ imapPort: account.imapPort,
114
+ imapTls: account.imapTls,
115
+ },
116
+ credentials,
117
+ );
118
+
119
+ await connection.connect();
120
+
121
+ await accountService.markAuthenticated(accountId);
122
+
123
+ // Phase transition: discovering mailboxes
124
+ await accountService.update(accountId, {
125
+ syncPhase: SyncPhase.discovering_mailboxes,
126
+ });
127
+
128
+ const mailboxSyncService = new MailboxSyncService(
129
+ mailboxService,
130
+ mailboxSpecialUseService,
131
+ );
132
+
133
+ const result = await mailboxSyncService
134
+ .syncMailboxes({ accountId }, connection)
135
+ .finally(() => connection.disconnect());
136
+
137
+ await accountService.update(accountId, { lastSyncAt: Date.now() });
138
+
139
+ log.info({ result }, "Mailbox sync complete");
140
+
141
+ // Get all mailboxes and emit SYNC_MESSAGES for each
142
+ const allMailboxes = await collectAllMailboxes(accountId, mailboxService);
143
+
144
+ // Filter out mailboxes that were synced recently (cooldown)
145
+ // Always include mailboxes that were never synced (lastMessageSyncAt is 0, undefined, or null)
146
+ const now = Date.now();
147
+ const mailboxes = allMailboxes.filter(
148
+ (m) => !m.lastMessageSyncAt || now - m.lastMessageSyncAt > SYNC_COOLDOWN_MS,
149
+ );
150
+
151
+ const skipped = allMailboxes.length - mailboxes.length;
152
+ if (skipped > 0) {
153
+ log.info({ accountId, skipped }, "Skipped mailboxes due to sync cooldown");
154
+ }
155
+
156
+ if (mailboxes.length === 0) {
157
+ log.info({ accountId }, "No mailboxes to sync messages for");
158
+ // If there are no mailboxes to sync, mark as complete
159
+ await accountService.update(accountId, {
160
+ syncPhase: SyncPhase.complete,
161
+ mailboxCountTotal: allMailboxes.length,
162
+ mailboxCountSynced: allMailboxes.length,
163
+ });
164
+ return;
165
+ }
166
+
167
+ log.info(
168
+ { accountId, count: mailboxes.length },
169
+ "Emitting SYNC_MESSAGES events",
170
+ );
171
+
172
+ // Phase transition. Completion events only fire for the enqueued
173
+ // (cooldown-filtered) set, so pre-credit the skipped mailboxes into
174
+ // mailboxCountSynced — otherwise synced can never reach total.
175
+ // If INBOX itself was skipped, go straight to syncing_others.
176
+ const inboxEnqueued = mailboxes.some(
177
+ (m) => m.fullPath.toUpperCase() === "INBOX",
178
+ );
179
+ await accountService.update(accountId, {
180
+ syncPhase: inboxEnqueued
181
+ ? SyncPhase.syncing_inbox
182
+ : SyncPhase.syncing_others,
183
+ mailboxCountTotal: allMailboxes.length,
184
+ mailboxCountSynced: skipped,
185
+ });
186
+
187
+ // Emit events in parallel with concurrency limit
188
+ // INBOX is first in the sorted list, so it gets priority
189
+ await pMap(
190
+ mailboxes,
191
+ ({ mailboxId }) => {
192
+ const syncEvent: Omit<SyncMessagesEvent, "eventId" | "timestamp"> = {
193
+ type: "SYNC_MESSAGES",
194
+ accountId,
195
+ mailboxId,
196
+ };
197
+ return emitEvent(syncEvent);
198
+ },
199
+ { concurrency: EVENT_EMIT_CONCURRENCY },
200
+ );
201
+ };
202
+
203
+ type MailboxSortEntry = {
204
+ mailboxId: string;
205
+ fullPath: string;
206
+ lastMessageSyncAt: number;
207
+ specialUse?: readonly string[];
208
+ };
209
+
210
+ /**
211
+ * Collect all mailboxes for an account, ordered for sync fan-out by
212
+ * special-use: INBOX first, then Sent/Drafts, then normal folders, with
213
+ * Junk/Spam and Trash last (issue #567). Real mail dispatches ahead of bulk
214
+ * folders so a fresh account fills its inbox before its spam.
215
+ */
216
+ const collectAllMailboxes = async (
217
+ accountId: string,
218
+ mailboxService: IMailboxRepository,
219
+ ): Promise<MailboxSortEntry[]> => {
220
+ const mailboxes: MailboxSortEntry[] = [];
221
+ let continuationToken: string | undefined;
222
+
223
+ do {
224
+ const result = await mailboxService.listByAccount(accountId, {
225
+ continuationToken,
226
+ });
227
+
228
+ for (const mailbox of result.items) {
229
+ mailboxes.push({
230
+ mailboxId: mailbox.mailboxId,
231
+ fullPath: mailbox.fullPath,
232
+ lastMessageSyncAt: mailbox.lastMessageSyncAt,
233
+ specialUse: mailbox.specialUse,
234
+ });
235
+ }
236
+
237
+ continuationToken = result.continuationToken ?? undefined;
238
+ } while (continuationToken);
239
+
240
+ return orderMailboxesForSync(mailboxes);
241
+ };