@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,412 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type {
3
+ AccountItem,
4
+ IAccountRepository,
5
+ IAddressRepository,
6
+ IEnvelopeRepository,
7
+ IMailboxRepository,
8
+ IMessageFlagPushRepository,
9
+ IMessageRepository,
10
+ IThreadMessageRepository,
11
+ IUnitOfWork,
12
+ } from "@remit/data-ports";
13
+ import { SyncPhase } from "@remit/domain-enums";
14
+ import { type Logger, MetricUnit, metrics } from "@remit/logger-lambda";
15
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
16
+ import {
17
+ createManagedConnectionFactory,
18
+ MailConnectionError,
19
+ type MailCredentials,
20
+ MessageSyncService,
21
+ type SyncedMessage,
22
+ } from "@remit/mailbox-service";
23
+ import pMap from "p-map";
24
+ import { isAccountDeleted, isUnsyncableHost } from "../account-check.js";
25
+ import { emitEvent } from "../emit.js";
26
+ import type {
27
+ FlagPushEvent,
28
+ SyncMessageBodyEvent,
29
+ SyncMessagesEvent,
30
+ } from "../events.js";
31
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
32
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
33
+
34
+ // One SYNC_MESSAGE_BODY event maps to one ranged UID FETCH on the consumer.
35
+ export const BODY_BATCH_SIZE = 200;
36
+ const EVENT_EMIT_CONCURRENCY = 10;
37
+ const MESSAGE_BATCH_SIZE = 200;
38
+
39
+ /** Slice synced messages into body-sync batches, each one ranged FETCH. */
40
+ export const batchSyncedMessages = (
41
+ syncedMessages: SyncedMessage[],
42
+ batchSize: number = BODY_BATCH_SIZE,
43
+ ): SyncedMessage[][] => {
44
+ const batches: SyncedMessage[][] = [];
45
+ for (let i = 0; i < syncedMessages.length; i += batchSize) {
46
+ batches.push(syncedMessages.slice(i, i + batchSize));
47
+ }
48
+ return batches;
49
+ };
50
+
51
+ export const syncMessages = async (
52
+ event: SyncMessagesEvent,
53
+ log: Logger,
54
+ ): Promise<void> => {
55
+ log.info(
56
+ {
57
+ event: event.type,
58
+ accountId: event.accountId,
59
+ mailboxId: event.mailboxId,
60
+ },
61
+ "Handling event",
62
+ );
63
+
64
+ const {
65
+ account: accountService,
66
+ mailbox: mailboxService,
67
+ message: messageService,
68
+ envelope: envelopeService,
69
+ address: addressService,
70
+ threadMessage: threadMessageService,
71
+ mailboxLock: mailboxLockService,
72
+ flagPush: flagPushMarkerService,
73
+ unitOfWork,
74
+ secrets,
75
+ } = await getClient();
76
+
77
+ // A deleted account never has its DDB row purged in lockstep with the queued
78
+ // SYNC_MESSAGES triggers, so a trigger can outlive its account. The lookup
79
+ // then returns null (or throws a named NotFoundError), which can never succeed
80
+ // on retry — it would retry to maxReceiveCount and poison the messages DLQ
81
+ // forever (issue #911). Treat a missing account as terminal: ack the event
82
+ // with a WARN. Genuinely transient failures (throttle, network) surface as
83
+ // other errors and still propagate to be retried.
84
+ let account: AccountItem;
85
+ const rawAccount = await accountService.get(event.accountId).catch((err) => {
86
+ if ((err as { name?: string })?.name === "NotFoundError") return null;
87
+ throw err;
88
+ });
89
+ if (!rawAccount) {
90
+ log.warn(
91
+ {
92
+ accountId: event.accountId,
93
+ mailboxId: event.mailboxId,
94
+ eventId: event.eventId,
95
+ },
96
+ "Skipping SYNC_MESSAGES: account no longer exists",
97
+ );
98
+ return;
99
+ }
100
+ account = rawAccount;
101
+
102
+ if (isAccountDeleted(account, log)) {
103
+ return;
104
+ }
105
+
106
+ // A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
107
+ // sync attempt would retry and dead-letter forever. Skip cleanly — ack the
108
+ // event without connecting or throwing.
109
+ if (isUnsyncableHost(account, log.child({ mailboxId: event.mailboxId }))) {
110
+ return;
111
+ }
112
+
113
+ // withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, resolve
114
+ // credentials, flip on terminal auth failure, rethrow transient). The
115
+ // mailbox lock and the actual sync run inside the wrapper callback.
116
+ await withOAuthLifecycle(
117
+ buildLifecycleDeps(secrets, accountService),
118
+ account,
119
+ log,
120
+ async (credentials) => {
121
+ // Acquire lock before starting sync operation
122
+ const { executed } = await mailboxLockService.withMailboxLock(
123
+ event.mailboxId,
124
+ "SYNC_MESSAGES",
125
+ event.accountId,
126
+ async () => {
127
+ try {
128
+ await syncMailboxMessages(
129
+ event,
130
+ account,
131
+ credentials,
132
+ {
133
+ accountService,
134
+ mailboxService,
135
+ messageService,
136
+ envelopeService,
137
+ addressService,
138
+ threadMessageService,
139
+ flagPushMarkerService,
140
+ unitOfWork,
141
+ },
142
+ log,
143
+ );
144
+ } catch (err) {
145
+ // Auth failures are handled by the wrapper — rethrow untouched.
146
+ if (
147
+ err instanceof RefreshTokenError ||
148
+ (err instanceof MailConnectionError && err.kind === "auth")
149
+ ) {
150
+ throw err;
151
+ }
152
+ // Record the terminal error phase before crashing (let-it-crash:
153
+ // record state, then rethrow so the event is retried/DLQ'd).
154
+ const message = err instanceof Error ? err.message : String(err);
155
+ await accountService.update(event.accountId, {
156
+ syncPhase: SyncPhase.error,
157
+ lastError: message,
158
+ });
159
+ throw err;
160
+ }
161
+ },
162
+ );
163
+
164
+ if (!executed) {
165
+ log.info(
166
+ { mailboxId: event.mailboxId },
167
+ "Sync already in progress, skipping",
168
+ );
169
+ }
170
+ },
171
+ );
172
+ };
173
+
174
+ interface SyncDeps {
175
+ accountService: IAccountRepository;
176
+ mailboxService: IMailboxRepository;
177
+ messageService: IMessageRepository;
178
+ envelopeService: IEnvelopeRepository;
179
+ addressService: IAddressRepository;
180
+ threadMessageService: IThreadMessageRepository;
181
+ flagPushMarkerService: IMessageFlagPushRepository;
182
+ unitOfWork?: IUnitOfWork;
183
+ }
184
+
185
+ // Bounds concurrent SQS sends while re-arming stuck flag-push markers —
186
+ // markers are expected to be few, but never unbounded (coding-standards.md).
187
+ const FLAG_PUSH_DRAIN_CONCURRENCY = 5;
188
+
189
+ /**
190
+ * Periodic per-mailbox drain point for pending flag-push markers (issue
191
+ * #1273, epic #1281). The SQS enqueue in `FlagPushService.flip` is only a
192
+ * wake-up hint and may fail freely — a marker left `state: "pending"` means
193
+ * that hint never landed (queue down, or a crash between the local write and
194
+ * the enqueue). This periodic tick — which already runs per mailbox on a
195
+ * schedule regardless of user activity — re-arms every such marker with a
196
+ * fresh `FLAG_PUSH` event, closing the gap without the caller ever having to
197
+ * retry.
198
+ *
199
+ * Markers already `queued`/`processing` are left alone: a live SQS message
200
+ * (or the single-marker handler currently running) already owns driving them
201
+ * forward, and re-arming them too would just duplicate work.
202
+ *
203
+ * A re-arm failure (the SQS send itself) is caught per-marker and logged
204
+ * loudly — it must never fail the surrounding SYNC_MESSAGES batch, which is
205
+ * unrelated message-header sync work. The marker stays durable regardless;
206
+ * the next periodic tick tries again.
207
+ *
208
+ * `emit` defaults to the real `emitEvent` (imap-worker's shared SQS
209
+ * producer) and is only ever overridden in tests.
210
+ */
211
+ export const drainPendingFlagPushes = async (
212
+ flagPushMarkerService: IMessageFlagPushRepository,
213
+ account: AccountItem,
214
+ mailboxId: string,
215
+ log: Logger,
216
+ emit: typeof emitEvent = emitEvent,
217
+ ): Promise<void> => {
218
+ const markers = await flagPushMarkerService.listByMailboxId(mailboxId);
219
+ const stuck = markers.filter((marker) => marker.state === "pending");
220
+ if (stuck.length === 0) return;
221
+
222
+ log.info(
223
+ { mailboxId, count: stuck.length },
224
+ "Periodic sync tick found flag-push marker(s) stuck before their wake-up hint; re-arming",
225
+ );
226
+
227
+ await pMap(
228
+ stuck,
229
+ (marker) => {
230
+ const rearmEvent: Omit<FlagPushEvent, "eventId" | "timestamp"> = {
231
+ type: "FLAG_PUSH",
232
+ accountId: account.accountId,
233
+ accountConfigId: account.accountConfigId,
234
+ messageId: marker.messageId,
235
+ flagName: marker.flagName,
236
+ };
237
+ return emit(rearmEvent).catch((error: unknown) => {
238
+ log.error(
239
+ {
240
+ alert: "flag_push_drain_rearm_failed",
241
+ mailboxId,
242
+ messageId: marker.messageId,
243
+ flagName: marker.flagName,
244
+ error: error instanceof Error ? error.message : String(error),
245
+ },
246
+ "Failed to re-arm a stuck pending flag-push marker during the periodic drain",
247
+ );
248
+ });
249
+ },
250
+ { concurrency: FLAG_PUSH_DRAIN_CONCURRENCY },
251
+ );
252
+ };
253
+
254
+ /**
255
+ * Sync one batch of messages for a mailbox. Runs under the mailbox lock.
256
+ */
257
+ const syncMailboxMessages = async (
258
+ event: SyncMessagesEvent,
259
+ account: AccountItem,
260
+ credentials: MailCredentials,
261
+ deps: SyncDeps,
262
+ log: Logger,
263
+ ): Promise<void> => {
264
+ const {
265
+ accountService,
266
+ mailboxService,
267
+ messageService,
268
+ envelopeService,
269
+ addressService,
270
+ threadMessageService,
271
+ flagPushMarkerService,
272
+ unitOfWork,
273
+ } = deps;
274
+
275
+ // Create a managed connection factory that caches and reuses the connection
276
+ const connectionFactory = createManagedConnectionFactory({
277
+ user: account.username,
278
+ credentials,
279
+ host: account.imapHost,
280
+ port: account.imapPort,
281
+ tls: account.imapTls,
282
+ });
283
+
284
+ // Get the mailbox - it must exist (should have been created by mailbox sync)
285
+ const mailbox = await mailboxService.get(account.accountId, event.mailboxId);
286
+ const mailboxId = mailbox.mailboxId;
287
+
288
+ // Periodic drain (issue #1273): independent of the IMAP sync below — no
289
+ // connection needed, just an SQS re-arm — so it runs regardless of this
290
+ // round's sync outcome.
291
+ await drainPendingFlagPushes(flagPushMarkerService, account, mailboxId, log);
292
+ const isInbox = mailbox.fullPath.toUpperCase() === "INBOX";
293
+
294
+ const syncService = new MessageSyncService(
295
+ connectionFactory,
296
+ mailboxService,
297
+ messageService,
298
+ envelopeService,
299
+ addressService,
300
+ threadMessageService,
301
+ log,
302
+ unitOfWork,
303
+ );
304
+
305
+ // Connect once, reuse for the entire sync operation
306
+ const connection = connectionFactory.getConnection();
307
+ await connection.connect();
308
+
309
+ const result = await syncService
310
+ .syncMessages(
311
+ mailboxId,
312
+ account.accountId,
313
+ account.accountConfigId,
314
+ MESSAGE_BATCH_SIZE,
315
+ )
316
+ .finally(() => connectionFactory.close());
317
+ log.info(
318
+ {
319
+ syncedCount: result.syncedCount,
320
+ hasMore: result.hasMore,
321
+ remainingCount: result.remainingCount,
322
+ },
323
+ "Message sync batch complete",
324
+ );
325
+
326
+ if (result.syncedCount > 0) {
327
+ metrics.addMetric(
328
+ "imapMessagesSynced",
329
+ MetricUnit.Count,
330
+ result.syncedCount,
331
+ );
332
+ }
333
+
334
+ // Emit body sync events for the messages we just synced. Each event carries
335
+ // messageId+uid pairs so the consumer issues one ranged FETCH per batch
336
+ // without re-resolving UIDs. messageIds stays populated for backward compat.
337
+ if (result.syncedMessages.length > 0) {
338
+ const batches = batchSyncedMessages(result.syncedMessages);
339
+
340
+ log.info(
341
+ { count: result.syncedMessages.length, batches: batches.length },
342
+ "Emitting SYNC_MESSAGE_BODY events",
343
+ );
344
+
345
+ await pMap(
346
+ batches,
347
+ (batch) => {
348
+ const bodyEvent: Omit<SyncMessageBodyEvent, "eventId" | "timestamp"> = {
349
+ type: "SYNC_MESSAGE_BODY",
350
+ accountId: event.accountId,
351
+ mailboxId,
352
+ messageIds: batch.map((m) => m.messageId),
353
+ messages: batch.map((m) => ({ messageId: m.messageId, uid: m.uid })),
354
+ };
355
+ return emitEvent(bodyEvent);
356
+ },
357
+ { concurrency: EVENT_EMIT_CONCURRENCY },
358
+ );
359
+ }
360
+
361
+ // If there are more messages to sync, emit another SYNC_MESSAGES event
362
+ if (result.hasMore) {
363
+ log.info(
364
+ { remainingCount: result.remainingCount },
365
+ "Emitting SYNC_MESSAGES event for next batch",
366
+ );
367
+
368
+ const nextSyncEvent: Omit<SyncMessagesEvent, "eventId" | "timestamp"> = {
369
+ type: "SYNC_MESSAGES",
370
+ accountId: event.accountId,
371
+ mailboxId,
372
+ resumeCursor: result.remainingCount,
373
+ };
374
+ await emitEvent(nextSyncEvent);
375
+ return;
376
+ }
377
+
378
+ // Mailbox drained. Record the per-mailbox completion marker (used by the
379
+ // sync-status endpoint to derive the per-mailbox phase), and count it
380
+ // towards mailboxCountSynced — but only once per sync round, so that
381
+ // duplicate / no-op SYNC_MESSAGES completions don't inflate the counter.
382
+ // The check-then-write is safe: the mailbox lock serializes completions
383
+ // per mailbox, and `mailbox` was read under the lock.
384
+ const roundStartedAt = account.lastSyncAt ?? 0;
385
+ const previousCompletedAt = mailbox.initialSyncCompletedAt ?? 0;
386
+ const firstCompletionThisRound =
387
+ previousCompletedAt === 0 || previousCompletedAt < roundStartedAt;
388
+
389
+ await mailboxService.update(account.accountId, mailboxId, {
390
+ initialSyncCompletedAt: Date.now(),
391
+ });
392
+
393
+ if (!firstCompletionThisRound) {
394
+ log.info(
395
+ { mailboxId },
396
+ "Mailbox already counted as synced this round, skipping increment",
397
+ );
398
+ return;
399
+ }
400
+
401
+ const currentAccount = await accountService.get(event.accountId);
402
+
403
+ if (isInbox && currentAccount.syncPhase === SyncPhase.syncing_inbox) {
404
+ // INBOX is drained; advance to syncing_others
405
+ await accountService.update(event.accountId, {
406
+ syncPhase: SyncPhase.syncing_others,
407
+ });
408
+ }
409
+
410
+ // Atomically increment mailboxCountSynced; transitions to complete when all done
411
+ await accountService.incrementMailboxSynced(event.accountId);
412
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Handler-level guard: an account whose IMAP host is a reserved, never-resolvable
3
+ * placeholder name (RFC 2606 — .invalid/.example) must be skipped cleanly.
4
+ * No connection attempt, no thrown error, so SQS acks the event instead of
5
+ * retrying it into the mailboxes DLQ forever (issue #835).
6
+ *
7
+ * The proof is structural: the only AccountService method the handler may touch
8
+ * is `get`. Any of the post-connect writes (`markAuthenticated`, `update`) firing
9
+ * would mean we passed the skip gate and tried to connect — so those are stubbed
10
+ * to throw, and the test asserts the handler still resolves cleanly.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { afterEach, describe, it, mock } from "node:test";
15
+ import {
16
+ _resetForTest,
17
+ _setClientForTest,
18
+ type RemitClient,
19
+ } from "@remit/backend/client";
20
+ import type { AccountItem } from "@remit/data-ports";
21
+ import type { Logger } from "@remit/logger-lambda";
22
+ import type { SyncMailboxesEvent, SyncMessagesEvent } from "../events.js";
23
+ import { syncMailboxes } from "./sync-mailboxes.js";
24
+ import { syncMessages } from "./sync-messages.js";
25
+
26
+ const silentLogger = (() => {
27
+ const noop = () => {};
28
+ const log = {
29
+ info: noop,
30
+ warn: noop,
31
+ error: noop,
32
+ debug: noop,
33
+ fatal: noop,
34
+ trace: noop,
35
+ child: () => log,
36
+ } as unknown as Logger;
37
+ return log;
38
+ })();
39
+
40
+ const reservedAccount = (): AccountItem =>
41
+ ({
42
+ accountId: "acct-reserved",
43
+ accountConfigId: "acfg-reserved",
44
+ connectionState: "authenticated",
45
+ username: "alice@imap.invalid",
46
+ imapHost: "imap.invalid",
47
+ imapPort: 993,
48
+ imapTls: true,
49
+ }) as unknown as AccountItem;
50
+
51
+ const failIfReached = () => {
52
+ throw new Error("post-skip-gate account write must not run");
53
+ };
54
+
55
+ const setClientWithAccountGet = (get: ReturnType<typeof mock.fn>): void => {
56
+ _setClientForTest({
57
+ account: {
58
+ get,
59
+ markAuthenticated: failIfReached,
60
+ update: failIfReached,
61
+ },
62
+ } as unknown as RemitClient);
63
+ };
64
+
65
+ afterEach(() => {
66
+ mock.restoreAll();
67
+ _resetForTest();
68
+ });
69
+
70
+ describe("reserved-host skip gate", () => {
71
+ it("syncMailboxes skips a reserved host cleanly — no connect, no throw", async () => {
72
+ const get = mock.fn(async () => reservedAccount());
73
+ setClientWithAccountGet(get);
74
+
75
+ const event = {
76
+ type: "SYNC_MAILBOXES",
77
+ accountId: "acct-reserved",
78
+ } as unknown as SyncMailboxesEvent;
79
+
80
+ await assert.doesNotReject(() => syncMailboxes(event, silentLogger));
81
+ assert.equal(get.mock.callCount(), 1);
82
+ });
83
+
84
+ it("syncMessages skips a reserved host cleanly — no connect, no throw", async () => {
85
+ const get = mock.fn(async () => reservedAccount());
86
+ setClientWithAccountGet(get);
87
+
88
+ const event = {
89
+ type: "SYNC_MESSAGES",
90
+ accountId: "acct-reserved",
91
+ mailboxId: "mbox-1",
92
+ } as unknown as SyncMessagesEvent;
93
+
94
+ await assert.doesNotReject(() => syncMessages(event, silentLogger));
95
+ assert.equal(get.mock.callCount(), 1);
96
+ });
97
+ });
@@ -0,0 +1,22 @@
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 ADDED
@@ -0,0 +1,70 @@
1
+ import {
2
+ createLogger,
3
+ MetricUnit,
4
+ metrics,
5
+ withTelemetry,
6
+ } from "@remit/logger-lambda";
7
+ import type { SQSBatchResponse, SQSEvent, SQSHandler } from "aws-lambda";
8
+ import type { WorkerEvent } from "./events.js";
9
+ import { processEvent } from "./processor.js";
10
+
11
+ const log = createLogger();
12
+
13
+ /**
14
+ * Parse SQS's `ApproximateReceiveCount` record attribute (1 on first
15
+ * delivery). Missing/malformed defaults to 1 so a record with no attribute
16
+ * (e.g. an older local harness) is treated as a first attempt rather than
17
+ * skipping straight to retry-exhaustion handling.
18
+ */
19
+ export const parseReceiveCount = (value: string | undefined): number => {
20
+ const parsed = Number.parseInt(value ?? "1", 10);
21
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
22
+ };
23
+
24
+ export const handler: SQSHandler = withTelemetry(
25
+ async (event: SQSEvent): Promise<SQSBatchResponse> => {
26
+ const batchItemFailures: { itemIdentifier: string }[] = [];
27
+
28
+ for (const record of event.Records) {
29
+ const imapEvent: WorkerEvent = JSON.parse(record.body);
30
+ const receiveCount = parseReceiveCount(
31
+ record.attributes?.ApproximateReceiveCount,
32
+ );
33
+ log.info(
34
+ {
35
+ eventType: imapEvent.type,
36
+ eventId: "eventId" in imapEvent ? imapEvent.eventId : undefined,
37
+ receiveCount,
38
+ },
39
+ "Processing event",
40
+ );
41
+
42
+ metrics.addDimension("operation", imapEvent.type);
43
+ const opStart = Date.now();
44
+ const failed = await processEvent(imapEvent, log, receiveCount)
45
+ .then(() => {
46
+ metrics.addMetric(
47
+ "imapOperationLatency",
48
+ MetricUnit.Milliseconds,
49
+ Date.now() - opStart,
50
+ );
51
+ return false;
52
+ })
53
+ .catch((error) => {
54
+ log.error(
55
+ { error, messageId: record.messageId },
56
+ "Event processing failed",
57
+ );
58
+ metrics.addMetric("imapOperationFailures", MetricUnit.Count, 1);
59
+ return true;
60
+ });
61
+ metrics.clearDimensions();
62
+
63
+ if (failed) {
64
+ batchItemFailures.push({ itemIdentifier: record.messageId });
65
+ }
66
+ }
67
+
68
+ return { batchItemFailures };
69
+ },
70
+ );
package/src/poller.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { createLogger } from "@remit/logger-lambda";
2
+ import { runQueuePoller } from "@remit/sqs-client/poller";
3
+ import { env } from "expect-env";
4
+ import { handler } from "./index.js";
5
+
6
+ /**
7
+ * Production queue poller — the deployed form of `e2e-processor-shim.ts`.
8
+ * Polls every imap-worker queue and invokes the production Lambda handler.
9
+ * The search-index queue is NOT polled here: it is its own image/deployment
10
+ * (`remit-search-index-worker`), unlike the e2e shim which piggybacks it
11
+ * onto this same process for test convenience.
12
+ */
13
+ const log = createLogger();
14
+
15
+ await runQueuePoller({
16
+ log,
17
+ targets: [
18
+ {
19
+ queueUrl: env.SQS_QUEUE_URL_MAILBOXES,
20
+ handler,
21
+ functionName: "imap-worker-mailboxes",
22
+ },
23
+ {
24
+ queueUrl: env.SQS_QUEUE_URL_MESSAGES,
25
+ handler,
26
+ functionName: "imap-worker-messages",
27
+ },
28
+ {
29
+ queueUrl: env.SQS_QUEUE_URL_FLAGS,
30
+ handler,
31
+ functionName: "imap-worker-flags",
32
+ },
33
+ {
34
+ queueUrl: env.SQS_QUEUE_URL_BODY,
35
+ handler,
36
+ functionName: "imap-worker-body",
37
+ },
38
+ {
39
+ queueUrl: env.SQS_QUEUE_URL_MAILBOX_MGMT,
40
+ handler,
41
+ functionName: "imap-worker-mailbox-mgmt",
42
+ },
43
+ {
44
+ queueUrl: env.SQS_QUEUE_URL_MESSAGE_MGMT,
45
+ handler,
46
+ functionName: "imap-worker-message-mgmt",
47
+ },
48
+ ],
49
+ });
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { Logger } from "@remit/logger-lambda";
4
+ import type { ImapWorkerStopEvent } from "./events.js";
5
+ import { processEvent } from "./processor.js";
6
+
7
+ interface CapturedLogEntry {
8
+ args: unknown[];
9
+ }
10
+
11
+ const createCapturingLogger = (): {
12
+ log: Logger;
13
+ infoCalls: CapturedLogEntry[];
14
+ } => {
15
+ const infoCalls: CapturedLogEntry[] = [];
16
+ const noop = () => {};
17
+ const log = {
18
+ info: (...args: unknown[]) => infoCalls.push({ args }),
19
+ warn: noop,
20
+ error: noop,
21
+ debug: noop,
22
+ fatal: noop,
23
+ trace: noop,
24
+ child: () => log,
25
+ } as unknown as Logger;
26
+ return { log, infoCalls };
27
+ };
28
+
29
+ describe("processEvent — IMAP_WORKER_STOP", () => {
30
+ it("returns undefined and logs the stop signal — tombstone fence on the account row already halts work", async () => {
31
+ const event: ImapWorkerStopEvent = {
32
+ type: "IMAP_WORKER_STOP",
33
+ accountConfigId: "acfg_alice_replay_safe_test_id",
34
+ accountId: "acct_alice_replay_safe_test_id",
35
+ };
36
+ const { log, infoCalls } = createCapturingLogger();
37
+
38
+ const result = await processEvent(event, log);
39
+
40
+ assert.equal(result, undefined);
41
+ const matched = infoCalls.find((c) => {
42
+ const [meta, msg] = c.args;
43
+ return (
44
+ typeof msg === "string" &&
45
+ msg === "Imap worker stop signal received" &&
46
+ typeof meta === "object" &&
47
+ meta !== null &&
48
+ (meta as { accountConfigId?: string }).accountConfigId ===
49
+ event.accountConfigId &&
50
+ (meta as { accountId?: string }).accountId === event.accountId
51
+ );
52
+ });
53
+ assert.ok(
54
+ matched,
55
+ "processor must log the cascade-contract stop signal with both ids",
56
+ );
57
+ });
58
+ });