@remit/imap-worker 0.0.16 → 0.0.18
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.18",
|
|
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
|
},
|
|
@@ -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) => {
|