@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
@@ -0,0 +1,182 @@
1
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
+ import { BodySyncService, guardConnectionCursor, isCursorRebuildNeeded, isFolderMutationInFlight, MailboxCursorPausedError, PlacementMoveService, QuarantineService, resolveExhaustedBodySyncFailures, resolveFolderPlacement, SyncWarningService, } from "@remit/mailbox-service";
3
+ import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
4
+ import { isAccountDeleted } from "../account-check.js";
5
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
6
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
7
+ export const resolveBatch = (event) => {
8
+ const force = event.force === true;
9
+ if (event.messages !== undefined) {
10
+ return {
11
+ messageIds: event.messages.map((m) => m.messageId),
12
+ uidByMessageId: new Map(event.messages.map((m) => [m.messageId, m.uid])),
13
+ force,
14
+ };
15
+ }
16
+ return { messageIds: event.messageIds, force };
17
+ };
18
+ /**
19
+ * Build the loud, structured error thrown when a batch still has failed
20
+ * messages and SQS redelivery budget left. Throwing (rather than swallowing
21
+ * into a fresh re-enqueue) is what lets a genuine processing failure reach
22
+ * the body-dlq once `BODY_SYNC_MAX_ATTEMPTS` is exhausted — the SQS-level
23
+ * redrive owns retry scheduling now, not this handler.
24
+ */
25
+ export const buildRetryableFailureError = (failedMessageIds, receiveCount, maxAttempts) => new Error(`Body sync failed for ${failedMessageIds.length} message(s) ` +
26
+ `(attempt ${receiveCount}/${maxAttempts}): ${failedMessageIds.join(", ")}`);
27
+ export const syncMessageBody = async (event, ctx, receiveCount) => {
28
+ const { log, queues, pool, metrics, config } = ctx;
29
+ const { accountId, mailboxId } = event;
30
+ // Prefer the messageId+uid pairs when present (one ranged FETCH, no
31
+ // per-message UID lookup); fall back to the legacy id-only list otherwise.
32
+ const { messageIds, force } = resolveBatch(event);
33
+ log.info({
34
+ event: event.type,
35
+ accountId,
36
+ mailboxId,
37
+ messageCount: messageIds.length,
38
+ hasUids: event.messages !== undefined,
39
+ force,
40
+ receiveCount,
41
+ }, "Handling event");
42
+ const { account: accountService, mailbox: mailboxService, mailboxSpecialUse: mailboxSpecialUseService, message: messageService, threadMessage: threadMessageService, address: addressService, envelope: envelopeService, placementMove: markerService, filter: filterService, messageLabel: messageLabelService, storage, secrets, mailboxSpecialUse: mailboxSpecialUseRepository, quarantine: quarantineRepository, syncWarning: syncWarningRepository, flagQueue: flagQueueService, calendarSuggestion: calendarSuggestionService, calendarStore, } = ctx.client;
43
+ const account = await accountService.get(accountId);
44
+ if (!account) {
45
+ throw new Error(`Account ${accountId} not found`);
46
+ }
47
+ if (isAccountDeleted(account, log)) {
48
+ return;
49
+ }
50
+ // PLACEMENT_MOVE_PUSH rides messageMgmtQueue (issue #1271) — same queue
51
+ // the general MessageMoveService move/delete/copy machinery uses, not a
52
+ // dedicated one.
53
+ const placementMoveService = new PlacementMoveService({
54
+ messageService,
55
+ threadMessageService,
56
+ markerService,
57
+ addressService,
58
+ mailboxSpecialUseService,
59
+ mailboxService,
60
+ queue: queues.messageManagement,
61
+ });
62
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
63
+ // A SYNC_MESSAGE_BODY trigger can outlive the mailbox it targets: deleting
64
+ // a folder that held mail leaves already-queued body events pointing at a
65
+ // row that is now gone, and the lookup then throws NotFoundError forever.
66
+ // Since the body queue carries MessageGroupId=accountId, that head message
67
+ // stalls the account's whole body pipeline (issues #287, #289, #290). A
68
+ // deleted folder is an expected terminal outcome: ack with a WARN.
69
+ const mailbox = await mailboxService
70
+ .get(accountId, mailboxId)
71
+ .catch((error) => {
72
+ if (isNotFoundError(error))
73
+ return null;
74
+ throw error;
75
+ });
76
+ // A folder still `pending` is terminal for the same reason: the batch
77
+ // was cut before the folder reached the server, so there is nothing
78
+ // there to fetch from and no retry that changes it.
79
+ if (!mailbox || isFolderMutationInFlight(mailbox)) {
80
+ log.warn({ accountId, mailboxId, eventId: event.eventId }, "Skipping SYNC_MESSAGE_BODY: a folder mutation is in flight");
81
+ return;
82
+ }
83
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
84
+ // paused never even borrows a connection. This is an optimization only
85
+ // — guardConnectionCursor below is the structural guarantee, so a
86
+ // handler that forgot this check still cannot reach a stale UID.
87
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
88
+ log.info({ accountId, mailboxId, cursorState: mailbox.cursorState }, "Mailbox cursor not normal; pausing outbound body sync this round");
89
+ return;
90
+ }
91
+ const borrowed = pool.borrow(account, credentials);
92
+ // A confident, actionable placement verdict moves mail directly on
93
+ // body-sync. Safety lives in the verdict itself (only confident,
94
+ // INBOX/Junk-only) and the movedByRemit loop guard.
95
+ const placementConfig = {
96
+ mailboxSpecialUseService,
97
+ placementMoveService,
98
+ };
99
+ // A matched filter's actions (label upsert, exclusive move) apply on
100
+ // the same body-sync pass, reusing the placement mover for the move
101
+ // (RFC 034 Decision 3.1).
102
+ const filterConfig = buildFilterConfig({
103
+ filterService,
104
+ messageLabelService,
105
+ mailboxService,
106
+ placementMoveService,
107
+ });
108
+ const bodySyncService = new BodySyncService(messageService, storage, threadMessageService, addressService, envelopeService, new SyncWarningService(syncWarningRepository), log, placementConfig, filterConfig, {
109
+ quarantineService: new QuarantineService(quarantineRepository, mailboxSpecialUseRepository, config.workerVersion, log),
110
+ mailboxId,
111
+ uidValidity: mailbox.uidValidity ?? 0,
112
+ attempts: receiveCount,
113
+ }, { flagQueueService },
114
+ // An invitation becomes a card the first time the message is seen,
115
+ // alongside the filters that run on the same pass (issue #1033).
116
+ { calendarSuggestionService, calendarStore, filterService });
117
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5). The
118
+ // served UIDVALIDITY is only knowable by opening the box, which only a
119
+ // genuine fetch needs — BodySyncService.syncBodies skips connecting
120
+ // entirely when every message already has a stored body (nothing would
121
+ // touch a stored UID either way) — so the check fires lazily, the first
122
+ // time `openBox` is actually called, instead of forcing an extra open
123
+ // on every event.
124
+ const getConnectionChecked = async () => guardConnectionCursor(await borrowed.getConnection(), { mailboxService }, accountId, mailbox);
125
+ // Both the sync attempt AND the retry-exhaustion resolution below run
126
+ // against the SAME borrowed connection, so the mailbox stays open
127
+ // across the two — the connection is only released once both are done.
128
+ const folder = {
129
+ mailboxId: mailbox.mailboxId,
130
+ fullPath: mailbox.fullPath,
131
+ uidValidity: mailbox.uidValidity,
132
+ placement: await resolveFolderPlacement(mailboxSpecialUseService, accountId, mailbox.mailboxId),
133
+ };
134
+ await (async () => {
135
+ const result = await bodySyncService.syncBodies(messageIds, accountId, account.accountConfigId, folder, getConnectionChecked, force);
136
+ if (result.failedMessageIds.length === 0) {
137
+ return;
138
+ }
139
+ if (receiveCount < config.attempts.body) {
140
+ // Redelivery budget remains: let SQS retry the whole record
141
+ // naturally. Messages that already synced skip via the
142
+ // already-stored guard on the next attempt, so only the failed
143
+ // ones actually redo work. This — not a manual re-enqueue — is
144
+ // what lets a genuine processing failure ever reach the body-dlq.
145
+ throw buildRetryableFailureError(result.failedMessageIds, receiveCount, config.attempts.body);
146
+ }
147
+ // Redelivery budget exhausted: a persistent per-message failure is
148
+ // never a steady state (epic #1281 invariant 3). Resolve every
149
+ // failed id into exactly one of the two terminal outcomes instead of
150
+ // letting the record dead-letter with no diagnosis.
151
+ const { brokenMessageIds } = await resolveExhaustedBodySyncFailures({
152
+ messageService,
153
+ storageService: storage,
154
+ log,
155
+ }, {
156
+ accountId,
157
+ accountConfigId: account.accountConfigId,
158
+ mailboxId,
159
+ mailboxPath: mailbox.fullPath,
160
+ failedMessageIds: result.failedMessageIds,
161
+ getConnection: getConnectionChecked,
162
+ });
163
+ // A broken body is terminal on a record the handler acks, so the
164
+ // handler-outcome series never sees it.
165
+ if (brokenMessageIds.length > 0) {
166
+ metrics.imapFailure("SYNC_MESSAGE_BODY_BROKEN", "other", brokenMessageIds.length);
167
+ }
168
+ })()
169
+ .catch((error) => {
170
+ // Expected pause (epic #1281 invariant 3), not a fault: ack and
171
+ // skip rather than propagating into SQS retry/DLQ. The read stays
172
+ // served from whatever is already stored; the fetch resumes once
173
+ // the mailbox returns to normal.
174
+ if (error instanceof MailboxCursorPausedError) {
175
+ log.info({ accountId, mailboxId, cursorState: error.state }, "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound body sync");
176
+ return;
177
+ }
178
+ throw error;
179
+ })
180
+ .finally(() => borrowed.release());
181
+ });
182
+ };
@@ -0,0 +1,29 @@
1
+ import type { AccountItem, IMessageFlagPushRepository } from "@remit/data-ports";
2
+ import type { Logger } from "@remit/logger";
3
+ import { type SyncedMessage } from "@remit/mailbox-service";
4
+ import type { ProcessContext } from "../context.js";
5
+ import { type EmitEvent } from "../emit.js";
6
+ import type { SyncMessagesEvent } from "../events.js";
7
+ /** Slice synced messages into body-sync batches, each one ranged FETCH. */
8
+ export declare const batchSyncedMessages: (syncedMessages: SyncedMessage[], batchSize: number) => SyncedMessage[][];
9
+ export declare const syncMessages: (event: SyncMessagesEvent, ctx: ProcessContext) => Promise<void>;
10
+ /**
11
+ * Periodic per-mailbox drain point for pending flag-push markers (issue
12
+ * #1273, epic #1281). The SQS enqueue in `FlagPushService.flip` is only a
13
+ * wake-up hint and may fail freely — a marker left `state: "pending"` means
14
+ * that hint never landed (queue down, or a crash between the local write and
15
+ * the enqueue). This periodic tick — which already runs per mailbox on a
16
+ * schedule regardless of user activity — re-arms every such marker with a
17
+ * fresh `FLAG_PUSH` event, closing the gap without the caller ever having to
18
+ * retry.
19
+ *
20
+ * Markers already `queued`/`processing` are left alone: a live SQS message
21
+ * (or the single-marker handler currently running) already owns driving them
22
+ * forward, and re-arming them too would just duplicate work.
23
+ *
24
+ * A re-arm failure (the SQS send itself) is caught per-marker and logged
25
+ * loudly — it must never fail the surrounding SYNC_MESSAGES batch, which is
26
+ * unrelated message-header sync work. The marker stays durable regardless;
27
+ * the next periodic tick tries again.
28
+ */
29
+ export declare const drainPendingFlagPushes: (flagPushMarkerService: IMessageFlagPushRepository, account: AccountItem, mailboxId: string, log: Logger, emit: EmitEvent) => Promise<void>;
@@ -0,0 +1,285 @@
1
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
+ import { SyncPhase } from "@remit/domain-enums";
3
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
4
+ import { isMailboxMutationInFlight, MailConnectionError, MessageSyncService, QuarantineService, SyncWarningService, } from "@remit/mailbox-service";
5
+ import pMap from "p-map";
6
+ import { isAccountDeleted, isMailSyncDisabled, isUnsyncableHost, } from "../account-check.js";
7
+ import { emitFrom, SYNC_MESSAGES_PENDING } from "../emit.js";
8
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
9
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
10
+ const EVENT_EMIT_CONCURRENCY = 10;
11
+ const MESSAGE_BATCH_SIZE = 200;
12
+ /** Slice synced messages into body-sync batches, each one ranged FETCH. */
13
+ export const batchSyncedMessages = (syncedMessages, batchSize) => {
14
+ const batches = [];
15
+ for (let i = 0; i < syncedMessages.length; i += batchSize) {
16
+ batches.push(syncedMessages.slice(i, i + batchSize));
17
+ }
18
+ return batches;
19
+ };
20
+ export const syncMessages = async (event, ctx) => {
21
+ const { log, pool, metrics, config } = ctx;
22
+ log.info({
23
+ event: event.type,
24
+ accountId: event.accountId,
25
+ mailboxId: event.mailboxId,
26
+ }, "Handling event");
27
+ const { account: accountService, mailbox: mailboxService, message: messageService, envelope: envelopeService, address: addressService, threadMessage: threadMessageService, mailboxLock: mailboxLockService, mailboxSpecialUse: mailboxSpecialUseService, quarantine: quarantineService, syncWarning: syncWarningRepository, flagPush: flagPushMarkerService, messageFlag: messageFlagService, secrets, } = ctx.client;
28
+ await mailboxLockService.releaseLock(event.accountId, event.mailboxId, SYNC_MESSAGES_PENDING, event.eventId);
29
+ // A deleted account never has its DDB row purged in lockstep with the queued
30
+ // SYNC_MESSAGES triggers, so a trigger can outlive its account. The lookup
31
+ // then returns null (or throws a named NotFoundError), which can never succeed
32
+ // on retry — it would retry to maxReceiveCount and poison the messages DLQ
33
+ // forever (issue #911). Treat a missing account as terminal: ack the event
34
+ // with a WARN. Genuinely transient failures (throttle, network) surface as
35
+ // other errors and still propagate to be retried.
36
+ let account;
37
+ const rawAccount = await accountService.get(event.accountId).catch((err) => {
38
+ if (isNotFoundError(err))
39
+ return null;
40
+ throw err;
41
+ });
42
+ if (!rawAccount) {
43
+ log.warn({
44
+ accountId: event.accountId,
45
+ mailboxId: event.mailboxId,
46
+ eventId: event.eventId,
47
+ }, "Skipping SYNC_MESSAGES: account no longer exists");
48
+ return;
49
+ }
50
+ account = rawAccount;
51
+ if (isAccountDeleted(account, log)) {
52
+ return;
53
+ }
54
+ if (isMailSyncDisabled(account, log)) {
55
+ return;
56
+ }
57
+ // A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
58
+ // sync attempt would retry and dead-letter forever. Skip cleanly — ack the
59
+ // event without connecting or throwing.
60
+ if (isUnsyncableHost(account, log.child({ mailboxId: event.mailboxId }))) {
61
+ return;
62
+ }
63
+ // A SYNC_MESSAGES trigger can address a folder a mutation still owns: the
64
+ // fan-out enqueues one for a folder whose own create has not landed yet, a
65
+ // `hasMore` next-batch or a periodic tick outlives a folder the user
66
+ // deleted, and a rename in flight leaves the folder on the server but under
67
+ // the worker that is moving it. Such an event can never succeed on retry,
68
+ // and the account's per-group FIFO ordering lets that one poison message
69
+ // stall the whole account's message pipeline for the full visibility window
70
+ // (issue #287, #339). A folder with a create, rename or delete outstanding
71
+ // is an expected terminal outcome, not an infra failure: ack the event with
72
+ // a WARN. The rename case is conservative rather than necessary — syncing it
73
+ // would be safe — and keeps the guard a single expression (D12).
74
+ if (await isMailboxMutationInFlight(mailboxService, event.accountId, event.mailboxId)) {
75
+ log.warn({
76
+ accountId: event.accountId,
77
+ mailboxId: event.mailboxId,
78
+ eventId: event.eventId,
79
+ event: event.type,
80
+ }, "Skipping SYNC_MESSAGES: a folder mutation is in flight");
81
+ return;
82
+ }
83
+ // withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, resolve
84
+ // credentials, flip on terminal auth failure, rethrow transient). The
85
+ // mailbox lock and the actual sync run inside the wrapper callback.
86
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
87
+ // Acquire lock before starting sync operation
88
+ const { executed } = await mailboxLockService.withMailboxLock(event.mailboxId, "SYNC_MESSAGES", event.accountId, async () => {
89
+ try {
90
+ await syncMailboxMessages(event, account, pool.borrow(account, credentials), {
91
+ accountService,
92
+ mailboxService,
93
+ messageService,
94
+ envelopeService,
95
+ addressService,
96
+ threadMessageService,
97
+ mailboxSpecialUseService,
98
+ quarantineService,
99
+ syncWarningRepository,
100
+ flagPushMarkerService,
101
+ messageFlagService,
102
+ emit: emitFrom(ctx),
103
+ metrics,
104
+ config,
105
+ }, log);
106
+ }
107
+ catch (err) {
108
+ // Auth failures are handled by the wrapper — rethrow untouched.
109
+ if (err instanceof RefreshTokenError ||
110
+ (err instanceof MailConnectionError && err.kind === "auth")) {
111
+ throw err;
112
+ }
113
+ // The guard above is a check-then-act: a delete asked for while
114
+ // this round was in flight lands inside it, and the round then
115
+ // fails somewhere deeper — on the row (NotFoundError from the
116
+ // service's own re-read) or on the server (the SELECTed folder
117
+ // vanished, so the next command runs against no mailbox). Both are
118
+ // the same expected outcome as an event that arrived after the
119
+ // delete, so they resolve the same way rather than head-of-line
120
+ // blocking the account's FIFO group (issue #339). Recording an
121
+ // error phase is skipped too: the account is healthy, one of its
122
+ // folders is not there.
123
+ //
124
+ // This read only classifies the failure already in hand, so it must
125
+ // never become the failure that is reported. A read that cannot answer
126
+ // leaves the round on the loud path below, with the real error and the
127
+ // state write that goes with it intact.
128
+ const folderIsGone = await isMailboxMutationInFlight(mailboxService, event.accountId, event.mailboxId).catch(() => false);
129
+ if (folderIsGone) {
130
+ log.warn({
131
+ accountId: event.accountId,
132
+ mailboxId: event.mailboxId,
133
+ eventId: event.eventId,
134
+ event: event.type,
135
+ error: err instanceof Error ? err.message : String(err),
136
+ }, "Resolving SYNC_MESSAGES: the folder left the server while the sync was in flight");
137
+ return;
138
+ }
139
+ // Record the terminal error phase before crashing (let-it-crash:
140
+ // record state, then rethrow so the event is retried/DLQ'd).
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ await accountService.update(event.accountId, {
143
+ syncPhase: SyncPhase.error,
144
+ lastError: message,
145
+ });
146
+ throw err;
147
+ }
148
+ });
149
+ if (!executed) {
150
+ log.info({ mailboxId: event.mailboxId }, "Sync already in progress, skipping");
151
+ }
152
+ });
153
+ };
154
+ // Bounds concurrent SQS sends while re-arming stuck flag-push markers —
155
+ // markers are expected to be few, but never unbounded (coding-standards.md).
156
+ const FLAG_PUSH_DRAIN_CONCURRENCY = 5;
157
+ /**
158
+ * Periodic per-mailbox drain point for pending flag-push markers (issue
159
+ * #1273, epic #1281). The SQS enqueue in `FlagPushService.flip` is only a
160
+ * wake-up hint and may fail freely — a marker left `state: "pending"` means
161
+ * that hint never landed (queue down, or a crash between the local write and
162
+ * the enqueue). This periodic tick — which already runs per mailbox on a
163
+ * schedule regardless of user activity — re-arms every such marker with a
164
+ * fresh `FLAG_PUSH` event, closing the gap without the caller ever having to
165
+ * retry.
166
+ *
167
+ * Markers already `queued`/`processing` are left alone: a live SQS message
168
+ * (or the single-marker handler currently running) already owns driving them
169
+ * forward, and re-arming them too would just duplicate work.
170
+ *
171
+ * A re-arm failure (the SQS send itself) is caught per-marker and logged
172
+ * loudly — it must never fail the surrounding SYNC_MESSAGES batch, which is
173
+ * unrelated message-header sync work. The marker stays durable regardless;
174
+ * the next periodic tick tries again.
175
+ */
176
+ export const drainPendingFlagPushes = async (flagPushMarkerService, account, mailboxId, log, emit) => {
177
+ const markers = await flagPushMarkerService.listByMailboxId(mailboxId);
178
+ const stuck = markers.filter((marker) => marker.state === "pending");
179
+ if (stuck.length === 0)
180
+ return;
181
+ log.info({ mailboxId, count: stuck.length }, "Periodic sync tick found flag-push marker(s) stuck before their wake-up hint; re-arming");
182
+ await pMap(stuck, (marker) => {
183
+ const rearmEvent = {
184
+ type: "FLAG_PUSH",
185
+ accountId: account.accountId,
186
+ accountConfigId: account.accountConfigId,
187
+ messageId: marker.messageId,
188
+ flagName: marker.flagName,
189
+ };
190
+ return emit(rearmEvent).catch((error) => {
191
+ log.error({
192
+ alert: "flag_push_drain_rearm_failed",
193
+ mailboxId,
194
+ messageId: marker.messageId,
195
+ flagName: marker.flagName,
196
+ error: error instanceof Error ? error.message : String(error),
197
+ }, "Failed to re-arm a stuck pending flag-push marker during the periodic drain");
198
+ });
199
+ }, { concurrency: FLAG_PUSH_DRAIN_CONCURRENCY });
200
+ };
201
+ /**
202
+ * Sync one batch of messages for a mailbox. Runs under the mailbox lock.
203
+ */
204
+ const syncMailboxMessages = async (event, account, borrowed, deps, log) => {
205
+ const { accountService, mailboxService, messageService, envelopeService, addressService, threadMessageService, mailboxSpecialUseService, quarantineService, syncWarningRepository, flagPushMarkerService, messageFlagService, emit, metrics, config, } = deps;
206
+ // Get the mailbox - it must exist (should have been created by mailbox sync)
207
+ const mailbox = await mailboxService.get(account.accountId, event.mailboxId);
208
+ const mailboxId = mailbox.mailboxId;
209
+ // Periodic drain (issue #1273): independent of the IMAP sync below — no
210
+ // connection needed, just an SQS re-arm — so it runs regardless of this
211
+ // round's sync outcome.
212
+ await drainPendingFlagPushes(flagPushMarkerService, account, mailboxId, log, emit);
213
+ const isInbox = mailbox.fullPath.toUpperCase() === "INBOX";
214
+ const result = await (async () => {
215
+ const connection = await borrowed.getConnection();
216
+ const syncService = new MessageSyncService(connection, mailboxService, mailboxSpecialUseService, messageService, envelopeService, addressService, threadMessageService, new SyncWarningService(syncWarningRepository), log, flagPushMarkerService, messageFlagService, new QuarantineService(quarantineService, mailboxSpecialUseService, config.workerVersion, log));
217
+ return syncService.syncMessages(mailboxId, account.accountId, account.accountConfigId, MESSAGE_BATCH_SIZE);
218
+ })().finally(() => borrowed.release());
219
+ log.info({
220
+ syncedCount: result.syncedCount,
221
+ hasMore: result.hasMore,
222
+ remainingCount: result.remainingCount,
223
+ }, "Message sync batch complete");
224
+ // A stalled cursor is the one failure on this path that produces no error:
225
+ // unapplicable messages are caught and held back, so the round returns
226
+ // normally, the SQS record is deleted, and neither redrive nor the DLQ ever
227
+ // engages. Without this counter the mailbox stops syncing behind a worker
228
+ // that looks healthy. Per-mailbox detail is in the accompanying ERROR log.
229
+ if (result.cursorStalled) {
230
+ metrics.imapFailure("SYNC_MESSAGES_CURSOR_STALLED", "other", 1);
231
+ }
232
+ // Emit body sync events for the messages we just synced. Each event carries
233
+ // messageId+uid pairs so the consumer issues one ranged FETCH per batch
234
+ // without re-resolving UIDs. messageIds stays populated for backward compat.
235
+ if (result.syncedMessages.length > 0) {
236
+ const batches = batchSyncedMessages(result.syncedMessages, config.bodyBatchSize);
237
+ log.info({ count: result.syncedMessages.length, batches: batches.length }, "Emitting SYNC_MESSAGE_BODY events");
238
+ await pMap(batches, (batch) => {
239
+ const bodyEvent = {
240
+ type: "SYNC_MESSAGE_BODY",
241
+ accountId: event.accountId,
242
+ mailboxId,
243
+ messageIds: batch.map((m) => m.messageId),
244
+ messages: batch.map((m) => ({ messageId: m.messageId, uid: m.uid })),
245
+ };
246
+ return emit(bodyEvent);
247
+ }, { concurrency: EVENT_EMIT_CONCURRENCY });
248
+ }
249
+ // If there are more messages to sync, emit another SYNC_MESSAGES event
250
+ if (result.hasMore) {
251
+ log.info({ remainingCount: result.remainingCount }, "Emitting SYNC_MESSAGES event for next batch");
252
+ const nextSyncEvent = {
253
+ type: "SYNC_MESSAGES",
254
+ accountId: event.accountId,
255
+ mailboxId,
256
+ };
257
+ await emit(nextSyncEvent);
258
+ return;
259
+ }
260
+ // Mailbox drained. Record the per-mailbox completion marker (used by the
261
+ // sync-status endpoint to derive the per-mailbox phase), and count it
262
+ // towards mailboxCountSynced — but only once per sync round, so that
263
+ // duplicate / no-op SYNC_MESSAGES completions don't inflate the counter.
264
+ // The check-then-write is safe: the mailbox lock serializes completions
265
+ // per mailbox, and `mailbox` was read under the lock.
266
+ const roundStartedAt = account.lastSyncAt ?? 0;
267
+ const previousCompletedAt = mailbox.initialSyncCompletedAt ?? 0;
268
+ const firstCompletionThisRound = previousCompletedAt === 0 || previousCompletedAt < roundStartedAt;
269
+ await mailboxService.update(account.accountId, mailboxId, {
270
+ initialSyncCompletedAt: Date.now(),
271
+ });
272
+ if (!firstCompletionThisRound) {
273
+ log.info({ mailboxId }, "Mailbox already counted as synced this round, skipping increment");
274
+ return;
275
+ }
276
+ const currentAccount = await accountService.get(event.accountId);
277
+ if (isInbox && currentAccount.syncPhase === SyncPhase.syncing_inbox) {
278
+ // INBOX is drained; advance to syncing_others
279
+ await accountService.update(event.accountId, {
280
+ syncPhase: SyncPhase.syncing_others,
281
+ });
282
+ }
283
+ // Atomically increment mailboxCountSynced; transitions to complete when all done
284
+ await accountService.incrementMailboxSynced(event.accountId);
285
+ };
@@ -0,0 +1,9 @@
1
+ import type { SQSHandler } from "aws-lambda";
2
+ /**
3
+ * Parse SQS's `ApproximateReceiveCount` record attribute (1 on first
4
+ * delivery). Missing/malformed defaults to 1 so a record with no attribute
5
+ * (e.g. an older local harness) is treated as a first attempt rather than
6
+ * skipping straight to retry-exhaustion handling.
7
+ */
8
+ export declare const parseReceiveCount: (value: string | undefined) => number;
9
+ export declare const handler: SQSHandler;
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ import { createLogger } from "@remit/logger";
2
+ import { queueNameFromEventSource, recordImapFailure, recordQueueEvent, } from "@remit/logger-lambda/metrics";
3
+ import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
4
+ import { env } from "expect-env";
5
+ import { isBodySyncEnabled } from "./body-sync-gate.js";
6
+ import { imapFailureKind } from "./failure-kind.js";
7
+ import { lambdaProcessContext } from "./lambda-context.js";
8
+ import { processEvent } from "./processor.js";
9
+ installNodeLogger();
10
+ const log = createLogger();
11
+ const bodySyncParameterName = env.BODY_SYNC_ENABLED_PARAMETER_NAME;
12
+ const isPausedBodySync = async (event) => event.type === "SYNC_MESSAGE_BODY" &&
13
+ !(await isBodySyncEnabled(bodySyncParameterName, log));
14
+ /**
15
+ * Parse SQS's `ApproximateReceiveCount` record attribute (1 on first
16
+ * delivery). Missing/malformed defaults to 1 so a record with no attribute
17
+ * (e.g. an older local harness) is treated as a first attempt rather than
18
+ * skipping straight to retry-exhaustion handling.
19
+ */
20
+ export const parseReceiveCount = (value) => {
21
+ const parsed = Number.parseInt(value ?? "1", 10);
22
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
23
+ };
24
+ export const handler = withTelemetry(async (event) => {
25
+ const batchItemFailures = [];
26
+ const ctx = await lambdaProcessContext(log);
27
+ for (const record of event.Records) {
28
+ const imapEvent = JSON.parse(record.body);
29
+ const receiveCount = parseReceiveCount(record.attributes?.ApproximateReceiveCount);
30
+ log.info({
31
+ eventType: imapEvent.type,
32
+ eventId: "eventId" in imapEvent ? imapEvent.eventId : undefined,
33
+ receiveCount,
34
+ }, "Processing event");
35
+ if (await isPausedBodySync(imapEvent)) {
36
+ log.info({ eventType: imapEvent.type, messageId: record.messageId }, "Body sync paused via SSM toggle, acking and skipping");
37
+ continue;
38
+ }
39
+ const queue = queueNameFromEventSource(record.eventSourceARN);
40
+ const opStart = Date.now();
41
+ const failed = await processEvent(imapEvent, ctx, receiveCount)
42
+ .then(() => {
43
+ recordQueueEvent({
44
+ queue,
45
+ eventType: imapEvent.type,
46
+ outcome: "success",
47
+ durationMs: Date.now() - opStart,
48
+ });
49
+ return false;
50
+ })
51
+ .catch((error) => {
52
+ log.error({ error, messageId: record.messageId }, "Event processing failed");
53
+ recordQueueEvent({
54
+ queue,
55
+ eventType: imapEvent.type,
56
+ outcome: "failure",
57
+ durationMs: Date.now() - opStart,
58
+ });
59
+ recordImapFailure(imapEvent.type, imapFailureKind(error));
60
+ return true;
61
+ });
62
+ if (failed) {
63
+ batchItemFailures.push({ itemIdentifier: record.messageId });
64
+ }
65
+ }
66
+ return { batchItemFailures };
67
+ });
@@ -0,0 +1,8 @@
1
+ import type { IMailboxLockRepository } from "@remit/data-ports";
2
+ import type { Logger } from "@remit/logger";
3
+ import type { ProcessContext, WorkerMetrics } from "./context.js";
4
+ import { type EmitEvent } from "./emit.js";
5
+ import type { WorkerQueues } from "./queues.js";
6
+ export declare const lambdaMetrics: WorkerMetrics;
7
+ export declare const lambdaEmitEvent: (queues: WorkerQueues, mailboxLock: IMailboxLockRepository) => EmitEvent;
8
+ export declare const lambdaProcessContext: (log: Logger) => Promise<ProcessContext>;
@@ -0,0 +1,33 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import { recordImapFailure } from "@remit/logger-lambda/metrics";
3
+ import { createMailOAuthService, microsoftProviderConfig, } from "@remit/mail-oauth-service";
4
+ import { env } from "expect-env";
5
+ import { imapWorkerConfigFromEnv, poolOptionsFromEnv } from "./config-env.js";
6
+ import { createConnectionPool } from "./connection-pool.js";
7
+ import { createConnectionScopeWithCredentials } from "./connection-scope.js";
8
+ import { createEmitEvent } from "./emit.js";
9
+ import { s3AccountObjects } from "./s3-account-objects.js";
10
+ import { sqsWorkerQueues, workerQueueUrlsFromEnv } from "./sqs-queues.js";
11
+ export const lambdaMetrics = { imapFailure: recordImapFailure };
12
+ export const lambdaEmitEvent = (queues, mailboxLock) => createEmitEvent({ queues, mailboxLock, metrics: lambdaMetrics });
13
+ const buildContext = async (log) => ({
14
+ client: await getClient(),
15
+ queues: sqsWorkerQueues(workerQueueUrlsFromEnv()),
16
+ pool: createConnectionPool(poolOptionsFromEnv(process.env), createConnectionScopeWithCredentials),
17
+ metrics: lambdaMetrics,
18
+ config: imapWorkerConfigFromEnv(process.env),
19
+ log,
20
+ oauth: createMailOAuthService(microsoftProviderConfig({
21
+ clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
22
+ clientSecret: process.env.MSOAUTH_CLIENT_SECRET ?? "",
23
+ overrides: process.env.MSOAUTH_TOKEN_ENDPOINT
24
+ ? { tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT }
25
+ : undefined,
26
+ })),
27
+ accountObjects: s3AccountObjects(() => env.S3_BUCKET_NAME),
28
+ });
29
+ let context;
30
+ export const lambdaProcessContext = (log) => {
31
+ context ??= buildContext(log);
32
+ return context;
33
+ };
@@ -0,0 +1,9 @@
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ export type MailboxRow = {
3
+ kind: "present";
4
+ mailbox: MailboxItem;
5
+ } | {
6
+ kind: "gone";
7
+ mailboxId: string;
8
+ };
9
+ export declare const findMailboxRow: (mailboxService: Pick<IMailboxRepository, "get">, accountId: string, mailboxId: string) => Promise<MailboxRow>;
@@ -0,0 +1,6 @@
1
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
+ export const findMailboxRow = (mailboxService, accountId, mailboxId) => mailboxService.get(accountId, mailboxId).then((mailbox) => mailbox ? { kind: "present", mailbox } : { kind: "gone", mailboxId }, (error) => {
3
+ if (isNotFoundError(error))
4
+ return { kind: "gone", mailboxId };
5
+ throw error;
6
+ });
@@ -0,0 +1 @@
1
+ export {};