@remit/imap-worker 0.0.22 → 0.0.24

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.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -37,6 +37,7 @@
37
37
  "@remit/logger-lambda": "*",
38
38
  "@remit/mailbox-service": "*",
39
39
  "@remit/search-index-worker": "*",
40
+ "@remit/search-service": "*",
40
41
  "@remit/sqs-client": "*",
41
42
  "@remit/backend": "*",
42
43
  "@remit/domain-enums": "*",
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ FilterItem,
5
+ IFilterAnchorRepository,
6
+ IFilterRepository,
7
+ IMessageLabelRepository,
8
+ } from "@remit/data-ports";
9
+ import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
10
+ import {
11
+ type FilterMessage,
12
+ FilterPipeline,
13
+ type MessageEmbedder,
14
+ NO_ACTION,
15
+ type PlacementMoveService,
16
+ } from "@remit/mailbox-service";
17
+ import { buildFilterConfig, type FilterConfigDeps } from "./filter-config.js";
18
+
19
+ const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
20
+ ({
21
+ filterId: "flt-semantic",
22
+ accountConfigId: "cfg-1",
23
+ name: "receipts",
24
+ scope: "Standing",
25
+ state: FilterState.Active,
26
+ hasAnchor: true,
27
+ ruleChangedAt: 1,
28
+ matchOperator: FilterMatchOperator.Or,
29
+ literalClauses: [],
30
+ actionLabelId: NO_ACTION,
31
+ actionMailboxId: destinationMailboxId,
32
+ createdAt: 1,
33
+ updatedAt: 1,
34
+ }) as unknown as FilterItem;
35
+
36
+ const repos = (filter: FilterItem): FilterConfigDeps => ({
37
+ filterService: {
38
+ listByAccountAndState: async () => [filter],
39
+ refreshExpiry: async (f: FilterItem) => f,
40
+ } as unknown as IFilterRepository,
41
+ filterAnchorService: {
42
+ get: async () => ({ anchorEmbedding: [1, 0, 0] }),
43
+ } as unknown as IFilterAnchorRepository,
44
+ messageLabelService: {} as unknown as IMessageLabelRepository,
45
+ placementMoveService: {} as unknown as PlacementMoveService,
46
+ });
47
+
48
+ const message: FilterMessage = {
49
+ from: "billing@stripe.com",
50
+ fromName: "Stripe",
51
+ subject: "Your receipt",
52
+ text: "Thanks for your payment",
53
+ listId: "",
54
+ };
55
+
56
+ describe("buildFilterConfig", () => {
57
+ it("returns undefined without a placement mover — no move path, filters stay off", () => {
58
+ const deps = repos(anchorOnlyFilter("mbx-archive"));
59
+ deps.placementMoveService = undefined;
60
+
61
+ assert.equal(buildFilterConfig(deps), undefined);
62
+ });
63
+
64
+ it("wires an embedder into the config", () => {
65
+ const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
66
+
67
+ assert.ok(config);
68
+ assert.ok(config.embedder);
69
+ });
70
+
71
+ it("lights up a semantic anchor-only filter on the body-sync pass", async () => {
72
+ let embedCalls = 0;
73
+ const embedder: MessageEmbedder = {
74
+ embed: async () => {
75
+ embedCalls += 1;
76
+ return [1, 0, 0];
77
+ },
78
+ };
79
+ const config = buildFilterConfig(
80
+ repos(anchorOnlyFilter("mbx-archive")),
81
+ embedder,
82
+ );
83
+ assert.ok(config);
84
+
85
+ const decision = await new FilterPipeline(config, {
86
+ info: () => {},
87
+ }).evaluate("cfg-1", "m-1", message);
88
+
89
+ assert.deepEqual(decision.move, {
90
+ destinationMailboxId: "mbx-archive",
91
+ filterId: "flt-semantic",
92
+ });
93
+ assert.equal(embedCalls, 1);
94
+ });
95
+
96
+ it("does not match when the message embedding diverges from the anchor", async () => {
97
+ const embedder: MessageEmbedder = {
98
+ embed: async () => [0, 1, 0],
99
+ };
100
+ const config = buildFilterConfig(
101
+ repos(anchorOnlyFilter("mbx-archive")),
102
+ embedder,
103
+ );
104
+ assert.ok(config);
105
+
106
+ const decision = await new FilterPipeline(config, {
107
+ info: () => {},
108
+ }).evaluate("cfg-1", "m-2", message);
109
+
110
+ assert.equal(decision.move, undefined);
111
+ });
112
+ });
@@ -0,0 +1,43 @@
1
+ import type {
2
+ IFilterAnchorRepository,
3
+ IFilterRepository,
4
+ IMessageLabelRepository,
5
+ } from "@remit/data-ports";
6
+ import type {
7
+ FilterConfig,
8
+ MessageEmbedder,
9
+ PlacementMoveService,
10
+ } from "@remit/mailbox-service";
11
+ import { getMessageEmbedder } from "./message-embedder.js";
12
+
13
+ export interface FilterConfigDeps {
14
+ filterService: IFilterRepository;
15
+ filterAnchorService: IFilterAnchorRepository;
16
+ messageLabelService: IMessageLabelRepository;
17
+ placementMoveService?: PlacementMoveService;
18
+ }
19
+
20
+ /**
21
+ * Assemble the index-time filter config the body-sync pass runs (RFC 034). The
22
+ * embedder is provisioned from env exactly as the backend read-path and
23
+ * search-index worker provision theirs, so a semantic (anchor-only) filter is
24
+ * evaluated on incoming mail instead of silently skipped.
25
+ *
26
+ * Absent the placement mover there is no move path, so filters stay off — a
27
+ * matched filter's actions reuse the same enqueue plumbing the placement mover
28
+ * owns.
29
+ */
30
+ export const buildFilterConfig = (
31
+ deps: FilterConfigDeps,
32
+ embedder?: MessageEmbedder,
33
+ ): FilterConfig | undefined => {
34
+ const { placementMoveService } = deps;
35
+ if (!placementMoveService) return undefined;
36
+ return {
37
+ filterService: deps.filterService,
38
+ filterAnchorService: deps.filterAnchorService,
39
+ messageLabelService: deps.messageLabelService,
40
+ placementMoveService,
41
+ embedder: embedder ?? getMessageEmbedder(),
42
+ };
43
+ };
@@ -363,3 +363,109 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
363
363
  assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
