@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,66 @@
1
+ import type { Logger } from "@remit/logger-lambda";
2
+ import type { WorkerEvent } from "./events.js";
3
+ import { handleAppendSentMessage } from "./handlers/append-sent-message.js";
4
+ import { handleDeleteAccountObjects } from "./handlers/delete-account-objects.js";
5
+ import { handleEmptyTrash } from "./handlers/empty-trash.js";
6
+ import { handleFlagPush } from "./handlers/flag-push.js";
7
+ import { processMailboxManagement } from "./handlers/mailbox-management.js";
8
+ import { handleMessageCopy } from "./handlers/message-copy.js";
9
+ import { handleMessageDelete } from "./handlers/message-delete.js";
10
+ import { handleMessageMove } from "./handlers/message-move.js";
11
+ import { handlePlacementMovePush } from "./handlers/placement-move-push.js";
12
+ import { syncMailboxes } from "./handlers/sync-mailboxes.js";
13
+ import { syncMessageBody } from "./handlers/sync-message-body.js";
14
+ import { syncMessages } from "./handlers/sync-messages.js";
15
+
16
+ export const processEvent = async (
17
+ event: WorkerEvent,
18
+ log: Logger,
19
+ /**
20
+ * SQS's own delivery count for the record carrying this event (1 on first
21
+ * delivery). Only SYNC_MESSAGE_BODY reads it — it's how the handler knows
22
+ * this is the last attempt before the queue's own redrive policy would
23
+ * DLQ the record, so it can resolve retry exhaustion into a terminal
24
+ * outcome (issue #1270) instead of dead-lettering blindly.
25
+ */
26
+ receiveCount = 1,
27
+ ): Promise<void> => {
28
+ switch (event.type) {
29
+ case "SYNC_MAILBOXES":
30
+ return syncMailboxes(event, log);
31
+ case "SYNC_MESSAGES":
32
+ return syncMessages(event, log);
33
+ case "SYNC_MESSAGE_BODY":
34
+ return syncMessageBody(event, log, receiveCount);
35
+ case "MAILBOX_CREATE":
36
+ case "MAILBOX_RENAME":
37
+ case "MAILBOX_DELETE":
38
+ return processMailboxManagement(event, log);
39
+ case "MESSAGE_DELETE":
40
+ return handleMessageDelete(event, log);
41
+ case "MESSAGE_MOVE":
42
+ return handleMessageMove(event, log);
43
+ case "PLACEMENT_MOVE_PUSH":
44
+ return handlePlacementMovePush(event, log, receiveCount);
45
+ case "FLAG_PUSH":
46
+ return handleFlagPush(event, log, receiveCount);
47
+ case "MESSAGE_COPY":
48
+ return handleMessageCopy(event, log);
49
+ case "EMPTY_TRASH":
50
+ return handleEmptyTrash(event, log);
51
+ case "APPEND_SENT_MESSAGE":
52
+ return handleAppendSentMessage(event, log);
53
+ case "DELETE_ACCOUNT_OBJECTS":
54
+ return handleDeleteAccountObjects(event, log);
55
+ case "IMAP_WORKER_STOP":
56
+ // Tombstone fence on the account row already stops processing;
57
+ // this event acks the cascade contract and is a no-op today.
58
+ log.info(
59
+ { accountConfigId: event.accountConfigId, accountId: event.accountId },
60
+ "Imap worker stop signal received",
61
+ );
62
+ return;
63
+ default:
64
+ throw new Error(`Unknown event type: ${(event as WorkerEvent).type}`);
65
+ }
66
+ };
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
4
+
5
+ describe("getTickIntervalMs", () => {
6
+ it("defaults to 1 hour when unset", () => {
7
+ assert.equal(getTickIntervalMs({}), 60 * 60 * 1000);
8
+ });
9
+
10
+ it("reads MAILBOX_SYNC_TICK_INTERVAL_SECONDS", () => {
11
+ assert.equal(
12
+ getTickIntervalMs({ MAILBOX_SYNC_TICK_INTERVAL_SECONDS: "60" }),
13
+ 60_000,
14
+ );
15
+ });
16
+
17
+ it("falls back to the default on a non-positive or non-numeric value", () => {
18
+ assert.equal(
19
+ getTickIntervalMs({ MAILBOX_SYNC_TICK_INTERVAL_SECONDS: "0" }),
20
+ 60 * 60 * 1000,
21
+ );
22
+ assert.equal(
23
+ getTickIntervalMs({ MAILBOX_SYNC_TICK_INTERVAL_SECONDS: "nope" }),
24
+ 60 * 60 * 1000,
25
+ );
26
+ });
27
+ });
28
+
29
+ describe("getOfflineIntervalMs", () => {
30
+ it("defaults to 12 hours when unset", () => {
31
+ assert.equal(getOfflineIntervalMs({}), 12 * 60 * 60 * 1000);
32
+ });
33
+
34
+ it("reads MAILBOX_SYNC_OFFLINE_INTERVAL_SECONDS", () => {
35
+ assert.equal(
36
+ getOfflineIntervalMs({ MAILBOX_SYNC_OFFLINE_INTERVAL_SECONDS: "3600" }),
37
+ 3_600_000,
38
+ );
39
+ });
40
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Periodic mailbox-sync scheduler (#1247, restructured #1251): the tick rate
3
+ * and the offline-sync threshold are separate, independently configured
4
+ * knobs — never hardcode a cadence — following the repo's
5
+ * env-var-with-a-safe-default convention (see `ACCOUNT_DELETION_GRACE_SECONDS`
6
+ * in remit-account-worker/src/config.ts).
7
+ *
8
+ * `tickIntervalSeconds` drives how often the tick itself runs — the
9
+ * EventBridge schedule rate in prod, the local-runner loop delay in dev — and
10
+ * must stay well below `offlineIntervalSeconds` so a tick reliably observes
11
+ * every account crossing the threshold. CDK and this runtime default must
12
+ * agree — see infra/lib/config.ts's `mailboxSync` stage config.
13
+ *
14
+ * `offlineIntervalSeconds` is the only due-ness threshold: an account is due
15
+ * once its last successful sync is older than this interval. There is no
16
+ * "online" tier — client-side polling (useStaleAccountSync) covers an
17
+ * account while its mail is actively open in the web client.
18
+ */
19
+
20
+ const DEFAULT_TICK_INTERVAL_SECONDS = 60 * 60; // 1 hour
21
+ const DEFAULT_OFFLINE_INTERVAL_SECONDS = 12 * 60 * 60; // 12 hours
22
+
23
+ const parsePositiveIntSeconds = (
24
+ raw: string | undefined,
25
+ fallback: number,
26
+ ): number => {
27
+ if (!raw) return fallback;
28
+ const parsed = Number.parseInt(raw, 10);
29
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
30
+ };
31
+
32
+ export const getTickIntervalMs = (
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ ): number =>
35
+ parsePositiveIntSeconds(
36
+ env.MAILBOX_SYNC_TICK_INTERVAL_SECONDS,
37
+ DEFAULT_TICK_INTERVAL_SECONDS,
38
+ ) * 1000;
39
+
40
+ export const getOfflineIntervalMs = (
41
+ env: NodeJS.ProcessEnv = process.env,
42
+ ): number =>
43
+ parsePositiveIntSeconds(
44
+ env.MAILBOX_SYNC_OFFLINE_INTERVAL_SECONDS,
45
+ DEFAULT_OFFLINE_INTERVAL_SECONDS,
46
+ ) * 1000;
47
+
48
+ // Page size + enqueue concurrency are implementation details of the tick
49
+ // itself, not stage config — unlike the two intervals above, nothing outside
50
+ // this worker needs to agree with them.
51
+ export const SCHEDULER_PAGE_SIZE = 100;
52
+ export const SCHEDULER_ENQUEUE_CONCURRENCY = 10;
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isSyncDue } from "./decide-due.js";
4
+
5
+ const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
6
+ const NOW = 1_700_000_000_000;
7
+
8
+ describe("isSyncDue", () => {
9
+ it("is always due when the account has never synced", () => {
10
+ assert.equal(isSyncDue({ accountId: "a" }, NOW, OFFLINE_INTERVAL_MS), true);
11
+ });
12
+
13
+ it("is due once the sync is older than the offline interval", () => {
14
+ const account = {
15
+ accountId: "a",
16
+ lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
17
+ };
18
+ assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), true);
19
+ });
20
+
21
+ it("is not due within the offline interval", () => {
22
+ const account = {
23
+ accountId: "a",
24
+ lastSyncAt: NOW - 60_000,
25
+ };
26
+ assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), false);
27
+ });
28
+
29
+ it("is due exactly at the threshold", () => {
30
+ const account = {
31
+ accountId: "a",
32
+ lastSyncAt: NOW - OFFLINE_INTERVAL_MS,
33
+ };
34
+ assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), true);
35
+ });
36
+
37
+ it("is not due one millisecond before the threshold", () => {
38
+ const account = {
39
+ accountId: "a",
40
+ lastSyncAt: NOW - OFFLINE_INTERVAL_MS + 1,
41
+ };
42
+ assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), false);
43
+ });
44
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure scheduling decision for the periodic mailbox-sync tick (#1247,
3
+ * restructured #1251).
4
+ *
5
+ * One tier: an account is due once its last successful sync is older than
6
+ * `offlineIntervalMs`. An account that has never synced is always due. The
7
+ * tick interval is decoupled from this threshold and runs far more often
8
+ * than `offlineIntervalMs` (see config.ts), so — unlike the prior two-tier
9
+ * design — no slack is needed to compensate for sampling lag: a tick that
10
+ * misses an account by a few seconds simply catches it on the next tick,
11
+ * which arrives long before the threshold matters again.
12
+ */
13
+
14
+ export interface SchedulableAccount {
15
+ readonly accountId: string;
16
+ readonly lastSyncAt?: number;
17
+ }
18
+
19
+ export const isSyncDue = (
20
+ account: SchedulableAccount,
21
+ now: number,
22
+ offlineIntervalMs: number,
23
+ ): boolean => {
24
+ if (!account.lastSyncAt) return true;
25
+ return now - account.lastSyncAt >= offlineIntervalMs;
26
+ };
@@ -0,0 +1,52 @@
1
+ import { SQSClient } from "@aws-sdk/client-sqs";
2
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
3
+ import { getClient } from "@remit/backend/client";
4
+ import { createLogger, withTelemetry } from "@remit/logger-lambda";
5
+ import { resolveSqsCredentials } from "@remit/sqs-client";
6
+ import type { ScheduledHandler } from "aws-lambda";
7
+ import { env } from "expect-env";
8
+ import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
9
+ import { runSchedulerTick } from "./run-tick.js";
10
+
11
+ const log = createLogger();
12
+
13
+ const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
14
+ const isLocal = mailboxesQueueUrl.startsWith("http://localhost");
15
+
16
+ const sqsClient = new SQSClient({
17
+ endpoint: isLocal ? new URL(mailboxesQueueUrl).origin : undefined,
18
+ ...(isLocal && { protocol: AwsQueryProtocol }),
19
+ credentials: resolveSqsCredentials(),
20
+ });
21
+
22
+ /**
23
+ * EventBridge-scheduled entry point for the periodic mailbox-sync tick
24
+ * (#1247, restructured #1251). Ticks at `MAILBOX_SYNC_TICK_INTERVAL_SECONDS`
25
+ * (rate schedule, wired in infra/stacks/dev/stacks/remit-worker-stack.ts) and
26
+ * delegates the actual decision + enqueue to `runSchedulerTick` — the same
27
+ * function the local dev-stack timer loop calls (see `local-runner.ts`), so
28
+ * production and local dev run one code path.
29
+ *
30
+ * Uses the EventBridge event's own `time` (the scheduled fire time) as the
31
+ * tick's `now`, rather than `Date.now()` at processing time. This is what
32
+ * keeps `buildScheduledSyncDedupId`'s time bucket aligned to the schedule
33
+ * instead of wall-clock/Lambda-cold-start jitter (review #1250): every
34
+ * distinct scheduled firing gets a `time` exactly `tickIntervalMs` apart
35
+ * from the last, so consecutive ticks always land in different buckets,
36
+ * while a genuine retry of the same invocation carries the same `time` and
37
+ * correctly dedupes.
38
+ */
39
+ export const handler: ScheduledHandler = withTelemetry(async (event) => {
40
+ const { account } = await getClient();
41
+ const now = Date.parse(event.time);
42
+
43
+ await runSchedulerTick({
44
+ accountService: account,
45
+ sqsClient,
46
+ queueUrl: mailboxesQueueUrl,
47
+ log,
48
+ tickIntervalMs: getTickIntervalMs(),
49
+ offlineIntervalMs: getOfflineIntervalMs(),
50
+ now,
51
+ });
52
+ });
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { SQSClient } from "@aws-sdk/client-sqs";
4
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
5
+ import { getClient } from "@remit/backend/client";
6
+ import { createLogger } from "@remit/logger-lambda";
7
+ import { resolveSqsCredentials } from "@remit/sqs-client";
8
+ import { env } from "expect-env";
9
+ import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
10
+ import { runSchedulerTick } from "./run-tick.js";
11
+
12
+ /**
13
+ * Standalone scheduled-sync runner for the local pg-dev docker-compose stack
14
+ * (#1247, restructured #1251). Production ticks `runSchedulerTick` off an
15
+ * EventBridge schedule (see handler.ts); ElasticMQ/the pg-dev stack has no
16
+ * EventBridge, so this process ticks on a plain loop at the same
17
+ * `MAILBOX_SYNC_TICK_INTERVAL_SECONDS` cadence instead — same function, same
18
+ * config knobs, so local dev behaves like production rather than needing its
19
+ * own scheduling logic.
20
+ *
21
+ * This is a dev-only harness, not production code: like
22
+ * `e2e-processor-shim.ts`, a tick failure crashes the process loudly rather
23
+ * than swallowing it — docker-compose's `restart: unless-stopped` brings it
24
+ * back for the next tick.
25
+ */
26
+
27
+ const log = createLogger();
28
+
29
+ const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
30
+ const isLocal = mailboxesQueueUrl.startsWith("http://localhost");
31
+
32
+ const sqsClient = new SQSClient({
33
+ endpoint: isLocal ? new URL(mailboxesQueueUrl).origin : undefined,
34
+ ...(isLocal && { protocol: AwsQueryProtocol }),
35
+ credentials: resolveSqsCredentials(),
36
+ });
37
+
38
+ const tickIntervalMs = getTickIntervalMs();
39
+ const offlineIntervalMs = getOfflineIntervalMs();
40
+
41
+ // A persistent failure (e.g. Postgres not up yet at container boot) throws
42
+ // before the loop ever reaches its own `delay`, so `restart: unless-stopped`
43
+ // would otherwise respawn the process immediately — a tight, log-flooding
44
+ // crash loop (review #1250). This fixed pause before exiting is not retry
45
+ // logic (there is nothing to retry here; the container restart IS the
46
+ // retry) — it only paces how fast that restart can happen.
47
+ const CRASH_BACKOFF_MS = 5_000;
48
+
49
+ log.info(
50
+ { tickIntervalMs, offlineIntervalMs },
51
+ "Local scheduled-sync runner started",
52
+ );
53
+
54
+ const runLoop = async (): Promise<void> => {
55
+ const { account } = await getClient();
56
+ for (;;) {
57
+ await runSchedulerTick({
58
+ accountService: account,
59
+ sqsClient,
60
+ queueUrl: mailboxesQueueUrl,
61
+ log,
62
+ tickIntervalMs,
63
+ offlineIntervalMs,
64
+ });
65
+ await delay(tickIntervalMs);
66
+ }
67
+ };
68
+
69
+ runLoop()
70
+ .catch(async (error) => {
71
+ log.error({ error }, "Scheduled-sync tick failed");
72
+ await delay(CRASH_BACKOFF_MS);
73
+ })
74
+ .finally(() => {
75
+ process.exit(1);
76
+ });
@@ -0,0 +1,248 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
+ import type { AccountItem, AccountSchedulerPage } from "@remit/data-ports";
5
+ import type { Logger } from "@remit/logger-lambda";
6
+ import { runSchedulerTick } from "./run-tick.js";
7
+
8
+ const TICK_INTERVAL_MS = 60 * 60 * 1000;
9
+ const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
10
+ const NOW = 1_700_000_000_000;
11
+
12
+ const createNoopLogger = (): Logger => {
13
+ const noop = () => {};
14
+ const log = {
15
+ info: noop,
16
+ warn: noop,
17
+ error: noop,
18
+ debug: noop,
19
+ fatal: noop,
20
+ trace: noop,
21
+ child: () => log,
22
+ } as unknown as Logger;
23
+ return log;
24
+ };
25
+
26
+ const createCapturingLogger = (): {
27
+ log: Logger;
28
+ calls: { level: string; args: unknown[] }[];
29
+ } => {
30
+ const calls: { level: string; args: unknown[] }[] = [];
31
+ const capture =
32
+ (level: string) =>
33
+ (...args: unknown[]) =>
34
+ calls.push({ level, args });
35
+ const log = {
36
+ info: capture("info"),
37
+ warn: capture("warn"),
38
+ error: capture("error"),
39
+ debug: capture("debug"),
40
+ fatal: capture("fatal"),
41
+ trace: capture("trace"),
42
+ child: () => log,
43
+ } as unknown as Logger;
44
+ return { log, calls };
45
+ };
46
+
47
+ const baseAccount = (overrides: Partial<AccountItem>): AccountItem =>
48
+ ({
49
+ accountId: "acct_1",
50
+ accountConfigId: "acfg_1",
51
+ username: "user",
52
+ email: "user@example.com",
53
+ authType: "password",
54
+ imapHost: "imap.example.com",
55
+ imapPort: 993,
56
+ imapTls: true,
57
+ imapStartTls: false,
58
+ smtpEnabled: false,
59
+ smtpHost: "",
60
+ smtpPort: 587,
61
+ smtpTls: false,
62
+ smtpStartTls: true,
63
+ smtpUsername: "",
64
+ isActive: true,
65
+ connectionState: "authenticated",
66
+ createdAt: NOW - 1_000_000,
67
+ updatedAt: NOW - 1_000_000,
68
+ ...overrides,
69
+ }) as AccountItem;
70
+
71
+ const fakeAccountService = (pages: AccountSchedulerPage[]) => {
72
+ let call = 0;
73
+ return {
74
+ listAllAccountsPage: async (): Promise<AccountSchedulerPage> => {
75
+ const page = pages[call];
76
+ call++;
77
+ if (!page) throw new Error("no more pages configured");
78
+ return page;
79
+ },
80
+ };
81
+ };
82
+
83
+ const fakeSqsClient = (): {
84
+ sqsClient: SQSClient;
85
+ sent: SendMessageCommand[];
86
+ } => {
87
+ const sent: SendMessageCommand[] = [];
88
+ const sqsClient = {
89
+ send: async (cmd: SendMessageCommand) => {
90
+ sent.push(cmd);
91
+ return {};
92
+ },
93
+ } as unknown as SQSClient;
94
+ return { sqsClient, sent };
95
+ };
96
+
97
+ describe("runSchedulerTick", () => {
98
+ it("enqueues only accounts that are due, and pages through every account", async () => {
99
+ const due = baseAccount({
100
+ accountId: "acct_due",
101
+ lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
102
+ });
103
+ const notDue = baseAccount({
104
+ accountId: "acct_not_due",
105
+ lastSyncAt: NOW - 60_000,
106
+ });
107
+
108
+ const accountService = fakeAccountService([
109
+ { items: [due], cursor: "page2" },
110
+ { items: [notDue], cursor: null },
111
+ ]);
112
+ const { sqsClient, sent } = fakeSqsClient();
113
+
114
+ const result = await runSchedulerTick({
115
+ accountService,
116
+ sqsClient,
117
+ queueUrl:
118
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
119
+ log: createNoopLogger(),
120
+ tickIntervalMs: TICK_INTERVAL_MS,
121
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
122
+ now: NOW,
123
+ });
124
+
125
+ assert.equal(result.scanned, 2);
126
+ assert.equal(result.enqueued, 1);
127
+ assert.equal(result.skipped, 1);
128
+ assert.equal(sent.length, 1);
129
+ const body = JSON.parse(sent[0]?.input.MessageBody ?? "{}");
130
+ assert.equal(body.accountId, "acct_due");
131
+ });
132
+
133
+ it("skips deleted, unsyncable-host, and reauth-required accounts", async () => {
134
+ const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
135
+ const unsyncable = baseAccount({
136
+ accountId: "acct_unsyncable",
137
+ imapHost: "mail.invalid",
138
+ });
139
+ const reauth = baseAccount({
140
+ accountId: "acct_reauth",
141
+ connectionState: "reauth_required",
142
+ });
143
+
144
+ const accountService = fakeAccountService([
145
+ { items: [deleted, unsyncable, reauth], cursor: null },
146
+ ]);
147
+ const { sqsClient, sent } = fakeSqsClient();
148
+
149
+ const result = await runSchedulerTick({
150
+ accountService,
151
+ sqsClient,
152
+ queueUrl:
153
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
154
+ log: createNoopLogger(),
155
+ tickIntervalMs: TICK_INTERVAL_MS,
156
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
157
+ now: NOW,
158
+ });
159
+
160
+ assert.equal(result.enqueued, 0);
161
+ assert.equal(result.skipped, 3);
162
+ assert.equal(sent.length, 0);
163
+ });
164
+
165
+ it("never logs per-account for ineligible accounts — only the aggregate tick summary (review #1250)", async () => {
166
+ const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
167
+ const unsyncable = baseAccount({
168
+ accountId: "acct_unsyncable",
169
+ imapHost: "mail.invalid",
170
+ });
171
+ const reauth = baseAccount({
172
+ accountId: "acct_reauth",
173
+ connectionState: "reauth_required",
174
+ });
175
+
176
+ const accountService = fakeAccountService([
177
+ { items: [deleted, unsyncable, reauth], cursor: null },
178
+ ]);
179
+ const { sqsClient } = fakeSqsClient();
180
+ const { log, calls } = createCapturingLogger();
181
+
182
+ await runSchedulerTick({
183
+ accountService,
184
+ sqsClient,
185
+ queueUrl:
186
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
187
+ log,
188
+ tickIntervalMs: TICK_INTERVAL_MS,
189
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
190
+ now: NOW,
191
+ });
192
+
193
+ // Sweeping 3 ineligible accounts must not produce 3 (or more) log
194
+ // lines through the tick's own logger — only the one aggregate
195
+ // "tick complete" summary. Per-account noise from the shared
196
+ // isAccountDeleted/isUnsyncableHost/isAccountReauthRequired helpers
197
+ // must be swallowed by a silent logger inside the sweep.
198
+ assert.equal(
199
+ calls.length,
200
+ 1,
201
+ `expected exactly one aggregate log line, got ${calls.length}: ${JSON.stringify(calls)}`,
202
+ );
203
+ });
204
+
205
+ it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
206
+ const due = baseAccount({ accountId: "acct_due" });
207
+ const accountService = fakeAccountService([{ items: [due], cursor: null }]);
208
+ const { sqsClient, sent } = fakeSqsClient();
209
+
210
+ await runSchedulerTick({
211
+ accountService,
212
+ sqsClient,
213
+ queueUrl:
214
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
215
+ log: createNoopLogger(),
216
+ tickIntervalMs: TICK_INTERVAL_MS,
217
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
218
+ now: NOW,
219
+ });
220
+
221
+ const dedupId = sent[0]?.input.MessageDeduplicationId;
222
+ assert.ok(dedupId?.startsWith("SYNC_MAILBOXES:scheduled:acct_due:"));
223
+ assert.notEqual(dedupId, "SYNC_MAILBOXES:acct_due");
224
+ });
225
+
226
+ it("terminates pagination and never revisits a page", async () => {
227
+ const accountService = fakeAccountService([
228
+ { items: [], cursor: "page2" },
229
+ { items: [], cursor: "page3" },
230
+ { items: [], cursor: null },
231
+ ]);
232
+ const { sqsClient } = fakeSqsClient();
233
+
234
+ const result = await runSchedulerTick({
235
+ accountService,
236
+ sqsClient,
237
+ queueUrl:
238
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
239
+ log: createNoopLogger(),
240
+ tickIntervalMs: TICK_INTERVAL_MS,
241
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
242
+ now: NOW,
243
+ });
244
+
245
+ assert.equal(result.scanned, 0);
246
+ assert.equal(result.enqueued, 0);
247
+ });
248
+ });