@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.
- package/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.ts +1 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +27 -33
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +16 -18
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -569
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- 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
|
|
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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
|