364
364
  });
365
365
  });
366
+
367
+ describe("processMailboxManagement — a tagged NO the server means as success (#339)", () => {
368
+ beforeEach(() => {
369
+ h = fresh();
370
+ });
371
+
372
+ /**
373
+ * Dovecot answers `NO [NONEXISTENT] Mailbox doesn't exist`, which ImapFlow
374
+ * raises as a bare "Command failed" carrying the code on the error. Reading
375
+ * only the message treated an already-absent folder as a failure: the row was
376
+ * marked failed and the event left poisoning the account's queue.
377
+ */
378
+ it("treats it as the delete already having happened, and drops the local row", async () => {
379
+ h.connection.deleteMailbox = async () => {
380
+ throw Object.assign(new Error("Command failed"), {
381
+ serverResponseCode: "NONEXISTENT",
382
+ responseText: "Mailbox doesn't exist: Archive",
383
+ });
384
+ };
385
+
386
+ await assert.doesNotReject(
387
+ processMailboxManagement(deleteEvent, noopLog, deps()),
388
+ );
389
+ assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
390
+ assert.equal(called("mailbox.update").length, 0);
391
+ });
392
+
393
+ it("still marks failed and rethrows when the server fails for any other reason", async () => {
394
+ h.connection.deleteMailbox = async () => {
395
+ throw Object.assign(new Error("Command failed"), {
396
+ serverResponseCode: "SERVERBUG",
397
+ responseText: "Internal error",
398
+ });
399
+ };
400
+
401
+ await assert.rejects(
402
+ processMailboxManagement(deleteEvent, noopLog, deps()),
403
+ /Command failed/,
404
+ );
405
+ assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
406
+ });
407
+
408
+ /**
409
+ * Dovecot answers `NO [ALREADYEXISTS]` when the folder is already there — a
410
+ * folder another client made, or a redelivered create. That is the create
411
+ * having happened. Reading it as a failure marked the row `failed` and
412
+ * rethrew, holding back every later sync on the account's FIFO group.
413
+ */
414
+ it("reads ALREADYEXISTS on a CREATE as the folder already being there", async () => {
415
+ h.connection.createMailbox = async () => {
416
+ throw Object.assign(new Error("Command failed"), {
417
+ serverResponseCode: "ALREADYEXISTS",
418
+ responseText: "Mailbox already exists: Archive",
419
+ });
420
+ };
421
+
422
+ await assert.doesNotReject(
423
+ processMailboxManagement(createEvent, noopLog, deps()),
424
+ );
425
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
426
+ });
427
+
428
+ it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
429
+ h.connection.createMailbox = async () => {
430
+ throw Object.assign(new Error("Command failed"), {
431
+ serverResponseCode: "SERVERBUG",
432
+ responseText: "Internal error",
433
+ });
434
+ };
435
+
436
+ await assert.rejects(
437
+ processMailboxManagement(createEvent, noopLog, deps()),
438
+ /Command failed/,
439
+ );
440
+ assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
441
+ });
442
+
443
+ it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
444
+ h.connection.renameMailbox = async () => {
445
+ throw Object.assign(new Error("Command failed"), {
446
+ serverResponseCode: "NONEXISTENT",
447
+ responseText: "Mailbox doesn't exist: Archive",
448
+ });
449
+ };
450
+
451
+ await assert.doesNotReject(
452
+ processMailboxManagement(renameEvent, noopLog, deps()),
453
+ );
454
+ assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
455
+ });
456
+
457
+ it("still rolls back and rethrows when a RENAME fails for any other reason", async () => {
458
+ h.connection.renameMailbox = async () => {
459
+ throw Object.assign(new Error("Command failed"), {
460
+ serverResponseCode: "SERVERBUG",
461
+ responseText: "Internal error",
462
+ });
463
+ };
464
+
465
+ await assert.rejects(
466
+ processMailboxManagement(renameEvent, noopLog, deps()),
467
+ /Command failed/,
468
+ );
469
+ assert.equal(lastUpdate()?.syncStatus, "failed");
470
+ });
471
+ });
@@ -28,6 +28,44 @@ const defaultDeps: MailboxManagementDeps = {
28
28
  createConnectionScope: createConnectionScopeWithCredentials,
29
29
  };
