@remit/imap-worker 0.0.86 → 0.0.87

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 (71) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +16 -18
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +115 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -56,7 +56,6 @@ describe("mailboxNeedsSync", () => {
56
56
  describe("resolveMailboxFreshnessMs", () => {
57
57
  it("defaults to the production window when unset", () => {
58
58
  assert.equal(resolveMailboxFreshnessMs({}), DEFAULT_MAILBOX_FRESHNESS_MS);
59
- assert.equal(DEFAULT_MAILBOX_FRESHNESS_MS, 60_000);
60
59
  });
61
60
 
62
61
  it("honors a valid override", () => {
@@ -13,7 +13,7 @@ import type {
13
13
  } from "@remit/data-ports";
14
14
  import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
15
15
  import { SyncPhase } from "@remit/domain-enums";
16
- import type { Logger } from "@remit/logger-lambda";
16
+ import type { Logger } from "@remit/logger";
17
17
  import { RefreshTokenError } from "@remit/mail-oauth-service";
18
18
  import {
19
19
  createConnectionWithCredentials,
@@ -0,0 +1,314 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
3
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
4
+ import type { MailboxItem } from "@remit/data-ports";
5
+ import {
6
+ FilterClauseField,
7
+ FilterScope,
8
+ MailboxCursorState,
9
+ MessageStatus,
10
+ } from "@remit/domain-enums";
11
+ import { isMessageBodySyncBroken } from "@remit/mailbox-service";
12
+ import { mockClient } from "aws-sdk-client-mock";
13
+ import { resetBodySyncGateCache } from "../body-sync-gate.js";
14
+ import type { SyncMessageBodyEvent } from "../events.js";
15
+ import {
16
+ openStore,
17
+ type SeededMessage,
18
+ type World,
19
+ } from "../test-helpers/test-world.js";
20
+ import {
21
+ BODY_SYNC_MAX_ATTEMPTS,
22
+ syncMessageBody,
23
+ } from "./sync-message-body.js";
24
+
25
+ const ssm = mockClient(SSMClient);
26
+
27
+ let store: Awaited<ReturnType<typeof openStore>>;
28
+
29
+ before(async () => {
30
+ store = await openStore();
31
+ });
32
+
33
+ after(() => store.close());
34
+
35
+ const bodySync = (enabled: boolean) =>
36
+ ssm
37
+ .on(GetParameterCommand)
38
+ .resolves({ Parameter: { Value: enabled ? "true" : "false" } });
39
+
40
+ beforeEach(() => {
41
+ ssm.reset();
42
+ resetBodySyncGateCache();
43
+ bodySync(true);
44
+ });
45
+
46
+ afterEach(() => resetBodySyncGateCache());
47
+
48
+ interface Arranged {
49
+ w: World;
50
+ inbox: MailboxItem;
51
+ message: SeededMessage;
52
+ event: SyncMessageBodyEvent;
53
+ }
54
+
55
+ const arrange = async (): Promise<Arranged> => {
56
+ const w = await store.world();
57
+ const inbox = await w.folder("INBOX");
58
+ const message = await w.message(inbox);
59
+ return {
60
+ w,
61
+ inbox,
62
+ message,
63
+ event: {
64
+ type: "SYNC_MESSAGE_BODY",
65
+ eventId: `evt-${message.messageId}`,
66
+ timestamp: Date.now(),
67
+ accountId: w.account.accountId,
68
+ mailboxId: inbox.mailboxId,
69
+ messageIds: [message.messageId],
70
+ messages: [{ messageId: message.messageId, uid: message.uid }],
71
+ },
72
+ };
73
+ };
74
+
75
+ const run = (a: Arranged, receiveCount = 1) =>
76
+ syncMessageBody(a.event, a.w.log, receiveCount, a.w.scope);
77
+
78
+ const bodyFetches = (a: Arranged): string[] => a.w.imap.commands("BODY");
79
+
80
+ describe("a body sync", () => {
81
+ it("fetches the body and stores it against the message", async () => {
82
+ const a = await arrange();
83
+
84
+ await run(a);
85
+
86
+ assert.equal(bodyFetches(a).length, 1);
87
+ assert.ok((await a.w.row(a.message.messageId))?.bodyStorageKey);
88
+ });
89
+
90
+ // Issue #1270: swallowing the failure into a fresh enqueue meant the queue's
91
+ // own redelivery and dead-letter queue never engaged.
92
+ it("throws within the budget when the server cannot return the body, so the queue redelivers", async () => {
93
+ const a = await arrange();
94
+ a.w.imap.failNext.BODY = new Error("Connection reset");
95
+
96
+ await assert.rejects(run(a), /Connection reset/);
97
+
98
+ assert.equal(
99
+ (await a.w.row(a.message.messageId))?.bodyStorageKey,
100
+ undefined,
101
+ );
102
+ });
103
+
104
+ it("fetches nothing while body sync is paused", async () => {
105
+ const a = await arrange();
106
+ ssm.reset();
107
+ bodySync(false);
108
+
109
+ await run(a);
110
+
111
+ assert.equal(a.w.imap.connections, 0);
112
+ });
113
+
114
+ it("acks without connecting when the mailbox was deleted", async () => {
115
+ const a = await arrange();
116
+ await a.w.client.mailbox.delete(a.w.account.accountId, a.inbox.mailboxId);
117
+
118
+ await run(a);
119
+
120
+ assert.equal(a.w.imap.connections, 0);
121
+ });
122
+
123
+ it("fetches nothing while the cursor is rebuilding", async () => {
124
+ const a = await arrange();
125
+ await a.w.client.mailbox.update(a.w.account.accountId, a.inbox.mailboxId, {
126
+ cursorState: MailboxCursorState.rebuilding,
127
+ });
128
+
129
+ await run(a);
130
+
131
+ assert.equal(a.w.imap.connections, 0);
132
+ });
133
+
134
+ it("fetches nothing when the mailbox's UIDVALIDITY changed under it", async () => {
135
+ const a = await arrange();
136
+ a.w.imap.setUidValidity("INBOX", 2);
137
+
138
+ await run(a);
139
+
140
+ assert.deepEqual(bodyFetches(a), []);
141
+ assert.equal(
142
+ (await a.w.row(a.message.messageId))?.bodyStorageKey,
143
+ undefined,
144
+ );
145
+ });
146
+ });
147
+
148
+ const batchOf = (
149
+ w: World,
150
+ inbox: MailboxItem,
151
+ messages: SeededMessage[],
152
+ ): SyncMessageBodyEvent => ({
153
+ type: "SYNC_MESSAGE_BODY",
154
+ eventId: `evt-${messages.map((m) => m.messageId).join("-")}`,
155
+ timestamp: Date.now(),
156
+ accountId: w.account.accountId,
157
+ mailboxId: inbox.mailboxId,
158
+ messageIds: messages.map((m) => m.messageId),
159
+ messages: messages.map((m) => ({ messageId: m.messageId, uid: m.uid })),
160
+ });
161
+
162
+ const runBatch = (w: World, event: SyncMessageBodyEvent, receiveCount = 1) =>
163
+ syncMessageBody(event, w.log, receiveCount, w.scope);
164
+
165
+ const arrangeFiling = async () => {
166
+ const w = await store.world();
167
+ const inbox = await w.folder("INBOX");
168
+ const travel = await w.folder("Travel");
169
+ await w.client.filter.create({
170
+ accountConfigId: w.account.accountConfigId,
171
+ name: "From a friend",
172
+ scope: FilterScope.Standing,
173
+ literalClauses: [
174
+ { field: FilterClauseField.From, value: "friend@example.com" },
175
+ ],
176
+ actionMailboxId: travel.mailboxId,
177
+ });
178
+ const first = await w.message(inbox);
179
+ const second = await w.message(inbox);
180
+ return {
181
+ w,
182
+ travel,
183
+ first,
184
+ second,
185
+ event: batchOf(w, inbox, [first, second]),
186
+ };
187
+ };
188
+
189
+ describe("a body batch whose filter files mail while the queue is refusing", () => {
190
+ it("fails at the first filing it cannot enqueue and leaves the rest untouched", async () => {
191
+ const { w, first, second, event } = await arrangeFiling();
192
+ w.refuseSends(new Error("connect ECONNREFUSED 127.0.0.1:9324"));
193
+
194
+ await assert.rejects(runBatch(w, event), /ECONNREFUSED/);
195
+
196
+ const untouched = await w.row(second.messageId);
197
+ assert.equal(untouched?.status, MessageStatus.active);
198
+ assert.equal(untouched?.bodyStorageKey, undefined);
199
+ assert.equal(await w.client.placementMove.find(second.messageId), null);
200
+ assert.equal(
201
+ (await w.client.placementMove.find(first.messageId))?.state,
202
+ "pending",
203
+ );
204
+ });
205
+
206
+ it("files and stores the whole batch on the redelivery once the queue accepts", async () => {
207
+ const { w, travel, first, second, event } = await arrangeFiling();
208
+ w.refuseSends(new Error("connect ECONNREFUSED 127.0.0.1:9324"));
209
+ await assert.rejects(runBatch(w, event), /ECONNREFUSED/);
210
+
211
+ w.refuseSends(undefined);
212
+ await runBatch(w, event, 2);
213
+
214
+ for (const message of [first, second]) {
215
+ const row = await w.row(message.messageId);
216
+ assert.equal(row?.mailboxId, travel.mailboxId);
217
+ assert.ok(row?.bodyStorageKey, `${message.messageId} body stored`);
218
+ assert.equal(row?.movedByRemit, true);
219
+ assert.equal(row?.filterMove?.sourceMailboxId, event.mailboxId);
220
+ assert.equal(
221
+ (await w.client.placementMove.find(message.messageId))?.state,
222
+ "queued",
223
+ );
224
+ }
225
+ });
226
+ });
227
+
228
+ describe("a body batch on its last attempt while the queue is refusing", () => {
229
+ it("throws, so the batch dead-letters instead of its messages being marked broken", async () => {
230
+ const { w, first: message, event } = await arrangeFiling();
231
+ w.refuseSends(new Error("connect ECONNREFUSED 127.0.0.1:9324"));
232
+
233
+ await assert.rejects(
234
+ runBatch(w, event, BODY_SYNC_MAX_ATTEMPTS),
235
+ /ECONNREFUSED/,
236
+ );
237
+
238
+ assert.equal(
239
+ await isMessageBodySyncBroken(
240
+ w.client.storage,
241
+ w.account.accountConfigId,
242
+ w.account.accountId,
243
+ message.messageId,
244
+ ),
245
+ false,
246
+ );
247
+ });
248
+ });
249
+
250
+ const startMove = (
251
+ w: World,
252
+ message: SeededMessage,
253
+ from: MailboxItem,
254
+ to: MailboxItem,
255
+ ) =>
256
+ w.client.message.transitionPlacement(
257
+ message.messageId,
258
+ {
259
+ status: MessageStatus.active,
260
+ mailboxId: from.mailboxId,
261
+ uid: message.uid,
262
+ },
263
+ {
264
+ mailboxId: to.mailboxId,
265
+ status: MessageStatus.moving,
266
+ originalMailboxId: from.mailboxId,
267
+ originalUid: message.uid,
268
+ },
269
+ );
270
+
271
+ describe("a body batch that has gone stale", () => {
272
+ it("does not fetch a message from a folder its confirmed move took it out of", async () => {
273
+ const w = await store.world();
274
+ const inbox = await w.folder("INBOX");
275
+ const travel = await w.folder("Travel");
276
+ const moved = await w.message(inbox);
277
+ const neighbour = await w.message(inbox);
278
+ await startMove(w, moved, inbox, travel);
279
+ await w.client.message.updateUid(
280
+ moved.messageId,
281
+ neighbour.uid,
282
+ travel.mailboxId,
283
+ );
284
+
285
+ await runBatch(w, batchOf(w, inbox, [moved]));
286
+
287
+ assert.deepEqual(w.imap.commands("BODY"), []);
288
+ assert.equal((await w.row(moved.messageId))?.bodyStorageKey, undefined);
289
+ });
290
+
291
+ it("fetches a message whose move has not confirmed from the folder its uid is in", async () => {
292
+ const w = await store.world();
293
+ const inbox = await w.folder("INBOX");
294
+ const travel = await w.folder("Travel");
295
+ const filing = await w.message(inbox);
296
+ await startMove(w, filing, inbox, travel);
297
+
298
+ await runBatch(w, batchOf(w, inbox, [filing]));
299
+
300
+ assert.equal(w.imap.commands("BODY").length, 1);
301
+ assert.ok((await w.row(filing.messageId))?.bodyStorageKey);
302
+ });
303
+
304
+ it("acks a batch whose message row has been deleted since", async () => {
305
+ const w = await store.world();
306
+ const inbox = await w.folder("INBOX");
307
+ const gone = await w.message(inbox);
308
+ await w.client.message.delete(gone.messageId);
309
+
310
+ await runBatch(w, batchOf(w, inbox, [gone]));
311
+
312
+ assert.equal(w.imap.connections, 0);
313
+ });
314
+ });
@@ -1,37 +1,12 @@
1
1
  import assert from "node:assert";
2
- import { afterEach, before, describe, mock, test } from "node:test";
3
- import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
- import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
5
- import { getClient, type RemitClient, setClient } from "@remit/backend/client";
6
- import type { AccountItem, MailboxItem } from "@remit/data-ports";
7
- import type { Logger } from "@remit/logger-lambda";
8
- import { BodySyncService, type SyncedMessage } from "@remit/mailbox-service";
9
- import { mockClient } from "aws-sdk-client-mock";
10
- import { resetBodySyncGateCache } from "../body-sync-gate.js";
11
- import { __warmPoolSizeForTest } from "../connection-scope.js";
2
+ import { describe, test } from "node:test";
3
+ import type { SyncedMessage } from "@remit/mailbox-service";
12
4
  import type { SyncMessageBodyEvent } from "../events.js";
13
5
  import {
14
- BODY_SYNC_MAX_ATTEMPTS,
15
6
  buildRetryableFailureError,
16
- getBodySyncMaxAttempts,
17
7
  resolveBatch,
18
- syncMessageBody,
19
8
  } from "./sync-message-body.js";
20
- import { BODY_BATCH_SIZE, batchSyncedMessages } from "./sync-messages.js";
21
-
22
- const silentLogger = (() => {
23
- const noop = () => {};
24
- const log = {
25
- info: noop,
26
- warn: noop,
27
- error: noop,
28
- debug: noop,
29
- fatal: noop,
30
- trace: noop,
31
- child: () => log,
32
- } as unknown as Logger;
33
- return log;
34
- })();
9
+ import { batchSyncedMessages } from "./sync-messages.js";
35
10
 
36
11
  const baseEvent = {
37
12
  type: "SYNC_MESSAGE_BODY" as const,
@@ -135,273 +110,6 @@ describe("buildRetryableFailureError — the DLQ-propagation signal", () => {
135
110
  assert.match(error.message, /msg-2/);
136
111
  assert.match(error.message, /attempt 1\/3/);
137
112
  });
138
-
139
- test("is a real Error instance so it propagates like any other failure", () => {
140
- const error = buildRetryableFailureError(["msg-1"], 2);
141
- assert.ok(error instanceof Error);
142
- });
143
-
144
- test("BODY_SYNC_MAX_ATTEMPTS matches the body queue's maxReceiveCount (3)", () => {
145
- // See MAX_RECEIVE_COUNT in infra/stacks/dev/stacks/remit-queue-stack.ts.
146
- assert.equal(BODY_SYNC_MAX_ATTEMPTS, 3);
147
- });
148
- });
149
-
150
- describe("getBodySyncMaxAttempts — env-derived, CDK-injected threshold (#1270)", () => {
151
- test("parses the CDK-injected env var (derived from the queue's MAX_RECEIVE_COUNT)", () => {
152
- assert.equal(getBodySyncMaxAttempts({ BODY_SYNC_MAX_ATTEMPTS: "3" }), 3);
153
- assert.equal(getBodySyncMaxAttempts({ BODY_SYNC_MAX_ATTEMPTS: "5" }), 5);
154
- });
155
-
156
- test("defaults to 3 when unset (local dev, unit tests) — matches the queue's own default", () => {
157
- assert.equal(getBodySyncMaxAttempts({}), 3);
158
- });
159
-
160
- test("defaults to 3 on a non-numeric or non-positive value", () => {
161
- assert.equal(getBodySyncMaxAttempts({ BODY_SYNC_MAX_ATTEMPTS: "nope" }), 3);
162
- assert.equal(getBodySyncMaxAttempts({ BODY_SYNC_MAX_ATTEMPTS: "0" }), 3);
163
- assert.equal(getBodySyncMaxAttempts({ BODY_SYNC_MAX_ATTEMPTS: "-1" }), 3);
164
- });
165
- });
166
-
167
- describe("syncMessageBody — pause gate runs before connection reuse", () => {
168
- afterEach(() => {
169
- mockClient(SSMClient).reset();
170
- resetBodySyncGateCache();
171
- });
172
-
173
- test("paused: acks-and-skips before borrowing a warm connection", async () => {
174
- const accountId = "paused-account-zzz";
175
- mockClient(SSMClient)
176
- .on(GetParameterCommand)
177
- .resolves({ Parameter: { Value: "false" } });
178
-
179
- const event: SyncMessageBodyEvent = {
180
- ...baseEvent,
181
- accountId,
182
- messageIds: ["msg-1"],
183
- messages: [{ messageId: "msg-1", uid: 101 }],
184
- };
185
-
186
- await syncMessageBody(event, silentLogger);
187
-
188
- // Returning at the gate must not touch the warm pool (no account lookup,
189
- // no IMAP connection) — proves the gate is first.
190
- assert.strictEqual(
191
- __warmPoolSizeForTest(accountId),
192
- 0,
193
- "paused handler must not create a warm connection",
194
- );
195
- });
196
- });
197
-
198
- describe("syncMessageBody — DLQ propagation (integrated, #1270)", () => {
199
- const accountId = "cap-account-zzz";
200
- const mailboxId = "cap-mailbox-zzz";
201
-
202
- const cappedAccount = (): AccountItem =>
203
- ({
204
- accountId,
205
- accountConfigId: "cap-acfg-zzz",
206
- connectionState: "authenticated",
207
- username: "cap@imap.example.com",
208
- imapHost: "imap.example.com",
209
- imapPort: 993,
210
- imapTls: true,
211
- // deserializeEncryptedPayload just needs base64-decodable strings; the
212
- // mocked secrets.decrypt below never inspects the actual bytes.
213
- passwordHash: JSON.stringify({
214
- encryptedDek: "",
215
- encryptedData: "",
216
- iv: "",
217
- authTag: "",
218
- }),
219
- }) as unknown as AccountItem;
220
-
221
- const cappedMailbox = (): MailboxItem =>
222
- ({ fullPath: "INBOX" }) as unknown as MailboxItem;
223
-
224
- // The client is supplied by injection (`setClient`), so these tests register
225
- // the repositories they then mock rather than reaching a composition.
226
- before(() => {
227
- setClient({
228
- account: { get: async () => cappedAccount() },
229
- mailbox: { get: async () => cappedMailbox() },
230
- mailboxSpecialUse: {
231
- findJunkMailbox: async () => null,
232
- findInboxMailbox: async () => null,
233
- },
234
- message: {},
235
- threadMessage: {},
236
- address: {},
237
- envelope: {},
238
- placementMove: {},
239
- filter: {},
240
- filterAnchor: {},
241
- messageLabel: {},
242
- storage: {},
243
- secrets: { decrypt: async () => "fake-password" },
244
- } as unknown as RemitClient);
245
- });
246
-
247
- afterEach(() => {
248
- mock.restoreAll();
249
- mockClient(SSMClient).reset();
250
- mockClient(SQSClient).reset();
251
- resetBodySyncGateCache();
252
- });
253
-
254
- test("below BODY_SYNC_MAX_ATTEMPTS: throws instead of re-enqueueing, so SQS redelivers the record (issue #1270)", async () => {
255
- // This is the mechanism that lets a genuine processing failure ever reach
256
- // the body-dlq: the handler used to swallow every failure into a fresh
257
- // SQS SendMessage and always return successfully, so the queue's own
258
- // maxReceiveCount/DLQ never engaged. Throwing here is what index.ts's SQS
259
- // handler turns into a batchItemFailure, which SQS then redelivers.
260
- mockClient(SSMClient)
261
- .on(GetParameterCommand)
262
- .resolves({ Parameter: { Value: "true" } });
263
- const sqsMock = mockClient(SQSClient);
264
-
265
- mock.method((await getClient()).account, "get", async () =>
266
- cappedAccount(),
267
- );
268
- mock.method((await getClient()).mailbox, "get", async () =>
269
- cappedMailbox(),
270
- );
271
- mock.method(
272
- (await getClient()).secrets,
273
- "decrypt",
274
- async () => "fake-password",
275
- );
276
- mock.method(BodySyncService.prototype, "syncBodies", async () => ({
277
- syncedCount: 0,
278
- syncedMessageIds: [],
279
- skippedCount: 0,
280
- failedCount: 1,
281
- failedMessageIds: ["msg-1"],
282
- }));
283
-
284
- const event: SyncMessageBodyEvent = {
285
- ...baseEvent,
286
- accountId,
287
- mailboxId,
288
- messageIds: ["msg-1"],
289
- messages: [{ messageId: "msg-1", uid: 101 }],
290
- };
291
-
292
- await assert.rejects(
293
- () => syncMessageBody(event, silentLogger, 1),
294
- (err: unknown) =>
295
- err instanceof Error &&
296
- /Body sync failed for 1 message/.test(err.message),
297
- );
298
-
299
- assert.equal(
300
- sqsMock.commandCalls(SendMessageCommand).length,
301
- 0,
302
- "no manual re-enqueue — SQS's own redelivery owns the retry now",
303
- );
304
- });
305
-
306
- test("deleted mailbox: acks-and-skips without ever calling BodySyncService.syncBodies (#287/#289)", async () => {
307
- // A SYNC_MESSAGE_BODY trigger outlived the folder it targeted. The mailbox
308
- // lookup throws NotFoundError, which would poison the account's per-group
309
- // body FIFO forever; the handler must resolve it terminally instead.
310
- mockClient(SSMClient)
311
- .on(GetParameterCommand)
312
- .resolves({ Parameter: { Value: "true" } });
313
-
314
- mock.method((await getClient()).account, "get", async () =>
315
- cappedAccount(),
316
- );
317
- mock.method((await getClient()).mailbox, "get", async () => {
318
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
319
- name: "NotFoundError",
320
- });
321
- });
322
- mock.method(
323
- (await getClient()).secrets,
324
- "decrypt",
325
- async () => "fake-password",
326
- );
327
- const syncBodies = mock.method(
328
- BodySyncService.prototype,
329
- "syncBodies",
330
- async () => {
331
- throw new Error("must not be called for a deleted mailbox");
332
- },
333
- );
334
-
335
- const event: SyncMessageBodyEvent = {
336
- ...baseEvent,
337
- accountId,
338
- mailboxId,
339
- messageIds: ["msg-1"],
340
- messages: [{ messageId: "msg-1", uid: 101 }],
341
- };
342
-
343
- // Must resolve, not reject — a deleted folder is an expected terminal
344
- // outcome, not a fault to retry/DLQ.
345
- await syncMessageBody(event, silentLogger, 1);
346
-
347
- assert.equal(
348
- syncBodies.mock.calls.length,
349
- 0,
350
- "a deleted mailbox must never reach the body fetch",
351
- );
352
- });
353
-
354
- test("cursor_invalid: acks-and-skips without ever calling BodySyncService.syncBodies (#1272)", async () => {
355
- // The cheap pre-check (isCursorRebuildNeeded, run before borrowing any
356
- // connection — frugal, epic #1281 invariant 6) catches an already-paused
357
- // mailbox before BodySyncService is ever invoked. The structural backstop
358
- // for a cursor that trips *during* this call — guardConnectionCursor's
359
- // openBox override — is covered directly in mailbox-cursor.test.ts.
360
- mockClient(SSMClient)
361
- .on(GetParameterCommand)
362
- .resolves({ Parameter: { Value: "true" } });
363
-
364
- mock.method((await getClient()).account, "get", async () =>
365
- cappedAccount(),
366
- );
367
- mock.method((await getClient()).mailbox, "get", async () => ({
368
- ...cappedMailbox(),
369
- mailboxId,
370
- cursorState: "cursor_invalid",
371
- }));
372
- mock.method(
373
- (await getClient()).secrets,
374
- "decrypt",
375
- async () => "fake-password",
376
- );
377
- const syncBodies = mock.method(
378
- BodySyncService.prototype,
379
- "syncBodies",
380
- async () => {
381
- throw new Error(
382
- "must not be called while the mailbox cursor is paused",
383
- );
384
- },
385
- );
386
-
387
- const event: SyncMessageBodyEvent = {
388
- ...baseEvent,
389
- accountId,
390
- mailboxId,
391
- messageIds: ["msg-1"],
392
- messages: [{ messageId: "msg-1", uid: 101 }],
393
- };
394
-
395
- // Must resolve, not reject — a paused cursor is a routine, expected pause
396
- // (epic #1281 invariant 3), not a fault to retry/DLQ.
397
- await syncMessageBody(event, silentLogger, 1);
398
-
399
- assert.equal(
400
- syncBodies.mock.calls.length,
401
- 0,
402
- "outbound body fetch must not run while the mailbox cursor is paused",
403
- );
404
- });
405
113
  });
406
114
 
407
115
  describe("batchSyncedMessages — one batch == one ranged fetch", () => {
@@ -411,10 +119,6 @@ describe("batchSyncedMessages — one batch == one ranged fetch", () => {
411
119
  uid: i + 1,
412
120
  }));
413
121
 
414
- test("batch size is raised to 200", () => {
415
- assert.equal(BODY_BATCH_SIZE, 200);
416
- });
417
-
418
122
  test("packs up to 200 messages into a single batch", () => {
419
123
  const batches = batchSyncedMessages(makeSynced(200));
420
124