@remit/imap-worker 0.0.17 → 0.0.19
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
|
|
21
21
|
"cli": "node --env-file=../../localhost-dev-aws.env src/cli.ts",
|
|
22
22
|
"test:typecheck": "tsgo --noEmit",
|
|
23
|
-
"test:run": "node --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=
|
|
23
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
|
|
24
24
|
"test": "npm run test:typecheck && npm run test:run",
|
|
25
25
|
"dev": "node --import tsx src/e2e-processor-shim.ts"
|
|
26
26
|
},
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
SQSHandler,
|
|
15
15
|
} from "aws-lambda";
|
|
16
16
|
import { env } from "expect-env";
|
|
17
|
+
import { receiveVisibilitySeconds } from "./e2e-processor-visibility.js";
|
|
17
18
|
import { handler } from "./index.js";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -150,7 +151,13 @@ if (cluster.isPrimary) {
|
|
|
150
151
|
QueueUrl: queueUrl,
|
|
151
152
|
MaxNumberOfMessages: maxMessages,
|
|
152
153
|
WaitTimeSeconds: waitTime,
|
|
153
|
-
|
|
154
|
+
// A failed message is left un-deleted and only redelivers when this
|
|
155
|
+
// window lapses; on the per-account FIFO sync queues that window is
|
|
156
|
+
// also how long a single failure head-of-line blocks the account's
|
|
157
|
+
// whole pipeline (#290). Keep it short for FIFO, long for the
|
|
158
|
+
// standard queues whose slower work needs it. See
|
|
159
|
+
// `receiveVisibilitySeconds`.
|
|
160
|
+
VisibilityTimeout: receiveVisibilitySeconds(queueUrl),
|
|
154
161
|
MessageSystemAttributeNames: ["ApproximateReceiveCount"],
|
|
155
162
|
}),
|
|
156
163
|
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
FIFO_RECEIVE_VISIBILITY_SECONDS,
|
|
5
|
+
receiveVisibilitySeconds,
|
|
6
|
+
STANDARD_RECEIVE_VISIBILITY_SECONDS,
|
|
7
|
+
} from "./e2e-processor-visibility.js";
|
|
8
|
+
|
|
9
|
+
describe("receiveVisibilitySeconds", () => {
|
|
10
|
+
it("gives the per-account FIFO sync queues a short window so a failure unblocks fast", () => {
|
|
11
|
+
for (const url of [
|
|
12
|
+
"http://localhost:9324/000/remit-mailboxes.fifo",
|
|
13
|
+
"http://localhost:9324/000/remit-messages.fifo",
|
|
14
|
+
"http://localhost:9324/000/remit-flags.fifo",
|
|
15
|
+
]) {
|
|
16
|
+
assert.equal(
|
|
17
|
+
receiveVisibilitySeconds(url),
|
|
18
|
+
FIFO_RECEIVE_VISIBILITY_SECONDS,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the long window for the standard body and management queues", () => {
|
|
24
|
+
for (const url of [
|
|
25
|
+
"http://localhost:9324/000/remit-body",
|
|
26
|
+
"http://localhost:9324/000/remit-mailbox-mgmt",
|
|
27
|
+
"http://localhost:9324/000/remit-message-mgmt",
|
|
28
|
+
"http://localhost:9324/000/remit-search-index",
|
|
29
|
+
]) {
|
|
30
|
+
assert.equal(
|
|
31
|
+
receiveVisibilitySeconds(url),
|
|
32
|
+
STANDARD_RECEIVE_VISIBILITY_SECONDS,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps the FIFO window short enough to unblock within a spec's seed budget, above a sync round's own duration", () => {
|
|
38
|
+
assert.ok(FIFO_RECEIVE_VISIBILITY_SECONDS > 0);
|
|
39
|
+
assert.ok(
|
|
40
|
+
FIFO_RECEIVE_VISIBILITY_SECONDS < STANDARD_RECEIVE_VISIBILITY_SECONDS,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receive-time visibility (seconds) the e2e drainer requests for a queue.
|
|
3
|
+
*
|
|
4
|
+
* A message the handler reports as failed is left un-deleted and only
|
|
5
|
+
* redelivers once its visibility lapses. The sync queues are per-account FIFO
|
|
6
|
+
* (`MessageGroupId = accountId`): while one message sits invisible, every later
|
|
7
|
+
* message in its group is held back too. So the visibility window a failed sync
|
|
8
|
+
* event sits out is also the window the whole account's sync pipeline stalls —
|
|
9
|
+
* and at a multi-minute window that stall swallows a spec's entire seed-and-wait
|
|
10
|
+
* budget when a transient IMAP hiccup fails one event mid-run, which is the
|
|
11
|
+
* recurring e2e-dev flake (#290).
|
|
12
|
+
*
|
|
13
|
+
* FIFO sync queues therefore get a short window: a transient failure costs one
|
|
14
|
+
* brief retry instead of a minutes-long group stall, and the handlers on these
|
|
15
|
+
* queues (a mailbox LIST, a header SEARCH/FETCH) finish well inside it — a rare
|
|
16
|
+
* slow round redelivers harmlessly, since the mailbox lock makes a concurrent
|
|
17
|
+
* re-run a no-op. The standard queues (body sync's ranged FETCH of up to 200
|
|
18
|
+
* message bodies, the management queues) keep the longer window their slower,
|
|
19
|
+
* non-FIFO work needs and where a redelivery blocks nothing but itself.
|
|
20
|
+
*/
|
|
21
|
+
export const FIFO_RECEIVE_VISIBILITY_SECONDS = 30;
|
|
22
|
+
export const STANDARD_RECEIVE_VISIBILITY_SECONDS = 300;
|
|
23
|
+
|
|
24
|
+
export const receiveVisibilitySeconds = (queueUrl: string): number =>
|
|
25
|
+
queueUrl.endsWith(".fifo")
|
|
26
|
+
? FIFO_RECEIVE_VISIBILITY_SECONDS
|
|
27
|
+
: STANDARD_RECEIVE_VISIBILITY_SECONDS;
|
|
@@ -4,30 +4,40 @@ import type {
|
|
|
4
4
|
AccountItem,
|
|
5
5
|
IMessageFlagPushRepository,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
7
8
|
import type { Logger } from "@remit/logger-lambda";
|
|
8
|
-
import {
|
|
9
|
+
import type { SyncMessagesEvent } from "../events.js";
|
|
10
|
+
import {
|
|
11
|
+
drainPendingFlagPushes,
|
|
12
|
+
type SyncMessagesDeps,
|
|
13
|
+
syncMessages,
|
|
14
|
+
} from "./sync-messages.js";
|
|
9
15
|
|
|
10
16
|
const buildLogger = (): {
|
|
11
17
|
log: Logger;
|
|
12
18
|
infos: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
19
|
+
warns: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
13
20
|
errors: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
14
21
|
} => {
|
|
15
22
|
const infos: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
23
|
+
const warns: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
16
24
|
const errors: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
17
25
|
const log = {
|
|
18
26
|
info: (fields: Record<string, unknown>, msg: string) => {
|
|
19
27
|
infos.push({ fields, msg });
|
|
20
28
|
},
|
|
29
|
+
warn: (fields: Record<string, unknown>, msg: string) => {
|
|
30
|
+
warns.push({ fields, msg });
|
|
31
|
+
},
|
|
21
32
|
error: (fields: Record<string, unknown>, msg: string) => {
|
|
22
33
|
errors.push({ fields, msg });
|
|
23
34
|
},
|
|
24
|
-
warn: () => {},
|
|
25
35
|
debug: () => {},
|
|
26
36
|
fatal: () => {},
|
|
27
37
|
trace: () => {},
|
|
28
38
|
child: () => log,
|
|
29
39
|
} as unknown as Logger;
|
|
30
|
-
return { log, infos, errors };
|
|
40
|
+
return { log, infos, warns, errors };
|
|
31
41
|
};
|
|
32
42
|
|
|
33
43
|
const account = {
|
|
@@ -241,3 +251,122 @@ describe("drainPendingFlagPushes — periodic per-mailbox re-arm (issue #1273)",
|
|
|
241
251
|
);
|
|
242
252
|
});
|
|
243
253
|
});
|
|
254
|
+
|
|
255
|
+
const liveAccount = {
|
|
256
|
+
accountId: "acc-1",
|
|
257
|
+
accountConfigId: "acc-cfg-1",
|
|
258
|
+
imapHost: "localhost",
|
|
259
|
+
username: "user@localhost",
|
|
260
|
+
} as unknown as AccountItem;
|
|
261
|
+
|
|
262
|
+
const syncEvent = (mailboxId: string): SyncMessagesEvent => ({
|
|
263
|
+
type: "SYNC_MESSAGES",
|
|
264
|
+
accountId: "acc-1",
|
|
265
|
+
mailboxId,
|
|
266
|
+
eventId: `evt-${mailboxId}`,
|
|
267
|
+
timestamp: 1,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A deps factory whose `withOAuthLifecycle` is a spy that records the call but
|
|
272
|
+
* never invokes the sync callback — the only thing under test here is the
|
|
273
|
+
* terminal gate BEFORE the lifecycle, so reaching (or not reaching) the spy is
|
|
274
|
+
* the observable outcome.
|
|
275
|
+
*/
|
|
276
|
+
const buildSyncDeps = (opts: {
|
|
277
|
+
mailboxGet: () => Promise<unknown>;
|
|
278
|
+
accountGet?: () => Promise<AccountItem>;
|
|
279
|
+
}): { deps: SyncMessagesDeps; lifecycleCalls: number } => {
|
|
280
|
+
const state = { lifecycleCalls: 0 };
|
|
281
|
+
const deps = {
|
|
282
|
+
getClient: async () => ({
|
|
283
|
+
account: {
|
|
284
|
+
get: opts.accountGet ?? (async () => liveAccount),
|
|
285
|
+
},
|
|
286
|
+
mailbox: {
|
|
287
|
+
get: opts.mailboxGet,
|
|
288
|
+
},
|
|
289
|
+
secrets: {},
|
|
290
|
+
}),
|
|
291
|
+
buildLifecycleDeps: () => ({}),
|
|
292
|
+
withOAuthLifecycle: async () => {
|
|
293
|
+
state.lifecycleCalls += 1;
|
|
294
|
+
},
|
|
295
|
+
} as unknown as SyncMessagesDeps;
|
|
296
|
+
return {
|
|
297
|
+
deps,
|
|
298
|
+
get lifecycleCalls() {
|
|
299
|
+
return state.lifecycleCalls;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
describe("syncMessages — terminal handling of events for a deleted mailbox (issue #287)", () => {
|
|
305
|
+
it("acks a SYNC_MESSAGES event whose mailbox row is gone — resolves without throwing, never connects", async () => {
|
|
306
|
+
const { log, warns } = buildLogger();
|
|
307
|
+
const harness = buildSyncDeps({
|
|
308
|
+
mailboxGet: async () => {
|
|
309
|
+
throw new NotFoundError("Mailbox not found: mbx-gone");
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await assert.doesNotReject(
|
|
314
|
+
syncMessages(syncEvent("mbx-gone"), log, harness.deps),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
assert.equal(
|
|
318
|
+
harness.lifecycleCalls,
|
|
319
|
+
0,
|
|
320
|
+
"a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
|
|
321
|
+
);
|
|
322
|
+
const skip = warns.find((w) => w.msg.includes("mailbox no longer exists"));
|
|
323
|
+
assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
|
|
324
|
+
assert.equal(skip.fields.accountId, "acc-1");
|
|
325
|
+
assert.equal(skip.fields.mailboxId, "mbx-gone");
|
|
326
|
+
assert.equal(skip.fields.event, "SYNC_MESSAGES");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("propagates a non-NotFound failure from the mailbox lookup — a transient read stays loud", async () => {
|
|
330
|
+
const { log } = buildLogger();
|
|
331
|
+
const harness = buildSyncDeps({
|
|
332
|
+
mailboxGet: async () => {
|
|
333
|
+
throw new Error("connection reset by peer");
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await assert.rejects(
|
|
338
|
+
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
339
|
+
/connection reset by peer/,
|
|
340
|
+
);
|
|
341
|
+
assert.equal(harness.lifecycleCalls, 0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("proceeds to the sync lifecycle for a live mailbox", async () => {
|
|
345
|
+
const { log } = buildLogger();
|
|
346
|
+
const harness = buildSyncDeps({
|
|
347
|
+
mailboxGet: async () => ({ mailboxId: "mbx-1", fullPath: "INBOX" }),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await syncMessages(syncEvent("mbx-1"), log, harness.deps);
|
|
351
|
+
|
|
352
|
+
assert.equal(harness.lifecycleCalls, 1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("a deleted-mailbox event does not stall the group — a following live-mailbox event still processes", async () => {
|
|
356
|
+
const { log } = buildLogger();
|
|
357
|
+
const gone = buildSyncDeps({
|
|
358
|
+
mailboxGet: async () => {
|
|
359
|
+
throw new NotFoundError("Mailbox not found: mbx-gone");
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
const live = buildSyncDeps({
|
|
363
|
+
mailboxGet: async () => ({ mailboxId: "mbx-live", fullPath: "INBOX" }),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await syncMessages(syncEvent("mbx-gone"), log, gone.deps);
|
|
367
|
+
await syncMessages(syncEvent("mbx-live"), log, live.deps);
|
|
368
|
+
|
|
369
|
+
assert.equal(gone.lifecycleCalls, 0);
|
|
370
|
+
assert.equal(live.lifecycleCalls, 1);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -53,9 +53,22 @@ export const batchSyncedMessages = (
|
|
|
53
53
|
return batches;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
export interface SyncMessagesDeps {
|
|
57
|
+
getClient: typeof getClient;
|
|
58
|
+
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
59
|
+
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const defaultDeps: SyncMessagesDeps = {
|
|
63
|
+
getClient,
|
|
64
|
+
withOAuthLifecycle,
|
|
65
|
+
buildLifecycleDeps,
|
|
66
|
+
};
|
|
67
|
+
|
|
56
68
|
export const syncMessages = async (
|
|
57
69
|
event: SyncMessagesEvent,
|
|
58
70
|
log: Logger,
|
|
71
|
+
deps: SyncMessagesDeps = defaultDeps,
|
|
59
72
|
): Promise<void> => {
|
|
60
73
|
log.info(
|
|
61
74
|
{
|
|
@@ -80,7 +93,7 @@ export const syncMessages = async (
|
|
|
80
93
|
messageFlag: messageFlagService,
|
|
81
94
|
unitOfWork,
|
|
82
95
|
secrets,
|
|
83
|
-
} = await getClient();
|
|
96
|
+
} = await deps.getClient();
|
|
84
97
|
|
|
85
98
|
// A deleted account never has its DDB row purged in lockstep with the queued
|
|
86
99
|
// SYNC_MESSAGES triggers, so a trigger can outlive its account. The lookup
|
|
@@ -118,11 +131,40 @@ export const syncMessages = async (
|
|
|
118
131
|
return;
|
|
119
132
|
}
|
|
120
133
|
|
|
134
|
+
// A SYNC_MESSAGES trigger can outlive the mailbox it targets. Deleting a
|
|
135
|
+
// mailbox that has held mail leaves already-queued events — a `hasMore`
|
|
136
|
+
// next-batch, a periodic sync tick — pointing at a row that is now gone. The
|
|
137
|
+
// lookup then throws a named NotFoundError that can never succeed on retry,
|
|
138
|
+
// and the account's per-group FIFO ordering lets that one poison message
|
|
139
|
+
// stall the whole account's message pipeline forever (issue #287). A mailbox
|
|
140
|
+
// the user deliberately deleted is an expected terminal outcome, not an infra
|
|
141
|
+
// failure: ack the event with a WARN. Any other error — a transient read, a
|
|
142
|
+
// NotFoundError from elsewhere — still propagates to be retried.
|
|
143
|
+
const mailboxExists = await mailboxService
|
|
144
|
+
.get(event.accountId, event.mailboxId)
|
|
145
|
+
.then(() => true)
|
|
146
|
+
.catch((err) => {
|
|
147
|
+
if ((err as { name?: string })?.name === "NotFoundError") return false;
|
|
148
|
+
throw err;
|
|
149
|
+
});
|
|
150
|
+
if (!mailboxExists) {
|
|
151
|
+
log.warn(
|
|
152
|
+
{
|
|
153
|
+
accountId: event.accountId,
|
|
154
|
+
mailboxId: event.mailboxId,
|
|
155
|
+
eventId: event.eventId,
|
|
156
|
+
event: event.type,
|
|
157
|
+
},
|
|
158
|
+
"Skipping SYNC_MESSAGES: mailbox no longer exists (deleted)",
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
121
163
|
// withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, resolve
|
|
122
164
|
// credentials, flip on terminal auth failure, rethrow transient). The
|
|
123
165
|
// mailbox lock and the actual sync run inside the wrapper callback.
|
|
124
|
-
await withOAuthLifecycle(
|
|
125
|
-
buildLifecycleDeps(secrets, accountService),
|
|
166
|
+
await deps.withOAuthLifecycle(
|
|
167
|
+
deps.buildLifecycleDeps(secrets, accountService),
|
|
126
168
|
account,
|
|
127
169
|
log,
|
|
128
170
|
async (credentials) => {
|