30
30
 
31
+ const stringField = (value: unknown, key: string): string => {
32
+ if (!(value instanceof Object)) return "";
33
+ const field = Reflect.get(value, key);
34
+ return typeof field === "string" ? field : "";
35
+ };
36
+
37
+ /**
38
+ * Read a tagged-NO outcome out of an IMAP failure.
39
+ *
40
+ * The two outcomes below are each a folder operation finding the server already
41
+ * in the state it was asked for — the operation having happened, not failing.
42
+ * Reading them as failures marks the row `failed` and rethrows, and since folder
43
+ * management shares the account's per-account FIFO group with mailbox sync, that
44
+ * un-acked rethrow holds back every later sync for the account.
45
+ *
46
+ * Both places the server can say so are read. `message` carries it when the
47
+ * client raises the error itself; RFC 5530's response code and its text carry it
48
+ * when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
49
+ * with the code on the error, which is why matching the message alone never
50
+ * caught a real Dovecot answer.
51
+ */
52
+ const saidByServer = (error: Error): string =>
53
+ `${error.message} ${stringField(error, "responseText")}`;
54
+
55
+ /** The folder is not on the server: a delete has nothing left to do. */
56
+ const isMailboxAbsentUpstream = (error: unknown): boolean => {
57
+ if (!(error instanceof Error)) return false;
58
+ if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
59
+ return /not found|does ?n.?t exist/i.test(saidByServer(error));
60
+ };
61
+
62
+ /** The folder is already on the server: a create has nothing left to do. */
63
+ const isMailboxPresentUpstream = (error: unknown): boolean => {
64
+ if (!(error instanceof Error)) return false;
65
+ if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
66
+ return /already exists/i.test(saidByServer(error));
67
+ };
68
+
31
69
  /**
32
70
  * Pinned invariant for the whole-chain terminal guards below.
33
71
  *
@@ -112,10 +150,7 @@ const handleCreate = async (
112
150
  })
113
151
  .catch(async (error) => {
114
152
  // Check if mailbox already exists (idempotent)
115
- if (
116
- error instanceof Error &&
117
- error.message.includes("already exists")
118
- ) {
153
+ if (isMailboxPresentUpstream(error)) {
119
154
  log.info(
120
155
  { accountId, mailboxId, path },
121
156
  "Mailbox already exists, marking as synced",
@@ -223,7 +258,7 @@ const handleRename = async (
223
258
  })
224
259
  .catch(async (error) => {
225
260
  // If source not found, delete local mailbox
226
- if (error instanceof Error && error.message.includes("not found")) {
261
+ if (isMailboxAbsentUpstream(error)) {
227
262
  log.info(
228
263
  { accountId, mailboxId, oldPath },
229
264
  "Source mailbox not found, deleting local",
@@ -321,7 +356,7 @@ const handleDelete = async (
321
356
  })
322
357
  .catch(async (error) => {
323
358
  // If mailbox not found, it's already deleted (idempotent)
324
- if (error instanceof Error && error.message.includes("not found")) {
359
+ if (isMailboxAbsentUpstream(error)) {
325
360
  log.info(
326
361
  { accountId, mailboxId, path },
327
362
  "Mailbox not found on IMAP, deleting local",
@@ -3,9 +3,9 @@ import type { Logger } from "@remit/logger-lambda";
3
3
  import { MetricUnit, metrics } from "@remit/logger-lambda";
4
4
  import {
5
5
  BodySyncService,
6
- type FilterConfig,
7
6
  guardConnectionCursor,
8
7
  isCursorRebuildNeeded,
8
+ isFolderOffServer,
9
9
  MailboxCursorPausedError,
10
10
  PlacementMoveService,
11
11
  QuarantineService,
@@ -19,6 +19,7 @@ import {
19
19
  createConnectionScopeWithCredentials,
20
20
  } from "../connection-scope.js";
21
21
  import type { SyncMessageBodyEvent } from "../events.js";
22
+ import { buildFilterConfig } from "../filter-config.js";
22
23
  import { isNotFoundError } from "../is-not-found.js";
23
24
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
25
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
@@ -197,10 +198,13 @@ export const syncMessageBody = async (
197
198
  if (isNotFoundError(error)) return null;
198
199
  throw error;
199
200
  });
200
- if (!mailbox) {
201
+ // A folder still `pending` is terminal for the same reason: the batch
202
+ // was cut before the folder reached the server, so there is nothing
203
+ // there to fetch from and no retry that changes it.
204
+ if (!mailbox || isFolderOffServer(mailbox)) {
201
205
  log.warn(
202
206
  { accountId, mailboxId, eventId: event.eventId },
203
- "Skipping SYNC_MESSAGE_BODY: mailbox no longer exists (deleted)",
207
+ "Skipping SYNC_MESSAGE_BODY: the server does not hold this folder",
204
208
  );
205
209
  return;
206
210
  }
@@ -237,16 +241,14 @@ export const syncMessageBody = async (
237
241
 
238
242
  // A matched filter's actions (label upsert, exclusive move) apply on
239
243
  // the same body-sync pass, reusing the placement mover for the move
240
- // (RFC 034 Decision 3.1). Absent the placement mover there is no move
241
- // path, so filters stay off the two share the same enqueue plumbing.
242
- const filterConfig: FilterConfig | undefined = placementMoveService
243
- ? {
244
- filterService,
245
- filterAnchorService,
246
- messageLabelService,
247
- placementMoveService,
248
- }
249
- : undefined;
244
+ // (RFC 034 Decision 3.1). The env-provisioned embedder lets a semantic
245
+ // (anchor-only) filter match here instead of being silently skipped.
246
+ const filterConfig = buildFilterConfig({
247
+ filterService,
248
+ filterAnchorService,
249
+ messageLabelService,
250
+ placementMoveService,
251
+ });
250
252
 
251
253
  const bodySyncService = new BodySyncService(
252
254
  messageService,
@@ -319,7 +319,9 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
319
319
  0,
320
320
  "a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
321
321
  );
322
- const skip = warns.find((w) => w.msg.includes("mailbox no longer exists"));
322
+ const skip = warns.find((w) =>
323
+ w.msg.includes("the server does not hold this folder"),
324
+ );
323
325
  assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
324
326
  assert.equal(skip.fields.accountId, "acc-1");
325
327
  assert.equal(skip.fields.mailboxId, "mbx-gone");
@@ -352,6 +354,30 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
352
354
  assert.equal(harness.lifecycleCalls, 1);
353
355
  });
354
356
 
357
+ for (const syncStatus of ["pending", "deleting"]) {
358
+ it(`acks a SYNC_MESSAGES event for a \`${syncStatus}\` mailbox — the row exists, the server folder does not`, async () => {
359
+ const { log, warns } = buildLogger();
360
+ const harness = buildSyncDeps({
361
+ mailboxGet: async () => ({
362
+ mailboxId: "mbx-unsettled",
363
+ fullPath: "Archive",
364
+ syncStatus,
365
+ }),
366
+ });
367
+
368
+ await assert.doesNotReject(
369
+ syncMessages(syncEvent("mbx-unsettled"), log, harness.deps),
370
+ );
371
+
372
+ assert.equal(harness.lifecycleCalls, 0);
373
+ assert.ok(
374
+ warns.find((w) =>
375
+ w.msg.includes("the server does not hold this folder"),
376
+ ),
377
+ );
378
+ });
379
+ }
380
+
355
381
  it("a deleted-mailbox event does not stall the group — a following live-mailbox event still processes", async () => {
356
382
  const { log } = buildLogger();
357
383
  const gone = buildSyncDeps({
@@ -370,3 +396,151 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
370
396
  assert.equal(live.lifecycleCalls, 1);
371
397
  });
372
398
  });
399
+
400
+ /**
401
+ * A deps factory that runs the lifecycle and the mailbox lock for real, so the
402
+ * sync body — and the failure handling around it — is what gets exercised.
403
+ * `mailboxGet` is called per lookup, which is how a delete landing mid-round is
404
+ * expressed: the guard's read succeeds, a later one does not.
405
+ */
406
+ const buildRunningSyncDeps = (
407
+ mailboxGet: (call: number) => Promise<unknown>,
408
+ ): {
409
+ deps: SyncMessagesDeps;
410
+ accountUpdates: Array<Record<string, unknown>>;
411
+ } => {
412
+ const accountUpdates: Array<Record<string, unknown>> = [];
413
+ let calls = 0;
414
+ const deps = {
415
+ getClient: async () => ({
416
+ account: {
417
+ get: async () => liveAccount,
418
+ update: async (_id: string, input: Record<string, unknown>) => {
419
+ accountUpdates.push(input);
420
+ },
421
+ },
422
+ mailbox: {
423
+ get: async () => {
424
+ calls += 1;
425
+ return mailboxGet(calls);
426
+ },
427
+ },
428
+ mailboxLock: {
429
+ withMailboxLock: async (
430
+ _mailboxId: string,
431
+ _operation: string,
432
+ _accountId: string,
433
+ run: () => Promise<void>,
434
+ ) => {
435
+ await run();
436
+ return { executed: true };
437
+ },
438
+ },
439
+ flagPush: { listByMailboxId: async () => [] },
440
+ secrets: {},
441
+ }),
442
+ buildLifecycleDeps: () => ({}),
443
+ withOAuthLifecycle: async (
444
+ _lifecycleDeps: unknown,
445
+ _account: AccountItem,
446
+ _log: Logger,
447
+ run: (credentials: unknown) => Promise<void>,
448
+ ) => {
449
+ await run({ type: "password", password: "pw" });
450
+ },
451
+ } as unknown as SyncMessagesDeps;
452
+ return { deps, accountUpdates };
453
+ };
454
+
455
+ describe("syncMessages — a delete that lands mid-round (issue #339)", () => {
456
+ it("resolves terminally when the mailbox went away while the sync was in flight, and records no error phase", async () => {
457
+ const { log, warns } = buildLogger();
458
+ const harness = buildRunningSyncDeps(async (call) => {
459
+ if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
460
+ throw new NotFoundError("Mailbox not found: mbx-1");
461
+ });
462
+
463
+ await assert.doesNotReject(
464
+ syncMessages(syncEvent("mbx-1"), log, harness.deps),
465
+ );
466
+
467
+ const resolved = warns.find((w) =>
468
+ w.msg.includes("left the server while the sync was in flight"),
469
+ );
470
+ assert.ok(
471
+ resolved,
472
+ "expected a WARN naming the folder that left mid-round",
473
+ );
474
+ assert.equal(resolved.fields.mailboxId, "mbx-1");
475
+ assert.deepEqual(
476
+ harness.accountUpdates,
477
+ [],
478
+ "one folder leaving must not put the account into an error phase",
479
+ );
480
+ });
481
+
482
+ it("resolves terminally when the failure is on the server and the row is only marked deleting", async () => {
483
+ const { log, warns } = buildLogger();
484
+ const harness = buildRunningSyncDeps(async (call) => {
485
+ if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
486
+ if (call === 2) throw new Error("IMAP SEARCH failed in mailbox null");
487
+ return {
488
+ mailboxId: "mbx-1",
489
+ fullPath: "Archive",
490
+ syncStatus: "deleting",
491
+ };
492
+ });
493
+
494
+ await assert.doesNotReject(
495
+ syncMessages(syncEvent("mbx-1"), log, harness.deps),
496
+ );
497
+
498
+ assert.ok(
499
+ warns.find((w) =>
500
+ w.msg.includes("left the server while the sync was in flight"),
501
+ ),
502
+ );
503
+ assert.deepEqual(harness.accountUpdates, []);
504
+ });
505
+
506
+ /**
507
+ * The classifying read must not become the reported failure. A throttled or
508
+ * unreachable repository during the catch previously propagated in place of
509
+ * the real IMAP error, and the `syncPhase: error` write below it never ran —
510
+ * the let-it-crash contract lost both the diagnosis and the state.
511
+ */
512
+ it("reports the real failure, and still records the error phase, when the classifying read itself fails", async () => {
513
+ const { log } = buildLogger();
514
+ const harness = buildRunningSyncDeps(async (call) => {
515
+ if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
516
+ if (call === 2) throw new Error("IMAP SEARCH failed in mailbox null");
517
+ throw new Error("dynamodb throttled");
518
+ });
519
+
520
+ await assert.rejects(
521
+ syncMessages(syncEvent("mbx-1"), log, harness.deps),
522
+ /IMAP SEARCH failed in mailbox null/,
523
+ );
524
+ assert.equal(harness.accountUpdates.length, 1);
525
+ assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
526
+ assert.match(
527
+ String(harness.accountUpdates[0]?.lastError),
528
+ /IMAP SEARCH failed in mailbox null/,
529
+ );
530
+ });
531
+
532
+ it("still fails loudly when the mailbox is live — the failure is the account's, not a deletion", async () => {
533
+ const { log } = buildLogger();
534
+ const harness = buildRunningSyncDeps(async (call) => {
535
+ if (call === 2) throw new Error("connection reset by peer");
536
+ return { mailboxId: "mbx-1", fullPath: "Archive" };
537
+ });
538
+
539
+ await assert.rejects(
540
+ syncMessages(syncEvent("mbx-1"), log, harness.deps),
541
+ /connection reset by peer/,
542
+ );
543
+ assert.equal(harness.accountUpdates.length, 1);
544
+ assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
545
+ });
546
+ });
@@ -18,6 +18,7 @@ import { type Logger, MetricUnit, metrics } from "@remit/logger-lambda";
18
18
  import { RefreshTokenError } from "@remit/mail-oauth-service";
