@remit/imap-worker 0.0.90 → 0.0.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/account-check.d.ts +27 -0
  2. package/dist/account-check.js +70 -0
  3. package/dist/body-sync-gate.d.ts +12 -0
  4. package/dist/body-sync-gate.js +53 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/config-env.d.ts +5 -0
  8. package/dist/config-env.js +37 -0
  9. package/dist/connection-pool.d.ts +22 -0
  10. package/dist/connection-pool.js +97 -0
  11. package/dist/connection-scope.d.ts +8 -0
  12. package/dist/connection-scope.js +10 -0
  13. package/dist/context.d.ts +46 -0
  14. package/dist/context.js +15 -0
  15. package/dist/e2e-processor-shim.d.ts +2 -0
  16. package/dist/e2e-processor-shim.js +181 -0
  17. package/dist/emit.d.ts +15 -0
  18. package/dist/emit.js +76 -0
  19. package/dist/events.d.ts +203 -0
  20. package/dist/events.js +1 -0
  21. package/dist/failure-kind.d.ts +10 -0
  22. package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
  23. package/dist/handlers/append-sent-message.d.ts +3 -0
  24. package/dist/handlers/append-sent-message.js +152 -0
  25. package/dist/handlers/delete-account-objects.d.ts +3 -0
  26. package/dist/handlers/delete-account-objects.js +35 -0
  27. package/dist/handlers/empty-trash.d.ts +14 -0
  28. package/dist/handlers/empty-trash.js +217 -0
  29. package/dist/handlers/flag-push.d.ts +38 -0
  30. package/dist/handlers/flag-push.js +195 -0
  31. package/dist/handlers/imported-folder.d.ts +4 -0
  32. package/dist/handlers/imported-folder.js +23 -0
  33. package/dist/handlers/mailbox-management.d.ts +6 -0
  34. package/dist/handlers/mailbox-management.js +294 -0
  35. package/dist/handlers/mailbox-sync-order.d.ts +21 -0
  36. package/dist/handlers/mailbox-sync-order.js +50 -0
  37. package/dist/handlers/message-copy.d.ts +40 -0
  38. package/dist/handlers/message-copy.js +371 -0
  39. package/dist/handlers/message-delete-terminal.d.ts +60 -0
  40. package/dist/handlers/message-delete-terminal.js +77 -0
  41. package/dist/handlers/message-delete.d.ts +57 -0
  42. package/dist/handlers/message-delete.js +520 -0
  43. package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
  44. package/dist/handlers/message-move-terminal.js +94 -0
  45. package/dist/handlers/message-move.d.ts +146 -0
  46. package/dist/handlers/message-move.js +490 -0
  47. package/dist/handlers/placement-move-push.d.ts +62 -0
  48. package/dist/handlers/placement-move-push.js +307 -0
  49. package/dist/handlers/probe-message-id.d.ts +2 -0
  50. package/dist/handlers/probe-message-id.js +6 -0
  51. package/dist/handlers/sync-mailboxes.d.ts +45 -0
  52. package/dist/handlers/sync-mailboxes.js +233 -0
  53. package/dist/handlers/sync-message-body.d.ts +23 -0
  54. package/dist/handlers/sync-message-body.js +182 -0
  55. package/dist/handlers/sync-messages.d.ts +29 -0
  56. package/dist/handlers/sync-messages.js +285 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.js +67 -0
  59. package/dist/lambda-context.d.ts +8 -0
  60. package/dist/lambda-context.js +33 -0
  61. package/dist/mailbox-row.d.ts +9 -0
  62. package/dist/mailbox-row.js +6 -0
  63. package/dist/poller.d.ts +1 -0
  64. package/dist/poller.js +51 -0
  65. package/dist/process.d.ts +6 -0
  66. package/dist/process.js +3 -0
  67. package/dist/processor.d.ts +3 -0
  68. package/dist/processor.js +44 -0
  69. package/dist/queues.d.ts +10 -0
  70. package/dist/queues.js +1 -0
  71. package/dist/s3-account-objects.d.ts +3 -0
  72. package/dist/s3-account-objects.js +26 -0
  73. package/dist/scheduler/attachment-sweep.d.ts +11 -0
  74. package/dist/scheduler/attachment-sweep.js +23 -0
  75. package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +27 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
  79. package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
  80. package/dist/scheduler/decide-due.js +17 -0
  81. package/dist/scheduler/handler.d.ts +21 -0
  82. package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +25 -42
  83. package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
  84. package/dist/scheduler/loop.js +8 -0
  85. package/dist/scheduler/run-tick.d.ts +62 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +57 -78
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/sqs-queues.d.ts +4 -0
  92. package/dist/sqs-queues.js +22 -0
  93. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  94. package/dist/with-oauth-lifecycle-deps.js +17 -0
  95. package/dist/with-oauth-lifecycle.d.ts +56 -0
  96. package/dist/with-oauth-lifecycle.js +92 -0
  97. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  98. package/dist/worker-version.js +12 -0
  99. package/package.json +75 -69
  100. package/src/account-check.test.ts +0 -149
  101. package/src/account-check.ts +0 -101
  102. package/src/body-sync-gate.test.ts +0 -185
  103. package/src/body-sync-gate.ts +0 -86
  104. package/src/cli.ts +0 -218
  105. package/src/connection-scope.test.ts +0 -266
  106. package/src/connection-scope.ts +0 -314
  107. package/src/credential-less-sync-fence.test.ts +0 -152
  108. package/src/e2e-processor-shim.ts +0 -232
  109. package/src/emit.test.ts +0 -145
  110. package/src/emit.ts +0 -115
  111. package/src/events.ts +0 -252
  112. package/src/failure-kind.test.ts +0 -42
  113. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  114. package/src/handlers/append-sent-message.ts +0 -264
  115. package/src/handlers/delete-account-objects.test.ts +0 -126
  116. package/src/handlers/delete-account-objects.ts +0 -96
  117. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  118. package/src/handlers/empty-trash.ts +0 -352
  119. package/src/handlers/flag-push.ts +0 -285
  120. package/src/handlers/imported-folder.test.ts +0 -63
  121. package/src/handlers/imported-folder.ts +0 -38
  122. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  123. package/src/handlers/mailbox-management.ts +0 -486
  124. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  125. package/src/handlers/mailbox-sync-order.ts +0 -65
  126. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  127. package/src/handlers/message-copy.ts +0 -569
  128. package/src/handlers/message-delete-terminal.ts +0 -147
  129. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  130. package/src/handlers/message-delete.ts +0 -888
  131. package/src/handlers/message-move.sqlite.test.ts +0 -271
  132. package/src/handlers/message-move.test.ts +0 -216
  133. package/src/handlers/message-move.ts +0 -755
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -478
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  137. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  138. package/src/handlers/probe-message-id.ts +0 -15
  139. package/src/handlers/sync-mailboxes.test.ts +0 -183
  140. package/src/handlers/sync-mailboxes.ts +0 -425
  141. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  142. package/src/handlers/sync-message-body.test.ts +0 -150
  143. package/src/handlers/sync-message-body.ts +0 -366
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  145. package/src/handlers/sync-messages.ts +0 -537
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -82
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -165
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -62
  153. package/src/queues.ts +0 -40
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  155. package/src/scheduler/attachment-sweep.ts +0 -46
  156. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  157. package/src/scheduler/calendar-subscriptions.ts +0 -103
  158. package/src/scheduler/config.test.ts +0 -40
  159. package/src/scheduler/decide-due.test.ts +0 -44
  160. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  161. package/src/scheduler/loop.test.ts +0 -143
  162. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  163. package/src/scheduler/run-tick.ts +0 -281
  164. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  165. package/src/stranded-placement.sqlite.test.ts +0 -799
  166. package/src/test-helpers/test-world.ts +0 -425
  167. package/src/test-helpers/worker-queues.ts +0 -11
  168. package/src/thread-join.sqlite.test.ts +0 -106
  169. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  170. package/src/with-oauth-lifecycle-deps.ts +0 -71
  171. package/src/with-oauth-lifecycle.ts +0 -158
  172. package/tsconfig.json +0 -8
