@remit/imap-worker 0.0.89 → 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 (171) 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} +24 -36
  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} +58 -84
  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 -224
  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 -127
  110. package/src/emit.ts +0 -117
  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 -128
  116. package/src/handlers/delete-account-objects.ts +0 -94
  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 -566
  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 -885
  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 -752
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -475
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -298
  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 -421
  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 -365
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -183
  145. package/src/handlers/sync-messages.ts +0 -525
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -86
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -234
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -166
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -60
  153. package/src/queues.ts +0 -49
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -75
  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 -794
  166. package/src/test-helpers/test-world.ts +0 -427
  167. package/src/thread-join.sqlite.test.ts +0 -106
  168. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  169. package/src/with-oauth-lifecycle-deps.ts +0 -71
  170. package/src/with-oauth-lifecycle.ts +0 -158
  171. package/tsconfig.json +0 -8
@@ -1,143 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { runSchedulerLoop, type SchedulerLoopOptions } from "./loop.js";
4
- import type { RunSchedulerTickDeps, SchedulerTickResult } from "./run-tick.js";
5
-
6
- const RESULT: SchedulerTickResult = {
7
- scanned: 0,
8
- enqueued: 0,
9
- skipped: 0,
10
- swept: 0,
11
- sweepFailed: 0,
12
- subscriptionsRefreshed: 0,
13
- subscriptionsFailed: 0,
14
- redriven: 0,
15
- redriveFailed: 0,
16
- };
17
-
18
- // Only the loop's own two calls matter here; the tick is a stub, so what it is
19
- // handed is never read.
20
- const TICK_DEPS = {} as RunSchedulerTickDeps;
21
-
22
- const buildLoop = (
23
- overrides: Partial<SchedulerLoopOptions>,
24
- ): SchedulerLoopOptions => ({
25
- tick: () => Promise.resolve(RESULT),
26
- tickDeps: TICK_DEPS,
27
- heartbeat: () => Promise.resolve(),
28
- onHeartbeatError: () => {},
29
- tickIntervalMs: 1000,
30
- wait: () => Promise.resolve(),
31
- ...overrides,
32
- });
33
-
34
- describe("runSchedulerLoop", () => {
35
- it("writes no heartbeat for a tick that throws", async () => {
36
- let beats = 0;
37
- await assert.rejects(
38
- runSchedulerLoop(
39
- buildLoop({
40
- tick: () => Promise.reject(new Error("tick failed")),
41
- heartbeat: () => {
42
- beats += 1;
43
- return Promise.resolve();
44
- },
45
- }),
46
- ),
47
- /tick failed/,
48
- );
49
- assert.equal(beats, 0);
50
- });
51
-
52
- it("beats once per completed round, and stops beating when a round stops completing", async () => {
53
- let rounds = 0;
54
- let beats = 0;
55
- await assert.rejects(
56
- runSchedulerLoop(
57
- buildLoop({
58
- tick: () => {
59
- rounds += 1;
60
- return rounds > 2
61
- ? Promise.reject(new Error("tick failed"))
62
- : Promise.resolve(RESULT);
63
- },
64
- heartbeat: () => {
65
- beats += 1;
66
- return Promise.resolve();
67
- },
68
- }),
69
- ),
70
- /tick failed/,
71
- );
72
- assert.equal(beats, 2);
73
- });
74
-
75
- it("keeps ticking when the heartbeat write fails", async () => {
76
- const errors: unknown[] = [];
77
- let rounds = 0;
78
- await assert.rejects(
79
- runSchedulerLoop(
80
- buildLoop({
81
- tick: () => {
82
- rounds += 1;
83
- return rounds > 3
84
- ? Promise.reject(new Error("done"))
85
- : Promise.resolve(RESULT);
86
- },
87
- heartbeat: () => Promise.reject(new Error("ENOSPC")),
88
- onHeartbeatError: (error) => errors.push(error),
89
- }),
90
- ),
91
- /done/,
92
- );
93
- assert.equal(rounds, 4);
94
- assert.equal(errors.length, 3);
95
- });
96
-
97
- it("waits a tick interval between rounds", async () => {
98
- const waited: number[] = [];
99
- let rounds = 0;
100
- await assert.rejects(
101
- runSchedulerLoop(
102
- buildLoop({
103
- tickIntervalMs: 300_000,
104
- tick: () => {
105
- rounds += 1;
106
- return rounds > 2
107
- ? Promise.reject(new Error("done"))
108
- : Promise.resolve(RESULT);
109
- },
110
- wait: (ms) => {
111
- waited.push(ms);
112
- return Promise.resolve();
113
- },
114
- }),
115
- ),
116
- /done/,
117
- );
118
- assert.deepEqual(waited, [300_000, 300_000]);
119
- });
120
-
121
- // The default is the only one production uses, so it is the one a wrong unit
122
- // or a dropped argument would ship in.
123
- it("sleeps the real timer when no wait is injected", async () => {
124
- let rounds = 0;
125
- const started = Date.now();
126
- await assert.rejects(
127
- runSchedulerLoop({
128
- ...buildLoop({
129
- tickIntervalMs: 25,
130
- tick: () => {
131
- rounds += 1;
132
- return rounds > 2
133
- ? Promise.reject(new Error("done"))
134
- : Promise.resolve(RESULT);
135
- },
136
- }),
137
- wait: undefined,
138
- }),
139
- /done/,
140
- );
141
- assert.ok(Date.now() - started >= 50);
142
- });
143
- });
@@ -1,191 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, it } from "node:test";
3
- import type { AccountItem } from "@remit/data-ports";
4
- import { recordingQueue } from "@remit/data-ports/recording-queue";
5
- import {
6
- AccountAuthType,
7
- AccountService,
8
- ConnectionState,
9
- } from "@remit/domain-enums";
10
- import {
11
- type AccountRepo,
12
- createSqliteRepositories,
13
- } from "@remit/drizzle-service";
14
- import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
15
- import { capturingLogger, type LogLine } from "../test-helpers/test-world.js";
16
- import { runSchedulerTick } from "./run-tick.js";
17
-
18
- const TICK_INTERVAL_MS = 60 * 60 * 1000;
19
- const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
20
- const NOW = 1_700_000_000_000;
21
-
22
- let accounts: AccountRepo;
23
- let close: () => void;
24
-
25
- beforeEach(() => {
26
- const store = createShippedSqliteDb();
27
- close = store.close;
28
- accounts = createSqliteRepositories({
29
- app: store.db,
30
- mail: store.db,
31
- search: store.db,
32
- }).account;
33
- });
34
-
35
- afterEach(() => close());
36
-
37
- const account = async (
38
- name: string,
39
- overrides: Partial<AccountItem> = {},
40
- stored: { credential: boolean } = { credential: true },
41
- ): Promise<AccountItem> => {
42
- const created = await accounts.create({
43
- accountConfigId: `cfg-${name}`,
44
- username: `${name}@example.com`,
45
- email: `${name}@example.com`,
46
- authType: AccountAuthType.Password,
47
- passwordHash: stored.credential ? '{"ciphertext":"x"}' : undefined,
48
- imapHost: "imap.example.com",
49
- imapPort: 993,
50
- imapTls: true,
51
- imapStartTls: false,
52
- isActive: true,
53
- connectionState: ConnectionState.Authenticated,
54
- } as Parameters<AccountRepo["create"]>[0]);
55
- if (Object.keys(overrides).length === 0) return created;
56
- await accounts.update(created.accountId, overrides);
57
- return accounts.get(created.accountId);
58
- };
59
-
60
- const tick = async (
61
- options: Partial<Parameters<typeof runSchedulerTick>[0]> = {},
62
- ) => {
63
- const queue = recordingQueue();
64
- const { sent } = queue;
65
- const logs: LogLine[] = [];
66
- const result = await runSchedulerTick({
67
- accountService: accounts,
68
- queue,
69
- log: capturingLogger(logs),
70
- tickIntervalMs: TICK_INTERVAL_MS,
71
- offlineIntervalMs: OFFLINE_INTERVAL_MS,
72
- now: NOW,
73
- redriveStrandedPlacements: async () => 0,
74
- ...options,
75
- });
76
- const enqueued = sent.map((message) => String(message.body.accountId));
77
- return { result, sent, enqueued, logs };
78
- };
79
-
80
- describe("a scheduler tick", () => {
81
- it("enqueues the accounts that are due and only those", async () => {
82
- const due = await account("due", {
83
- lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
84
- });
85
- const never = await account("never");
86
- await account("fresh", { lastSyncAt: NOW - 60_000 });
87
-
88
- const { enqueued } = await tick();
89
-
90
- assert.deepEqual(enqueued.sort(), [due.accountId, never.accountId].sort());
91
- });
92
-
93
- it("skips an account it cannot or should not sync, and says so once in aggregate", async () => {
94
- const live = await account("live");
95
- await account("unsyncable", { imapHost: "mail.invalid" });
96
- await account("reauth", {
97
- connectionState: ConnectionState.ReauthRequired,
98
- });
99
- await account("calendar", { syncedServices: [AccountService.Calendar] });
100
-
101
- const { enqueued, result, logs } = await tick();
102
-
103
- assert.deepEqual(enqueued, [live.accountId]);
104
- assert.equal(result.skipped, 3);
105
- assert.equal(logs.length, 1, "one aggregate line, no per-account noise");
106
- });
107
-
108
- // Issue #1120: a sync for an account with no stored credential fenced the
109
- // account's FIFO group for its whole visibility timeout.
110
- it("skips an account that stores no credential", async () => {
111
- const live = await account("live");
112
- await account("missing", {}, { credential: false });
113
-
114
- const { enqueued } = await tick();
115
-
116
- assert.deepEqual(enqueued, [live.accountId]);
117
- });
118
-
119
- it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
120
- const live = await account("live");
121
-
122
- const { sent } = await tick();
123
-
124
- const dedupId = sent[0]?.deduplicationId;
125
- assert.ok(
126
- dedupId?.startsWith(`SYNC_MAILBOXES:scheduled:${live.accountId}:`),
127
- );
128
- });
129
-
130
- it("sweeps every account's attachments, due a sync or not", async () => {
131
- const due = await account("due");
132
- const fresh = await account("fresh", { lastSyncAt: NOW - 60_000 });
133
- const swept: string[] = [];
134
-
135
- const { result } = await tick({
136
- sweepAttachments: async (item) => {
137
- swept.push(item.accountId);
138
- },
139
- });
140
-
141
- assert.deepEqual(swept.sort(), [due.accountId, fresh.accountId].sort());
142
- assert.equal(result.swept, 2);
143
- });
144
-
145
- // Uncontained, one storage error reaches the loop, which exits, and the
146
- // container restarts without ever enqueuing an account again.
147
- it("keeps enqueuing mail when the attachment sweep throws", async () => {
148
- const live = await account("live");
149
-
150
- const { enqueued, result } = await tick({
151
- sweepAttachments: async () => {
152
- throw new Error("AccessDenied on ListObjectsV2");
153
- },
154
- });
155
-
156
- assert.deepEqual(enqueued, [live.accountId]);
157
- assert.equal(result.sweepFailed, 1);
158
- });
159
-
160
- it("keeps the tick alive when the calendar subscription refresh throws", async () => {
161
- const live = await account("live");
162
-
163
- const { enqueued, logs } = await tick({
164
- refreshCalendarSubscriptions: async () => {
165
- throw new Error("database is locked");
166
- },
167
- });
168
-
169
- assert.deepEqual(enqueued, [live.accountId]);
170
- assert.ok(logs.some((line) => line.level === "error"));
171
- });
172
-
173
- it("counts what each account re-drove and keeps enqueuing past one that fails", async () => {
174
- const live = await account("live");
175
- const busy = await account("busy");
176
-
177
- const { enqueued, result, logs } = await tick({
178
- redriveStrandedPlacements: async (redriven) => {
179
- if (redriven.accountId === busy.accountId) {
180
- throw new Error("SQLITE_BUSY");
181
- }
182
- return 2;
183
- },
184
- });
185
-
186
- assert.deepEqual(enqueued.sort(), [live.accountId, busy.accountId].sort());
187
- assert.equal(result.redriven, 2);
188
- assert.equal(result.redriveFailed, 1);
189
- assert.ok(logs.some((line) => line.level === "error"));
190
- });
191
- });
@@ -1,281 +0,0 @@
1
- import {
2
- buildScheduledSyncDedupId,
3
- type SyncMailboxesEvent,
4
- triggerAccountSync,
5
- } from "@remit/backend/trigger-sync";
6
- import type {
7
- AccountItem,
8
- FifoQueueProducer,
9
- IAccountRepository,
10
- } from "@remit/data-ports";
11
- import type { Logger } from "@remit/logger";
12
- import { hasStoredCredential } from "@remit/mailbox-service/account-credentials";
13
- import pMap from "p-map";
14
- import {
15
- isAccountDeleted,
16
- isAccountReauthRequired,
17
- isMailSyncDisabled,
18
- isUnsyncableHost,
19
- } from "../account-check.js";
20
- import type { CalendarSubscriptionTally } from "./calendar-subscriptions.js";
21
- import {
22
- SCHEDULER_ENQUEUE_CONCURRENCY,
23
- SCHEDULER_PAGE_SIZE,
24
- } from "./config.js";
25
- import { isSyncDue } from "./decide-due.js";
26
-
27
- export interface RunSchedulerTickDeps {
28
- accountService: Pick<IAccountRepository, "listAllAccountsPage">;
29
- queue: FifoQueueProducer<SyncMailboxesEvent>;
30
- log: Logger;
31
- offlineIntervalMs: number;
32
- /**
33
- * How often this tick itself runs — the dedup-id bucket width for
34
- * `buildScheduledSyncDedupId`, so consecutive ticks each get a fresh id.
35
- */
36
- tickIntervalMs: number;
37
- /** Injectable for tests; defaults to `Date.now()`. */
38
- now?: number;
39
- /**
40
- * Collect attachment bytes the database does not know about, for one account.
41
- *
42
- * The tick already walks every account on a cadence, which is the only thing
43
- * this needs. It exists because an upload URL minted for a draft stays valid
44
- * after that draft is discarded, and where the browser writes straight to
45
- * block storage nothing is in the path to refuse it — see
46
- * `sweepAbandonedOutboxAttachments`. Optional only so a test can leave it out;
47
- * both real entry points pass it.
48
- */
49
- sweepAttachments?: (account: AccountItem) => Promise<void>;
50
- /**
51
- * Re-read the calendar subscriptions whose last fetch is older than
52
- * `offlineIntervalMs` — the same knob that says how stale mail may get.
53
- * Optional only so a test can leave it out; both real entry points pass it.
54
- */
55
- refreshCalendarSubscriptions?: (
56
- now: number,
57
- intervalMs: number,
58
- ) => Promise<CalendarSubscriptionTally>;
59
- redriveStrandedPlacements: (
60
- account: AccountItem,
61
- now: number,
62
- ) => Promise<number>;
63
- }
64
-
65
- export interface SchedulerTickResult {
66
- scanned: number;
67
- enqueued: number;
68
- skipped: number;
69
- swept: number;
70
- sweepFailed: number;
71
- subscriptionsRefreshed: number;
72
- subscriptionsFailed: number;
73
- redriven: number;
74
- redriveFailed: number;
75
- }
76
-
77
- /**
78
- * `isAccountDeleted` / `isUnsyncableHost` / `isAccountReauthRequired` each log
79
- * one line per ineligible account — the right volume for their real call site
80
- * (once per SYNC_MAILBOXES event). Run across the whole account base every
81
- * tick, that becomes one log line per deleted/reauth/placeholder account
82
- * every 5 minutes, forever (review #1250). The tick already reports the
83
- * aggregate `skipped` count, so eligibility checks here go through a silent
84
- * logger — the per-event path (sync-mailboxes.ts) is untouched and keeps
85
- * logging normally.
86
- */
87
- const silentLogger: Logger = (() => {
88
- const noop = () => {};
89
- const stub = {
90
- info: noop,
91
- warn: noop,
92
- error: noop,
93
- debug: noop,
94
- fatal: noop,
95
- trace: noop,
96
- child: () => stub,
97
- };
98
- return stub as unknown as Logger;
99
- })();
100
-
101
- const isEligible = (account: AccountItem): boolean => {
102
- if (isAccountDeleted(account, silentLogger)) return false;
103
- if (isMailSyncDisabled(account, silentLogger)) return false;
104
- if (isUnsyncableHost(account, silentLogger)) return false;
105
- if (isAccountReauthRequired(account, silentLogger)) return false;
106
- // An account storing no credential can only produce a sync that fails at
107
- // credential resolution (issue #1120); the tick would enqueue one every
108
- // tick, forever, for an account the user never finished setting up.
109
- if (!hasStoredCredential(account)) return false;
110
- return true;
111
- };
112
-
113
- /**
114
- * One tick of the periodic mailbox-sync scheduler (#1247, restructured
115
- * #1251). Pages through every account (never loading the whole account base
116
- * into memory), decides per account via `isSyncDue` against the single
117
- * offline threshold, and enqueues SYNC_MAILBOXES for the accounts that are
118
- * due — in bounded-concurrency batches, never an unbounded `Promise.all`.
119
- *
120
- * Every enqueue goes through the same `triggerAccountSync` the manual
121
- * POST /sync path uses, with a scheduler-specific, time-bucketed dedup id
122
- * (`buildScheduledSyncDedupId`, bucketed by `tickIntervalMs`) so this tick
123
- * can never collide with its own previous tick or with a concurrent manual
124
- * trigger (see trigger-sync.ts). Concurrent-sync safety for the mailbox
125
- * itself is MailboxLockService's job, inside the worker handler — this tick
126
- * only decides "is a sync due" and enqueues; it never talks to IMAP.
127
- */
128
- export const runSchedulerTick = async (
129
- deps: RunSchedulerTickDeps,
130
- ): Promise<SchedulerTickResult> => {
131
- const { accountService, queue, log, offlineIntervalMs, tickIntervalMs } =
132
- deps;
133
- const now = deps.now ?? Date.now();
134
-
135
- let cursor: string | undefined;
136
- let scanned = 0;
137
- let enqueued = 0;
138
- let skipped = 0;
139
- let swept = 0;
140
- let sweepFailed = 0;
141
- let redriven = 0;
142
- let redriveFailed = 0;
143
-
144
- do {
145
- const page = await accountService.listAllAccountsPage({
146
- limit: SCHEDULER_PAGE_SIZE,
147
- cursor,
148
- });
149
- scanned += page.items.length;
150
-
151
- const due = page.items.filter(
152
- (account) =>
153
- isEligible(account) && isSyncDue(account, now, offlineIntervalMs),
154
- );
155
- skipped += page.items.length - due.length;
156
-
157
- await pMap(
158
- due,
159
- (account) =>
160
- triggerAccountSync({
161
- queue,
162
- accountId: account.accountId,
163
- dedupId: buildScheduledSyncDedupId(
164
- account.accountId,
165
- now,
166
- tickIntervalMs,
167
- ),
168
- }),
169
- { concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
170
- );
171
- enqueued += due.length;
172
-
173
- if (deps.sweepAttachments) {
174
- // After the enqueue, never before it. Enqueuing mail is the tick's job
175
- // and collecting bytes is housekeeping; on a hosted Lambda a slow sweep
176
- // ahead of the enqueue eats the invocation budget, and a timeout is not
177
- // catchable, so the remaining pages would never be enqueued at all.
178
- //
179
- // Every account, not only the ones due a sync: abandoned bytes have
180
- // nothing to do with how recently mail was fetched. Bounded to the same
181
- // concurrency as the enqueues.
182
- //
183
- // Contained, because this is housekeeping and enqueuing mail is not. An
184
- // unreadable input or a storage permission error thrown from here would
185
- // otherwise reach the loop, which logs and exits, and compose would
186
- // restart the process every five seconds — no account would ever be
187
- // enqueued again. A failed sweep costs a tick's collection and nothing
188
- // else; the count is what makes that visible.
189
- const sweep = deps.sweepAttachments;
190
- const outcomes = await pMap(
191
- page.items,
192
- (account) =>
193
- sweep(account).then(
194
- () => true,
195
- (error: unknown) => {
196
- log.error(
197
- { accountId: account.accountId, error },
198
- "Outbox attachment sweep failed for account",
199
- );
200
- return false;
201
- },
202
- ),
203
- { concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
204
- );
205
- swept += outcomes.filter(Boolean).length;
206
- sweepFailed += outcomes.filter((ok) => !ok).length;
207
- }
208
-
209
- // Contained like the sweep, and for the same reason: a failed re-drive
210
- // costs one account this tick, never the scheduler. Eligible accounts
211
- // only, since a re-driven event needs a connection to settle anything.
212
- const redrives = await pMap(
213
- page.items.filter(isEligible),
214
- (account) =>
215
- deps.redriveStrandedPlacements(account, now).then(
216
- (count) => count,
217
- (error: unknown) => {
218
- log.error(
219
- { accountId: account.accountId, error },
220
- "Stranded placement re-drive failed for account",
221
- );
222
- return null;
223
- },
224
- ),
225
- { concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
226
- );
227
- for (const count of redrives) {
228
- if (count === null) {
229
- redriveFailed += 1;
230
- continue;
231
- }
232
- redriven += count;
233
- }
234
-
235
- cursor = page.cursor ?? undefined;
236
- } while (cursor);
237
-
238
- let subscriptionsRefreshed = 0;
239
- let subscriptionsFailed = 0;
240
- if (deps.refreshCalendarSubscriptions) {
241
- // After every page is enqueued, for the same reason the sweep runs after
242
- // the enqueue: a slow feed must never cost an account its mail. Contained
243
- // like the sweep, so an unreadable store costs a tick's refresh and not
244
- // the scheduler.
245
- const tally = await deps
246
- .refreshCalendarSubscriptions(now, offlineIntervalMs)
247
- .catch((error: unknown) => {
248
- log.error({ error }, "Calendar subscription refresh failed");
249
- return { refreshed: 0, failed: 0, notDue: 0 };
250
- });
251
- subscriptionsRefreshed = tally.refreshed;
252
- subscriptionsFailed = tally.failed;
253
- }
254
-
255
- log.info(
256
- {
257
- scanned,
258
- enqueued,
259
- skipped,
260
- swept,
261
- sweepFailed,
262
- subscriptionsRefreshed,
263
- subscriptionsFailed,
264
- redriven,
265
- redriveFailed,
266
- },
267
- "Scheduled-sync tick complete",
268
- );
269
-
270
- return {
271
- scanned,
272
- enqueued,
273
- skipped,
274
- swept,
275
- sweepFailed,
276
- subscriptionsRefreshed,
277
- subscriptionsFailed,
278
- redriven,
279
- redriveFailed,
280
- };
281
- };