19
19
  import {
20
20
  createManagedConnectionFactory,
21
+ isMailboxNotOnServer,
21
22
  MailConnectionError,
22
23
  type MailCredentials,
23
24
  MessageSyncService,
@@ -132,23 +133,18 @@ export const syncMessages = async (
132
133
  return;
133
134
  }
134
135
 
135
- // A SYNC_MESSAGES trigger can outlive the mailbox it targets. Deleting a
136
- // mailbox that has held mail leaves already-queued events a `hasMore`
137
- // next-batch, a periodic sync tick pointing at a row that is now gone. The
138
- // lookup then throws a named NotFoundError that can never succeed on retry,
139
- // and the account's per-group FIFO ordering lets that one poison message
140
- // stall the whole account's message pipeline forever (issue #287). A mailbox
141
- // the user deliberately deleted is an expected terminal outcome, not an infra
142
- // failure: ack the event with a WARN. Any other error a transient read, a
143
- // NotFoundError from elsewhere — still propagates to be retried.
144
- const mailboxExists = await mailboxService
145
- .get(event.accountId, event.mailboxId)
146
- .then(() => true)
147
- .catch((err) => {
148
- if (isNotFoundError(err)) return false;
149
- throw err;
150
- });
151
- if (!mailboxExists) {
136
+ // A SYNC_MESSAGES trigger can address a folder the server does not hold: the
137
+ // fan-out enqueues one for a folder whose own create has not landed yet, and
138
+ // a `hasMore` next-batch or a periodic tick outlives a folder the user
139
+ // deleted. Such an event can never succeed on retry, and the account's
140
+ // per-group FIFO ordering lets that one poison message stall the
141
+ // whole account's message pipeline for the full visibility window (issue
142
+ // #287, #339). A folder the server does not hold not yet created, or being
143
+ // deleted is an expected terminal outcome, not an infra failure: ack the
144
+ // event with a WARN.
145
+ if (
146
+ await isMailboxNotOnServer(mailboxService, event.accountId, event.mailboxId)
147
+ ) {
152
148
  log.warn(
153
149
  {
154
150
  accountId: event.accountId,
@@ -156,7 +152,7 @@ export const syncMessages = async (
156
152
  eventId: event.eventId,
157
153
  event: event.type,
158
154
  },
159
- "Skipping SYNC_MESSAGES: mailbox no longer exists (deleted)",
155
+ "Skipping SYNC_MESSAGES: the server does not hold this folder",
160
156
  );
161
157
  return;
162
158
  }
@@ -203,6 +199,39 @@ export const syncMessages = async (
203
199
  ) {
204
200
  throw err;
205
201
  }
202
+ // The guard above is a check-then-act: a delete asked for while
203
+ // this round was in flight lands inside it, and the round then
204
+ // fails somewhere deeper — on the row (NotFoundError from the
205
+ // service's own re-read) or on the server (the SELECTed folder
206
+ // vanished, so the next command runs against no mailbox). Both are
207
+ // the same expected outcome as an event that arrived after the
208
+ // delete, so they resolve the same way rather than head-of-line
209
+ // blocking the account's FIFO group (issue #339). Recording an
210
+ // error phase is skipped too: the account is healthy, one of its
211
+ // folders is not there.
212
+ //
213
+ // This read only classifies the failure already in hand, so it must
214
+ // never become the failure that is reported. A read that cannot answer
215
+ // leaves the round on the loud path below, with the real error and the
216
+ // state write that goes with it intact.
217
+ const folderIsGone = await isMailboxNotOnServer(
218
+ mailboxService,
219
+ event.accountId,
220
+ event.mailboxId,
221
+ ).catch(() => false);
222
+ if (folderIsGone) {
223
+ log.warn(
224
+ {
225
+ accountId: event.accountId,
226
+ mailboxId: event.mailboxId,
227
+ eventId: event.eventId,
228
+ event: event.type,
229
+ error: err instanceof Error ? err.message : String(err),
230
+ },
231
+ "Resolving SYNC_MESSAGES: the folder left the server while the sync was in flight",
232
+ );
233
+ return;
234
+ }
206
235
  // Record the terminal error phase before crashing (let-it-crash:
207
236
  // record state, then rethrow so the event is retried/DLQ'd).
208
237
  const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import {
4
+ _resetMessageEmbedderForTest,
5
+ getMessageEmbedder,
6
+ } from "./message-embedder.js";
7
+
8
+ describe("getMessageEmbedder", () => {
9
+ afterEach(() => {
10
+ _resetMessageEmbedderForTest();
11
+ });
12
+
13
+ it("adapts the batch embedding service into a single-text message embedder", async () => {
14
+ const vector = await getMessageEmbedder().embed("invoice from stripe");
15
+
16
+ assert.ok(Array.isArray(vector));
17
+ assert.ok(vector.length > 0);
18
+ assert.ok(vector.every((value) => typeof value === "number"));
19
+ });
20
+
21
+ it("embeds the same text deterministically", async () => {
22
+ const embedder = getMessageEmbedder();
23
+ const a = await embedder.embed("your monthly receipt");
24
+ const b = await embedder.embed("your monthly receipt");
25
+
26
+ assert.deepEqual(a, b);
27
+ });
28
+
29
+ it("memoizes the embedder across calls", () => {
30
+ assert.strictEqual(getMessageEmbedder(), getMessageEmbedder());
31
+ });
32
+
33
+ it("rebuilds after a reset", () => {
34
+ const first = getMessageEmbedder();
35
+ _resetMessageEmbedderForTest();
36
+
37
+ assert.notStrictEqual(getMessageEmbedder(), first);
38
+ });
39
+ });
@@ -0,0 +1,32 @@
1
+ import type { MessageEmbedder } from "@remit/mailbox-service";
2
+ import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
3
+
4
+ /**
5
+ * Adapt the batch {@link EmbeddingService} the rest of the stack composes from
6
+ * env into the single-text {@link MessageEmbedder} the filter pipeline needs: one
7
+ * incoming message becomes one message-level vector to compare against a filter's
8
+ * persisted anchor (RFC 034 Decision 2.1/2.3).
9
+ *
10
+ * The embedder is selected by the same `SEARCH_EMBEDDING_*` env the backend and
11
+ * search-index worker read, so the worker embeds under the identical model the
12
+ * anchors were built with. The instance is memoized across warm invocations so a
13
+ * local Transformers.js model loads once per container rather than per event.
14
+ */
15
+ let cached: MessageEmbedder | undefined;
16
+
17
+ export const getMessageEmbedder = (): MessageEmbedder => {
18
+ if (cached) return cached;
19
+ const service = buildEmbeddingServiceFromEnv();
20
+ cached = {
21
+ embed: async (text: string): Promise<number[]> => {
22
+ const [vector] = await service.embed([text]);
23
+ return vector;
24
+ },
25
+ };
26
+ return cached;
27
+ };
28
+
29
+ /** Reset the singleton — test use only. */
30
+ export const _resetMessageEmbedderForTest = (): void => {
31
+ cached = undefined;
32
+ };