@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.17",
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=83 --test 'src/**/*.test.ts'",
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
- VisibilityTimeout: 300,
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 { drainPendingFlagPushes } from "./sync-messages.js";
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) => {