@@ -1,537 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import type {
3
- AccountItem,
4
- IAccountRepository,
5
- IAddressRepository,
6
- IEnvelopeRepository,
7
- IMailboxRepository,
8
- IMailboxSpecialUseRepository,
9
- IMessageFlagPushRepository,
10
- IMessageFlagRepository,
11
- IMessageRepository,
12
- IQuarantineRepository,
13
- ISyncWarningRepository,
14
- IThreadMessageRepository,
15
- } from "@remit/data-ports";
16
- import { isNotFoundError } from "@remit/data-ports/errors";
17
- import { SyncPhase } from "@remit/domain-enums";
18
- import type { Logger } from "@remit/logger";
19
- import { recordImapFailure } from "@remit/logger-lambda/metrics";
20
- import { RefreshTokenError } from "@remit/mail-oauth-service";
21
- import {
22
- createManagedConnectionFactory,
23
- isMailboxMutationInFlight,
24
- MailConnectionError,
25
- type MailCredentials,
26
- type ManagedConnectionFactory,
27
- MessageSyncService,
28
- QuarantineService,
29
- type SyncedMessage,
30
- SyncWarningService,
31
- } from "@remit/mailbox-service";
32
- import pMap from "p-map";
33
- import {
34
- isAccountDeleted,
35
- isMailSyncDisabled,
36
- isUnsyncableHost,
37
- } from "../account-check.js";
38
- import {
39
- createEmitEvent,
40
- type EmitEvent,
41
- SYNC_MESSAGES_PENDING,
42
- } from "../emit.js";
43
- import type {
44
- FlagPushEvent,
45
- SyncMessageBodyEvent,
46
- SyncMessagesEvent,
47
- } from "../events.js";
48
- import type { WorkerQueues } from "../queues.js";
49
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
50
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
51
- import { workerVersion } from "../worker-version.js";
52
-
53
- // One SYNC_MESSAGE_BODY event maps to one ranged UID FETCH on the consumer.
54
- export const BODY_BATCH_SIZE = 200;
55
- const EVENT_EMIT_CONCURRENCY = 10;
56
- const MESSAGE_BATCH_SIZE = 200;
57
-
58
- /** Slice synced messages into body-sync batches, each one ranged FETCH. */
59
- export const batchSyncedMessages = (
60
- syncedMessages: SyncedMessage[],
61
- batchSize: number = BODY_BATCH_SIZE,
62
- ): SyncedMessage[][] => {
63
- const batches: SyncedMessage[][] = [];
64
- for (let i = 0; i < syncedMessages.length; i += batchSize) {
65
- batches.push(syncedMessages.slice(i, i + batchSize));
66
- }
67
- return batches;
68
- };
69
-
70
- export const managedConnectionFor = (
71
- account: Pick<AccountItem, "username" | "imapHost" | "imapPort" | "imapTls">,
72
- credentials: MailCredentials,
73
- ): ManagedConnectionFactory =>
74
- createManagedConnectionFactory({
75
- user: account.username,
76
- credentials,
77
- host: account.imapHost,
78
- port: account.imapPort,
79
- tls: account.imapTls,
80
- });
81
-
82
- export const syncMessages = async (
83
- event: SyncMessagesEvent,
84
- log: Logger,
85
- queues: WorkerQueues,
86
- createConnectionFactory = managedConnectionFor,
87
- ): Promise<void> => {
88
- log.info(
89
- {
90
- event: event.type,
91
- accountId: event.accountId,
92
- mailboxId: event.mailboxId,
93
- },
94
- "Handling event",
95
- );
96
-
97
- const {
98
- account: accountService,
99
- mailbox: mailboxService,
100
- message: messageService,
101
- envelope: envelopeService,
102
- address: addressService,
103
- threadMessage: threadMessageService,
104
- mailboxLock: mailboxLockService,
105
- mailboxSpecialUse: mailboxSpecialUseService,
106
- quarantine: quarantineService,
107
- syncWarning: syncWarningRepository,
108
- flagPush: flagPushMarkerService,
109
- messageFlag: messageFlagService,
110
- secrets,
111
- } = await getClient();
112
-
113
- await mailboxLockService.releaseLock(
114
- event.accountId,
115
- event.mailboxId,
116
- SYNC_MESSAGES_PENDING,
117
- event.eventId,
118
- );
119
-
120
- // A deleted account never has its DDB row purged in lockstep with the queued
121
- // SYNC_MESSAGES triggers, so a trigger can outlive its account. The lookup
122
- // then returns null (or throws a named NotFoundError), which can never succeed
123
- // on retry — it would retry to maxReceiveCount and poison the messages DLQ
124
- // forever (issue #911). Treat a missing account as terminal: ack the event
125
- // with a WARN. Genuinely transient failures (throttle, network) surface as
126
- // other errors and still propagate to be retried.
127
- let account: AccountItem;
128
- const rawAccount = await accountService.get(event.accountId).catch((err) => {
129
- if (isNotFoundError(err)) return null;
130
- throw err;
131
- });
132
- if (!rawAccount) {
133
- log.warn(
134
- {
135
- accountId: event.accountId,
136
- mailboxId: event.mailboxId,
137
- eventId: event.eventId,
138
- },
139
- "Skipping SYNC_MESSAGES: account no longer exists",
140
- );
141
- return;
142
- }
143
- account = rawAccount;
144
-
145
- if (isAccountDeleted(account, log)) {
146
- return;
147
- }
148
-
149
- if (isMailSyncDisabled(account, log)) {
150
- return;
151
- }
152
-
153
- // A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
154
- // sync attempt would retry and dead-letter forever. Skip cleanly — ack the
155
- // event without connecting or throwing.
156
- if (isUnsyncableHost(account, log.child({ mailboxId: event.mailboxId }))) {
157
- return;
158
- }
159
-
160
- // A SYNC_MESSAGES trigger can address a folder a mutation still owns: the
161
- // fan-out enqueues one for a folder whose own create has not landed yet, a
162
- // `hasMore` next-batch or a periodic tick outlives a folder the user
163
- // deleted, and a rename in flight leaves the folder on the server but under
164
- // the worker that is moving it. Such an event can never succeed on retry,
165
- // and the account's per-group FIFO ordering lets that one poison message
166
- // stall the whole account's message pipeline for the full visibility window
167
- // (issue #287, #339). A folder with a create, rename or delete outstanding
168
- // is an expected terminal outcome, not an infra failure: ack the event with
169
- // a WARN. The rename case is conservative rather than necessary — syncing it
170
- // would be safe — and keeps the guard a single expression (D12).
171
- if (
172
- await isMailboxMutationInFlight(
173
- mailboxService,
174
- event.accountId,
175
- event.mailboxId,
176
- )
177
- ) {
178
- log.warn(
179
- {
180
- accountId: event.accountId,
181
- mailboxId: event.mailboxId,
182
- eventId: event.eventId,
183
- event: event.type,
184
- },
185
- "Skipping SYNC_MESSAGES: a folder mutation is in flight",
186
- );
187
- return;
188
- }
189
-
190
- // withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, resolve
191
- // credentials, flip on terminal auth failure, rethrow transient). The
192
- // mailbox lock and the actual sync run inside the wrapper callback.
193
- await withOAuthLifecycle(
194
- buildLifecycleDeps(secrets, accountService),
195
- account,
196
- log,
197
- async (credentials) => {
198
- // Acquire lock before starting sync operation
199
- const { executed } = await mailboxLockService.withMailboxLock(
200
- event.mailboxId,
201
- "SYNC_MESSAGES",
202
- event.accountId,
203
- async () => {
204
- try {
205
- await syncMailboxMessages(
206
- event,
207
- account,
208
- createConnectionFactory(account, credentials),
209
- {
210
- accountService,
211
- mailboxService,
212
- messageService,
213
- envelopeService,
214
- addressService,
215
- threadMessageService,
216
- mailboxSpecialUseService,
217
- quarantineService,
218
- syncWarningRepository,
219
- flagPushMarkerService,
220
- messageFlagService,
221
- emit: createEmitEvent(queues),
222
- },
223
- log,
224
- );
225
- } catch (err) {
226
- // Auth failures are handled by the wrapper — rethrow untouched.
227
- if (
228
- err instanceof RefreshTokenError ||
229
- (err instanceof MailConnectionError && err.kind === "auth")
230
- ) {
231
- throw err;
232
- }
233
- // The guard above is a check-then-act: a delete asked for while
234
- // this round was in flight lands inside it, and the round then
235
- // fails somewhere deeper — on the row (NotFoundError from the
236
- // service's own re-read) or on the server (the SELECTed folder
237
- // vanished, so the next command runs against no mailbox). Both are
238
- // the same expected outcome as an event that arrived after the
239
- // delete, so they resolve the same way rather than head-of-line
240
- // blocking the account's FIFO group (issue #339). Recording an
241
- // error phase is skipped too: the account is healthy, one of its
242
- // folders is not there.
243
- //
244
- // This read only classifies the failure already in hand, so it must
245
- // never become the failure that is reported. A read that cannot answer
246
- // leaves the round on the loud path below, with the real error and the
247
- // state write that goes with it intact.
248
- const folderIsGone = await isMailboxMutationInFlight(
249
- mailboxService,
250
- event.accountId,
251
- event.mailboxId,
252
- ).catch(() => false);
253
- if (folderIsGone) {
254
- log.warn(
255
- {
256
- accountId: event.accountId,
257
- mailboxId: event.mailboxId,
258
- eventId: event.eventId,
259
- event: event.type,
260
- error: err instanceof Error ? err.message : String(err),
261
- },
262
- "Resolving SYNC_MESSAGES: the folder left the server while the sync was in flight",
263
- );
264
- return;
265
- }
266
- // Record the terminal error phase before crashing (let-it-crash:
267
- // record state, then rethrow so the event is retried/DLQ'd).
268
- const message = err instanceof Error ? err.message : String(err);
269
- await accountService.update(event.accountId, {
270
- syncPhase: SyncPhase.error,
271
- lastError: message,
272
- });
273
- throw err;
274
- }
275
- },
276
- );
277
-
278
- if (!executed) {
279
- log.info(
280
- { mailboxId: event.mailboxId },
281
- "Sync already in progress, skipping",
282
- );
283
- }
284
- },
285
- );
286
- };
287
-
288
- interface SyncDeps {
289
- accountService: IAccountRepository;
290
- mailboxService: IMailboxRepository;
291
- messageService: IMessageRepository;
292
- envelopeService: IEnvelopeRepository;
293
- addressService: IAddressRepository;
294
- threadMessageService: IThreadMessageRepository;
295
- mailboxSpecialUseService: IMailboxSpecialUseRepository;
296
- quarantineService: IQuarantineRepository;
297
- syncWarningRepository: ISyncWarningRepository;
298
- flagPushMarkerService: IMessageFlagPushRepository;
299
- messageFlagService: IMessageFlagRepository;
300
- emit: EmitEvent;
301
- }
302
-
303
- // Bounds concurrent SQS sends while re-arming stuck flag-push markers —
304
- // markers are expected to be few, but never unbounded (coding-standards.md).
305
- const FLAG_PUSH_DRAIN_CONCURRENCY = 5;
306
-
307
- /**
308
- * Periodic per-mailbox drain point for pending flag-push markers (issue
309
- * #1273, epic #1281). The SQS enqueue in `FlagPushService.flip` is only a
310
- * wake-up hint and may fail freely — a marker left `state: "pending"` means
311
- * that hint never landed (queue down, or a crash between the local write and
312
- * the enqueue). This periodic tick — which already runs per mailbox on a
313
- * schedule regardless of user activity — re-arms every such marker with a
314
- * fresh `FLAG_PUSH` event, closing the gap without the caller ever having to
315
- * retry.
316
- *
317
- * Markers already `queued`/`processing` are left alone: a live SQS message
318
- * (or the single-marker handler currently running) already owns driving them
319
- * forward, and re-arming them too would just duplicate work.
320
- *
321
- * A re-arm failure (the SQS send itself) is caught per-marker and logged
322
- * loudly — it must never fail the surrounding SYNC_MESSAGES batch, which is
323
- * unrelated message-header sync work. The marker stays durable regardless;
324
- * the next periodic tick tries again.
325
- */
326
- export const drainPendingFlagPushes = async (
327
- flagPushMarkerService: IMessageFlagPushRepository,
328
- account: AccountItem,
329
- mailboxId: string,
330
- log: Logger,
331
- emit: EmitEvent,
332
- ): Promise<void> => {
333
- const markers = await flagPushMarkerService.listByMailboxId(mailboxId);
334
- const stuck = markers.filter((marker) => marker.state === "pending");
335
- if (stuck.length === 0) return;
336
-
337
- log.info(
338
- { mailboxId, count: stuck.length },
339
- "Periodic sync tick found flag-push marker(s) stuck before their wake-up hint; re-arming",
340
- );
341
-
342
- await pMap(
343
- stuck,
344
- (marker) => {
345
- const rearmEvent: Omit<FlagPushEvent, "eventId" | "timestamp"> = {
346
- type: "FLAG_PUSH",
347
- accountId: account.accountId,
348
- accountConfigId: account.accountConfigId,
349
- messageId: marker.messageId,
350
- flagName: marker.flagName,
351
- };
352
- return emit(rearmEvent).catch((error: unknown) => {
353
- log.error(
354
- {
355
- alert: "flag_push_drain_rearm_failed",
356
- mailboxId,
357
- messageId: marker.messageId,
358
- flagName: marker.flagName,
359
- error: error instanceof Error ? error.message : String(error),
360
- },
361
- "Failed to re-arm a stuck pending flag-push marker during the periodic drain",
362
- );
363
- });
364
- },
365
- { concurrency: FLAG_PUSH_DRAIN_CONCURRENCY },
366
- );
367
- };
368
-
369
- /**
370
- * Sync one batch of messages for a mailbox. Runs under the mailbox lock.
371
- */
372
- const syncMailboxMessages = async (
373
- event: SyncMessagesEvent,
374
- account: AccountItem,
375
- connectionFactory: ManagedConnectionFactory,
376
- deps: SyncDeps,
377
- log: Logger,
378
- ): Promise<void> => {
379
- const {
380
- accountService,
381
- mailboxService,
382
- messageService,
383
- envelopeService,
384
- addressService,
385
- threadMessageService,
386
- mailboxSpecialUseService,
387
- quarantineService,
388
- syncWarningRepository,
389
- flagPushMarkerService,
390
- messageFlagService,
391
- emit,
392
- } = deps;
393
-
394
- // Get the mailbox - it must exist (should have been created by mailbox sync)
395
- const mailbox = await mailboxService.get(account.accountId, event.mailboxId);
396
- const mailboxId = mailbox.mailboxId;
397
-
398
- // Periodic drain (issue #1273): independent of the IMAP sync below — no
399
- // connection needed, just an SQS re-arm — so it runs regardless of this
400
- // round's sync outcome.
401
- await drainPendingFlagPushes(
402
- flagPushMarkerService,
403
- account,
404
- mailboxId,
405
- log,
406
- emit,
407
- );
408
- const isInbox = mailbox.fullPath.toUpperCase() === "INBOX";
409
-
410
- const syncService = new MessageSyncService(
411
- connectionFactory,
412
- mailboxService,
413
- mailboxSpecialUseService,
414
- messageService,
415
- envelopeService,
416
- addressService,
417
- threadMessageService,
418
- new SyncWarningService(syncWarningRepository),
419
- log,
420
- flagPushMarkerService,
421
- messageFlagService,
422
- new QuarantineService(
423
- quarantineService,
424
- mailboxSpecialUseService,
425
- workerVersion(),
426
- log,
427
- ),
428
- );
429
-
430
- // Connect once, reuse for the entire sync operation
431
- const connection = connectionFactory.getConnection();
432
- await connection.connect();
433
-
434
- const result = await syncService
435
- .syncMessages(
436
- mailboxId,
437
- account.accountId,
438
- account.accountConfigId,
439
- MESSAGE_BATCH_SIZE,
440
- )
441
- .finally(() => connectionFactory.close());
442
- log.info(
443
- {
444
- syncedCount: result.syncedCount,
445
- hasMore: result.hasMore,
446
- remainingCount: result.remainingCount,
447
- },
448
- "Message sync batch complete",
449
- );
450
-
451
- // A stalled cursor is the one failure on this path that produces no error:
452
- // unapplicable messages are caught and held back, so the round returns
453
- // normally, the SQS record is deleted, and neither redrive nor the DLQ ever
454
- // engages. Without this counter the mailbox stops syncing behind a worker
455
- // that looks healthy. Per-mailbox detail is in the accompanying ERROR log.
456
- if (result.cursorStalled) {
457
- recordImapFailure("SYNC_MESSAGES_CURSOR_STALLED", "other");
458
- }
459
-
460
- // Emit body sync events for the messages we just synced. Each event carries
461
- // messageId+uid pairs so the consumer issues one ranged FETCH per batch
462
- // without re-resolving UIDs. messageIds stays populated for backward compat.
463
- if (result.syncedMessages.length > 0) {
464
- const batches = batchSyncedMessages(result.syncedMessages);
465
-
466
- log.info(
467
- { count: result.syncedMessages.length, batches: batches.length },
468
- "Emitting SYNC_MESSAGE_BODY events",
469
- );
470
-
471
- await pMap(
472
- batches,
473
- (batch) => {
474
- const bodyEvent: Omit<SyncMessageBodyEvent, "eventId" | "timestamp"> = {
475
- type: "SYNC_MESSAGE_BODY",
476
- accountId: event.accountId,
477
- mailboxId,
478
- messageIds: batch.map((m) => m.messageId),
479
- messages: batch.map((m) => ({ messageId: m.messageId, uid: m.uid })),
480
- };
481
- return emit(bodyEvent);
482
- },
483
- { concurrency: EVENT_EMIT_CONCURRENCY },
484
- );
485
- }
486
-
487
- // If there are more messages to sync, emit another SYNC_MESSAGES event
488
- if (result.hasMore) {
489
- log.info(
490
- { remainingCount: result.remainingCount },
491
- "Emitting SYNC_MESSAGES event for next batch",
492
- );
493
-
494
- const nextSyncEvent: Omit<SyncMessagesEvent, "eventId" | "timestamp"> = {
495
- type: "SYNC_MESSAGES",
496
- accountId: event.accountId,
497
- mailboxId,
498
- };
499
- await emit(nextSyncEvent);
500
- return;
501
- }
502
-
503
- // Mailbox drained. Record the per-mailbox completion marker (used by the
504
- // sync-status endpoint to derive the per-mailbox phase), and count it
505
- // towards mailboxCountSynced — but only once per sync round, so that
506
- // duplicate / no-op SYNC_MESSAGES completions don't inflate the counter.
507
- // The check-then-write is safe: the mailbox lock serializes completions
508
- // per mailbox, and `mailbox` was read under the lock.
509
- const roundStartedAt = account.lastSyncAt ?? 0;
510
- const previousCompletedAt = mailbox.initialSyncCompletedAt ?? 0;
511
- const firstCompletionThisRound =
512
- previousCompletedAt === 0 || previousCompletedAt < roundStartedAt;
513
-
514
- await mailboxService.update(account.accountId, mailboxId, {
515
- initialSyncCompletedAt: Date.now(),
516
- });
517
-
518
- if (!firstCompletionThisRound) {
519
- log.info(
520
- { mailboxId },
521
- "Mailbox already counted as synced this round, skipping increment",
522
- );
523
- return;
524
- }
525
-
526
- const currentAccount = await accountService.get(event.accountId);
527
-
528
- if (isInbox && currentAccount.syncPhase === SyncPhase.syncing_inbox) {
529
- // INBOX is drained; advance to syncing_others
530
- await accountService.update(event.accountId, {
531
- syncPhase: SyncPhase.syncing_others,
532
- });
533
- }
534
-
535
- // Atomically increment mailboxCountSynced; transitions to complete when all done
536
- await accountService.incrementMailboxSynced(event.accountId);
537
- };
package/src/index.test.ts DELETED
@@ -1,22 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { parseReceiveCount } from "./index.js";
4
-
5
- describe("parseReceiveCount — SQS ApproximateReceiveCount parsing", () => {
6
- it("parses the raw string attribute", () => {
7
- assert.equal(parseReceiveCount("1"), 1);
8
- assert.equal(parseReceiveCount("3"), 3);
9
- });
10
-
11
- it("defaults to 1 when the attribute is missing", () => {
12
- // A record with no attribute (e.g. an older local harness) is treated as
13
- // a first attempt, not fast-forwarded into retry-exhaustion handling.
14
- assert.equal(parseReceiveCount(undefined), 1);
15
- });
16
-
17
- it("defaults to 1 on a non-numeric or non-positive value", () => {
18
- assert.equal(parseReceiveCount("not-a-number"), 1);
19
- assert.equal(parseReceiveCount("0"), 1);
20
- assert.equal(parseReceiveCount("-1"), 1);
21
- });
22
- });
package/src/index.ts DELETED
@@ -1,82 +0,0 @@
1
- import { createLogger } from "@remit/logger";
2
- import {
3
- queueNameFromEventSource,
4
- recordImapFailure,
5
- recordQueueEvent,
6
- } from "@remit/logger-lambda/metrics";
7
- import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
8
- import type { SQSBatchResponse, SQSEvent, SQSHandler } from "aws-lambda";
9
- import type { WorkerEvent } from "./events.js";
10
- import { imapFailureKind } from "./failure-kind.js";
11
- import { processEvent } from "./processor.js";
12
- import { sqsWorkerQueues, workerQueueUrlsFromEnv } from "./queues.js";
13
-
14
- installNodeLogger();
15
- const queues = sqsWorkerQueues(workerQueueUrlsFromEnv());
16
-
17
- const log = createLogger();
18
-
19
- /**
20
- * Parse SQS's `ApproximateReceiveCount` record attribute (1 on first
21
- * delivery). Missing/malformed defaults to 1 so a record with no attribute
22
- * (e.g. an older local harness) is treated as a first attempt rather than
23
- * skipping straight to retry-exhaustion handling.
24
- */
25
- export const parseReceiveCount = (value: string | undefined): number => {
26
- const parsed = Number.parseInt(value ?? "1", 10);
27
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
28
- };
29
-
30
- export const handler: SQSHandler = withTelemetry(
31
- async (event: SQSEvent): Promise<SQSBatchResponse> => {
32
- const batchItemFailures: { itemIdentifier: string }[] = [];
33
-
34
- for (const record of event.Records) {
35
- const imapEvent: WorkerEvent = JSON.parse(record.body);
36
- const receiveCount = parseReceiveCount(
37
- record.attributes?.ApproximateReceiveCount,
38
- );
39
- log.info(
40
- {
41
- eventType: imapEvent.type,
42
- eventId: "eventId" in imapEvent ? imapEvent.eventId : undefined,
43
- receiveCount,
44
- },
45
- "Processing event",
46
- );
47
-
48
- const queue = queueNameFromEventSource(record.eventSourceARN);
49
- const opStart = Date.now();
50
- const failed = await processEvent(imapEvent, log, queues, receiveCount)
51
- .then(() => {
52
- recordQueueEvent({
53
- queue,
54
- eventType: imapEvent.type,
55
- outcome: "success",
56
- durationMs: Date.now() - opStart,
57
- });
58
- return false;
59
- })
60
- .catch((error) => {
61
- log.error(
62
- { error, messageId: record.messageId },
63
- "Event processing failed",
64
- );
65
- recordQueueEvent({
66
- queue,
67
- eventType: imapEvent.type,
68
- outcome: "failure",
69
- durationMs: Date.now() - opStart,
70
- });
71
- recordImapFailure(imapEvent.type, imapFailureKind(error));
72
- return true;
73
- });
74
-
75
- if (failed) {
76
- batchItemFailures.push({ itemIdentifier: record.messageId });
77
- }
78
- }
79
-
80
- return { batchItemFailures };
81
- },
82
- );