@remit/imap-worker 0.0.20 → 0.0.21

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.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -36,6 +36,7 @@ interface Harness {
36
36
  deletedAt?: number;
37
37
  } | null;
38
38
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
39
+ mailboxError?: Error;
39
40
  connection: Connection;
40
41
  localMessages: { messageId: string }[];
41
42
  threadMessage: { accountConfigId: string; threadMessageId: string } | null;
@@ -88,7 +89,10 @@ const deps = (): EmptyTrashDeps =>
88
89
  delete: record("threadMessage.delete"),
89
90
  },
90
91
  mailbox: {
91
- get: async () => h.mailbox,
92
+ get: async () => {
93
+ if (h.mailboxError) throw h.mailboxError;
94
+ return h.mailbox;
95
+ },
92
96
  update: record("mailbox.update"),
93
97
  },
94
98
  secrets: {},
@@ -191,6 +195,17 @@ describe("handleEmptyTrash", () => {
191
195
  assert.equal(called("message.delete").length, 0);
192
196
  });
193
197
 
198
+ it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
199
+ h.mailboxError = Object.assign(new Error("Mailbox not found: trash-mbx"), {
200
+ name: "NotFoundError",
201
+ });
202
+
203
+ await handleEmptyTrash(event, noopLog, deps());
204
+
205
+ assert.equal(h.getConnectionCount, 0);
206
+ assert.equal(called("message.delete").length, 0);
207
+ });
208
+
194
209
  it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
195
210
  h.connection.openBox = async () => ({ uidvalidity: 999 });
196
211
 
@@ -8,6 +8,7 @@ import {
8
8
  import { isAccountDeleted } from "../account-check.js";
9
9
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
10
10
  import type { EmptyTrashEvent } from "../events.js";
11
+ import { isNotFoundError } from "../is-not-found.js";
11
12
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
12
13
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
13
14
 
@@ -70,7 +71,24 @@ export const handleEmptyTrash = async (
70
71
  account,
71
72
  log,
72
73
  async (credentials) => {
73
- const mailbox = await mailboxService.get(accountId, trashMailboxId);
74
+ // The Trash folder can be deleted between enqueue and this sync, leaving a
75
+ // queued event pointing at a gone row. The lookup then throws
76
+ // NotFoundError forever, and on the account's per-group FIFO that head
77
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
78
+ // Trash makes the empty moot: ack with a WARN.
79
+ const mailbox = await mailboxService
80
+ .get(accountId, trashMailboxId)
81
+ .catch((error: unknown) => {
82
+ if (isNotFoundError(error)) return null;
83
+ throw error;
84
+ });
85
+ if (!mailbox) {
86
+ log.warn(
87
+ { accountId, mailboxId: trashMailboxId },
88
+ "Skipping EMPTY_TRASH: mailbox no longer exists (deleted)",
89
+ );
90
+ return;
91
+ }
74
92
 
75
93
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
76
94
  // paused never even opens a connection. Optimization only — the
@@ -1,6 +1,27 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { FLAG_PUSH_MAX_ATTEMPTS, getFlagPushMaxAttempts } from "./flag-push.js";
2
+ import { afterEach, describe, it, mock } from "node:test";
3
+ import { getClient } from "@remit/backend/client";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import type { FlagPushEvent } from "../events.js";
6
+ import {
7
+ FLAG_PUSH_MAX_ATTEMPTS,
8
+ getFlagPushMaxAttempts,
9
+ handleFlagPush,
10
+ } from "./flag-push.js";
11
+
12
+ const silentLogger = (() => {
13
+ const noop = () => {};
14
+ const log = {
15
+ info: noop,
16
+ warn: noop,
17
+ error: noop,
18
+ debug: noop,
19
+ fatal: noop,
20
+ trace: noop,
21
+ child: () => log,
22
+ } as unknown as Logger;
23
+ return log;
24
+ })();
4
25
 
5
26
  describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts / #1289's getPlacementMoveMaxAttempts)", () => {
6
27
  it("parses the CDK-injected env var", () => {
@@ -23,3 +44,55 @@ describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getB
23
44
  assert.ok(FLAG_PUSH_MAX_ATTEMPTS > 0);
24
45
  });
25
46
  });
47
+
48
+ describe("handleFlagPush — deleted mailbox is terminal (#287/#289)", () => {
49
+ const accountId = "fp-acc-zzz";
50
+ const messageId = "fp-msg-zzz";
51
+ const flagName = "\\Seen";
52
+
53
+ const event: FlagPushEvent = {
54
+ type: "FLAG_PUSH",
55
+ accountId,
56
+ accountConfigId: "fp-cfg-zzz",
57
+ messageId,
58
+ flagName,
59
+ } as FlagPushEvent;
60
+
61
+ afterEach(() => mock.restoreAll());
62
+
63
+ it("drops the orphaned marker and acks without pushing when the mailbox is gone", async () => {
64
+ const client = await getClient();
65
+ mock.method(client.account, "get", async () => ({
66
+ accountId,
67
+ accountConfigId: "fp-cfg-zzz",
68
+ }));
69
+ mock.method(client.message, "get", async () => [
70
+ { messageId, mailboxId: "gone-mbx", uid: 42 },
71
+ ]);
72
+ mock.method(client.mailbox, "get", async () => {
73
+ throw Object.assign(new Error("Mailbox not found: gone-mbx"), {
74
+ name: "NotFoundError",
75
+ });
76
+ });
77
+ mock.method(client.flagPush, "find", async () => ({
78
+ operation: "add",
79
+ state: "pending",
80
+ }));
81
+ mock.method(client.flagPush, "updateState", async () => {});
82
+ const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
83
+
84
+ // Must resolve, not reject — a deleted folder is an expected terminal
85
+ // outcome, not a fault to retry on the account's per-group FIFO.
86
+ await handleFlagPush(event, silentLogger, 1);
87
+
88
+ assert.equal(
89
+ deleteMarker.mock.calls.length,
90
+ 1,
91
+ "the orphaned marker is dropped",
92
+ );
93
+ assert.deepEqual(deleteMarker.mock.calls[0].arguments, [
94
+ messageId,
95
+ flagName,
96
+ ]);
97
+ });
98
+ });
@@ -10,6 +10,7 @@ import {
10
10
  import { isAccountDeleted } from "../account-check.js";
11
11
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
12
  import type { FlagPushEvent } from "../events.js";
13
+ import { isNotFoundError } from "../is-not-found.js";
13
14
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
15
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
15
16
 
@@ -113,7 +114,25 @@ export const handleFlagPush = async (
113
114
  // died mid-flight already left it here).
114
115
  await markerService.updateState(messageId, flagName, "processing");
115
116
 
116
- const mailbox = await mailboxService.get(accountId, message.mailboxId);
117
+ // The folder can be deleted between enqueue and this push, leaving a marker
118
+ // pointing at a gone row. The lookup then throws NotFoundError forever, and
119
+ // on the account's per-group FIFO that head message stalls the whole pipeline
120
+ // (issues #287, #289, #290). The push is moot — drop the orphaned marker (as
121
+ // the message-gone branch above already does) and ack with a WARN.
122
+ const mailbox = await mailboxService
123
+ .get(accountId, message.mailboxId)
124
+ .catch((error: unknown) => {
125
+ if (isNotFoundError(error)) return null;
126
+ throw error;
127
+ });
128
+ if (!mailbox) {
129
+ await markerService.delete(messageId, flagName);
130
+ log.warn(
131
+ { messageId, flagName, accountId, mailboxId: message.mailboxId },
132
+ "Skipping FLAG_PUSH: mailbox no longer exists (deleted); marker dropped",
133
+ );
134
+ return;
135
+ }
117
136
 
118
137
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
119
138
  // paused never even borrows a connection. Optimization only — the
@@ -51,11 +51,17 @@ interface Harness {
51
51
  deletedAt?: number;
52
52
  } | null;
53
53
  connection: Connection;
54
+ mailboxUpdateError?: Error;
54
55
  disconnectCount: number;
55
56
  }
56
57
 
57
58
  let h: Harness;
58
59
 
60
+ const notFoundError = (): Error =>
61
+ Object.assign(new Error("Mailbox not found: mbx-1"), {
62
+ name: "NotFoundError",
63
+ });
64
+
59
65
  const record =
60
66
  (method: string) =>
61
67
  async (...args: unknown[]) => {
@@ -96,7 +102,10 @@ const deps = (): MailboxManagementDeps =>
96
102
  },
97
103
  },
98
104
  mailbox: {
99
- update: record("mailbox.update"),
105
+ update: async (...args: unknown[]) => {
106
+ h.calls.push({ method: "mailbox.update", args });
107
+ if (h.mailboxUpdateError) throw h.mailboxUpdateError;
108
+ },
100
109
  delete: record("mailbox.delete"),
101
110
  },
102
111
  secrets: {},
@@ -207,6 +216,16 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
207
216
  assert.equal(h.disconnectCount, 1);
208
217
  });
209
218
 
219
+ it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
220
+ // The status write-back throws NotFoundError because the row is gone; the
221
+ // create is moot and must not poison the account's FIFO.
222
+ h.mailboxUpdateError = notFoundError();
223
+
224
+ await processMailboxManagement(createEvent, noopLog, deps());
225
+
226
+ assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
227
+ });
228
+
210
229
  it("returns early without connecting when the account is soft-deleted", async () => {
211
230
  h.account = {
212
231
  accountId: "acc-1",
@@ -258,6 +277,17 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
258
277
  assert.equal(called("mailbox.update").length, 0);
259
278
  });
260
279
 
280
+ it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
281
+ h.connection.renameMailbox = async () => {
282
+ throw new Error("server exploded");
283
+ };
284
+ h.mailboxUpdateError = notFoundError();
285
+
286
+ await processMailboxManagement(renameEvent, noopLog, deps());
287
+
288
+ assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
289
+ });
290
+
261
291
  it("rolls the local path back and rethrows on any other rename error", async () => {
262
292
  h.connection.renameMailbox = async () => {
263
293
  throw new Error("server exploded");
@@ -321,4 +351,15 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
321
351
 
322
352
  assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
323
353
  });
354
+
355
+ it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
356
+ h.connection.deleteMailbox = async () => {
357
+ throw new Error("server exploded");
358
+ };
359
+ h.mailboxUpdateError = notFoundError();
360
+
361
+ await processMailboxManagement(deleteEvent, noopLog, deps());
362
+
363
+ assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
364
+ });
324
365
  });
@@ -10,6 +10,7 @@ import type {
10
10
  MailboxManagementEvent,
11
11
  MailboxRenameEvent,
12
12
  } from "../events.js";
13
+ import { isNotFoundError } from "../is-not-found.js";
13
14
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
15
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
15
16
 
@@ -27,6 +28,21 @@ const defaultDeps: MailboxManagementDeps = {
27
28
  createConnectionScope: createConnectionScopeWithCredentials,
28
29
  };
29
30
 
31
+ /**
32
+ * Pinned invariant for the whole-chain terminal guards below.
33
+ *
34
+ * Each handler wraps its `MailboxManagementService.sync*` chain in a try/catch
35
+ * that treats a NotFoundError as terminal (ack with a WARN, issue #289). That
36
+ * is only sound because `syncCreate` / `syncRename` / `syncDelete` — and their
37
+ * error-recovery branches here — touch nothing but the single target mailbox
38
+ * row via `mailboxService.get/update/delete(accountId, mailboxId)`. So the only
39
+ * row a NotFoundError can refer to is that target, and its absence can only mean
40
+ * the user deleted the folder mid-sync — never an unrelated missing entity that
41
+ * should have been retried. If a sync method ever reads or writes a second
42
+ * entity, narrow these catches (match the mailboxId / re-check existence) before
43
+ * a NotFoundError from elsewhere is silently acked.
44
+ */
45
+
30
46
  /**
31
47
  * Handle MAILBOX_CREATE event
32
48
  */
@@ -72,39 +88,65 @@ const handleCreate = async (
72
88
  log,
73
89
  );
74
90
 
75
- await managementService
76
- .syncCreate(accountId, mailboxId, path, scope.getConnection, subscribe)
77
- .then((result) => {
78
- if (result.success) {
79
- log.info({ accountId, mailboxId, path }, "Mailbox created on IMAP");
80
- } else {
81
- log.error(
82
- { accountId, mailboxId, path, error: result.error },
83
- "Failed to create mailbox on IMAP",
84
- );
85
- }
86
- })
87
- .catch(async (error) => {
88
- // Check if mailbox already exists (idempotent)
89
- if (
90
- error instanceof Error &&
91
- error.message.includes("already exists")
92
- ) {
93
- log.info(
94
- { accountId, mailboxId, path },
95
- "Mailbox already exists, marking as synced",
96
- );
97
- await mailboxService.update(accountId, mailboxId, {
98
- syncStatus: MailboxSyncStatus.synced,
99
- });
100
- } else {
101
- await mailboxService.update(accountId, mailboxId, {
102
- syncStatus: MailboxSyncStatus.failed,
103
- });
104
- throw error;
105
- }
106
- })
107
- .finally(() => scope.disconnect());
91
+ try {
92
+ await managementService
93
+ .syncCreate(
94
+ accountId,
95
+ mailboxId,
96
+ path,
97
+ scope.getConnection,
98
+ subscribe,
99
+ )
100
+ .then((result) => {
101
+ if (result.success) {
102
+ log.info(
103
+ { accountId, mailboxId, path },
104
+ "Mailbox created on IMAP",
105
+ );
106
+ } else {
107
+ log.error(
108
+ { accountId, mailboxId, path, error: result.error },
109
+ "Failed to create mailbox on IMAP",
110
+ );
111
+ }
112
+ })
113
+ .catch(async (error) => {
114
+ // Check if mailbox already exists (idempotent)
115
+ if (
116
+ error instanceof Error &&
117
+ error.message.includes("already exists")
118
+ ) {
119
+ log.info(
120
+ { accountId, mailboxId, path },
121
+ "Mailbox already exists, marking as synced",
122
+ );
123
+ await mailboxService.update(accountId, mailboxId, {
124
+ syncStatus: MailboxSyncStatus.synced,
125
+ });
126
+ } else {
127
+ await mailboxService.update(accountId, mailboxId, {
128
+ syncStatus: MailboxSyncStatus.failed,
129
+ });
130
+ throw error;
131
+ }
132
+ })
133
+ .finally(() => scope.disconnect());
134
+ } catch (error) {
135
+ // The mailbox row was deleted between enqueue and this sync — the
136
+ // create-then-status-write throws NotFoundError, which can never
137
+ // succeed on retry and would poison the account's per-group FIFO
138
+ // forever (issue #289, same class as #287/#290). A folder the user
139
+ // deleted is an expected terminal outcome: ack with a WARN. Real
140
+ // IMAP/infra failures carry other errors and still propagate.
141
+ if (isNotFoundError(error)) {
142
+ log.warn(
143
+ { accountId, mailboxId, path, eventId: event.eventId },
144
+ "Skipping MAILBOX_CREATE: mailbox no longer exists (deleted)",
145
+ );
146
+ return;
147
+ }
148
+ throw error;
149
+ }
108
150
  },
109
151
  );
110
152
  };
@@ -157,40 +199,61 @@ const handleRename = async (
157
199
  log,
158
200
  );
159
201
 
160
- await managementService
161
- .syncRename(accountId, mailboxId, oldPath, newPath, scope.getConnection)
162
- .then((result) => {
163
- if (result.success) {
164
- log.info(
165
- { accountId, mailboxId, oldPath, newPath },
166
- "Mailbox renamed on IMAP",
167
- );
168
- } else {
169
- log.error(
170
- { accountId, mailboxId, oldPath, newPath, error: result.error },
171
- "Failed to rename mailbox on IMAP",
172
- );
173
- }
174
- })
175
- .catch(async (error) => {
176
- // If source not found, delete local mailbox
177
- if (error instanceof Error && error.message.includes("not found")) {
178
- log.info(
179
- { accountId, mailboxId, oldPath },
180
- "Source mailbox not found, deleting local",
181
- );
182
- await mailboxService.delete(accountId, mailboxId);
183
- } else {
184
- // Rollback local rename by restoring old path
185
- await mailboxService.update(accountId, mailboxId, {
186
- fullPath: oldPath,
187
- oldPath: undefined,
188
- syncStatus: MailboxSyncStatus.failed,
189
- });
190
- throw error;
191
- }
192
- })
193
- .finally(() => scope.disconnect());
202
+ try {
203
+ await managementService
204
+ .syncRename(
205
+ accountId,
206
+ mailboxId,
207
+ oldPath,
208
+ newPath,
209
+ scope.getConnection,
210
+ )
211
+ .then((result) => {
212
+ if (result.success) {
213
+ log.info(
214
+ { accountId, mailboxId, oldPath, newPath },
215
+ "Mailbox renamed on IMAP",
216
+ );
217
+ } else {
218
+ log.error(
219
+ { accountId, mailboxId, oldPath, newPath, error: result.error },
220
+ "Failed to rename mailbox on IMAP",
221
+ );
222
+ }
223
+ })
224
+ .catch(async (error) => {
225
+ // If source not found, delete local mailbox
226
+ if (error instanceof Error && error.message.includes("not found")) {
227
+ log.info(
228
+ { accountId, mailboxId, oldPath },
229
+ "Source mailbox not found, deleting local",
230
+ );
231
+ await mailboxService.delete(accountId, mailboxId);
232
+ } else {
233
+ // Rollback local rename by restoring old path
234
+ await mailboxService.update(accountId, mailboxId, {
235
+ fullPath: oldPath,
236
+ oldPath: undefined,
237
+ syncStatus: MailboxSyncStatus.failed,
238
+ });
239
+ throw error;
240
+ }
241
+ })
242
+ .finally(() => scope.disconnect());
243
+ } catch (error) {
244
+ // The mailbox row was deleted between enqueue and this sync — the
245
+ // status write-back throws NotFoundError, unretryable and would poison
246
+ // the account's per-group FIFO forever (issue #289 class). Ack with a
247
+ // WARN; real IMAP/infra failures carry other errors and still propagate.
248
+ if (isNotFoundError(error)) {
249
+ log.warn(
250
+ { accountId, mailboxId, oldPath, newPath, eventId: event.eventId },
251
+ "Skipping MAILBOX_RENAME: mailbox no longer exists (deleted)",
252
+ );
253
+ return;
254
+ }
255
+ throw error;
256
+ }
194
257
  },
195
258
  );
196
259
  };
@@ -240,48 +303,67 @@ const handleDelete = async (
240
303
  log,
241
304
  );
242
305
 
243
- await managementService
244
- .syncDelete(accountId, mailboxId, path, scope.getConnection)
245
- .then((result) => {
246
- if (result.success) {
247
- log.info({ accountId, mailboxId, path }, "Mailbox deleted on IMAP");
248
- } else {
249
- log.error(
250
- { accountId, mailboxId, path, error: result.error },
251
- "Failed to delete mailbox on IMAP",
252
- );
253
- }
254
- })
255
- .catch(async (error) => {
256
- // If mailbox not found, it's already deleted (idempotent)
257
- if (error instanceof Error && error.message.includes("not found")) {
258
- log.info(
259
- { accountId, mailboxId, path },
260
- "Mailbox not found on IMAP, deleting local",
261
- );
262
- await mailboxService.delete(accountId, mailboxId);
263
- } else if (
264
- error instanceof Error &&
265
- error.message.includes("Cannot delete INBOX")
266
- ) {
267
- // Restore the mailbox
268
- await mailboxService.update(accountId, mailboxId, {
269
- syncStatus: MailboxSyncStatus.synced,
270
- });
271
- log.error(
272
- { accountId, mailboxId, path },
273
- "Cannot delete INBOX, restoring mailbox",
274
- );
275
- // Don't rethrow - this is an expected error
276
- } else {
277
- // Restore the mailbox on other errors
278
- await mailboxService.update(accountId, mailboxId, {
279
- syncStatus: MailboxSyncStatus.failed,
280
- });
281
- throw error;
282
- }
283
- })
284
- .finally(() => scope.disconnect());
306
+ try {
307
+ await managementService
308
+ .syncDelete(accountId, mailboxId, path, scope.getConnection)
309
+ .then((result) => {
310
+ if (result.success) {
311
+ log.info(
312
+ { accountId, mailboxId, path },
313
+ "Mailbox deleted on IMAP",
314
+ );
315
+ } else {
316
+ log.error(
317
+ { accountId, mailboxId, path, error: result.error },
318
+ "Failed to delete mailbox on IMAP",
319
+ );
320
+ }
321
+ })
322
+ .catch(async (error) => {
323
+ // If mailbox not found, it's already deleted (idempotent)
324
+ if (error instanceof Error && error.message.includes("not found")) {
325
+ log.info(
326
+ { accountId, mailboxId, path },
327
+ "Mailbox not found on IMAP, deleting local",
328
+ );
329
+ await mailboxService.delete(accountId, mailboxId);
330
+ } else if (
331
+ error instanceof Error &&
332
+ error.message.includes("Cannot delete INBOX")
333
+ ) {
334
+ // Restore the mailbox
335
+ await mailboxService.update(accountId, mailboxId, {
336
+ syncStatus: MailboxSyncStatus.synced,
337
+ });
338
+ log.error(
339
+ { accountId, mailboxId, path },
340
+ "Cannot delete INBOX, restoring mailbox",
341
+ );
342
+ // Don't rethrow - this is an expected error
343
+ } else {
344
+ // Restore the mailbox on other errors
345
+ await mailboxService.update(accountId, mailboxId, {
346
+ syncStatus: MailboxSyncStatus.failed,
347
+ });
348
+ throw error;
349
+ }
350
+ })
351
+ .finally(() => scope.disconnect());
352
+ } catch (error) {
353
+ // The mailbox row was already deleted (a duplicate/racing delete) — the
354
+ // error-recovery status write throws NotFoundError, unretryable and
355
+ // would poison the account's per-group FIFO forever (issue #289 class).
356
+ // The delete has effectively happened: ack with a WARN. Real IMAP/infra
357
+ // failures carry other errors and still propagate.
358
+ if (isNotFoundError(error)) {
359
+ log.warn(
360
+ { accountId, mailboxId, path, eventId: event.eventId },
361
+ "Skipping MAILBOX_DELETE: mailbox no longer exists (deleted)",
362
+ );
363
+ return;
364
+ }
365
+ throw error;
366
+ }
285
367
  },
286
368
  );
287
369
  };
@@ -39,6 +39,7 @@ interface Harness {
39
39
  deletedAt?: number;
40
40
  } | null;
41
41
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
42
+ mailboxError?: Error;
42
43
  connection: Connection;
43
44
  getConnectionCount: number;
44
45
  disconnectCount: number;
@@ -46,6 +47,11 @@ interface Harness {
46
47
 
47
48
  let h: Harness;
48
49
 
50
+ const notFoundError = (): Error =>
51
+ Object.assign(new Error("Mailbox not found: src-mbx"), {
52
+ name: "NotFoundError",
53
+ });
54
+
49
55
  const record =
50
56
  (method: string) =>
51
57
  async (...args: unknown[]) => {
@@ -94,7 +100,10 @@ const deps = (): MessageCopyDeps =>
94
100
  update: record("threadMessage.update"),
95
101
  },
96
102
  mailbox: {
97
- get: async () => h.mailbox,
103
+ get: async () => {
104
+ if (h.mailboxError) throw h.mailboxError;
105
+ return h.mailbox;
106
+ },
98
107
  update: record("mailbox.update"),
99
108
  },
100
109
  secrets: {},
@@ -189,6 +198,16 @@ describe("handleMessageCopy", () => {
189
198
  );
190
199
  });
191
200
 
201
+ it("acks terminally without connecting when the source mailbox was deleted", async () => {
202
+ h.mailboxError = notFoundError();
203
+
204
+ await handleMessageCopy(event, noopLog, deps());
205
+
206
+ assert.equal(h.getConnectionCount, 0);
207
+ assert.equal(called("message.updateUid").length, 0);
208
+ assert.equal(called("message.update").length, 0);
209
+ });
210
+
192
211
  it("skips the copy without opening a connection when the cursor is rebuilding", async () => {
193
212
  h.mailbox = {
194
213
  mailboxId: "src-mbx",
@@ -9,6 +9,7 @@ import {
9
9
  import { isAccountDeleted } from "../account-check.js";
10
10
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
11
11
  import type { MessageCopyEvent } from "../events.js";
12
+ import { isNotFoundError } from "../is-not-found.js";
12
13
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
13
14
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
14
15
 
@@ -87,7 +88,24 @@ export const handleMessageCopy = async (
87
88
  account,
88
89
  log,
89
90
  async (credentials) => {
90
- const mailbox = await mailboxService.get(accountId, sourceMailboxId);
91
+ // The source folder can be deleted between enqueue and this sync, leaving
92
+ // a queued event pointing at a gone row. The lookup then throws
93
+ // NotFoundError forever, and on the account's per-group FIFO that head
94
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
95
+ // source mailbox makes the copy moot: ack with a WARN.
96
+ const mailbox = await mailboxService
97
+ .get(accountId, sourceMailboxId)
98
+ .catch((error: unknown) => {
99
+ if (isNotFoundError(error)) return null;
100
+ throw error;
101
+ });
102
+ if (!mailbox) {
103
+ log.warn(
104
+ { accountId, sourceMessageId, mailboxId: sourceMailboxId },
105
+ "Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)",
106
+ );
107
+ return;
108
+ }
91
109
 
92
110
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
93
111
  // paused never even opens a connection. Optimization only — the
@@ -215,6 +215,7 @@ interface Harness {
215
215
  deletedAt?: number;
216
216
  } | null;
217
217
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
218
+ mailboxError?: Error;
218
219
  connection: Connection;
219
220
  threadMessage: Record<string, unknown> | null;
220
221
  allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
@@ -280,7 +281,10 @@ const deps = (): MessageDeleteDeps =>
280
281
  delete: record("threadMessage.delete"),
281
282
  },
282
283
  mailbox: {
283
- get: async () => h.mailbox,
284
+ get: async () => {
285
+ if (h.mailboxError) throw h.mailboxError;
286
+ return h.mailbox;
287
+ },
284
288
  update: record("mailbox.update"),
285
289
  },
286
290
  secrets: {},
@@ -442,6 +446,18 @@ describe("handleMessageDelete", () => {
442
446
  assert.equal(h.getConnectionCount, 0);
443
447
  });
444
448
 
449
+ it("acks terminally without connecting when the mailbox was deleted", async () => {
450
+ h.mailboxError = Object.assign(new Error("Mailbox not found: src-mbx"), {
451
+ name: "NotFoundError",
452
+ });
453
+
454
+ await handleMessageDelete(moveEvent, noopLog, deps());
455
+
456
+ assert.equal(h.getConnectionCount, 0);
457
+ assert.equal(called("message.updateUid").length, 0);
458
+ assert.equal(called("message.delete").length, 0);
459
+ });
460
+
445
461
  it("returns early without connecting when the account is soft-deleted", async () => {
446
462
  h.account = {
447
463
  accountId: "acc-1",
@@ -13,6 +13,7 @@ import {
13
13
  import { isAccountDeleted } from "../account-check.js";
14
14
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
15
  import type { MessageDeleteEvent } from "../events.js";
16
+ import { isNotFoundError } from "../is-not-found.js";
16
17
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
18
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
19
 
@@ -151,7 +152,24 @@ export const handleMessageDelete = async (
151
152
  account,
152
153
  log,
153
154
  async (credentials) => {
154
- const mailbox = await mailboxService.get(accountId, mailboxId);
155
+ // The folder can be deleted between enqueue and this sync, leaving a
156
+ // queued event pointing at a gone row. The lookup then throws
157
+ // NotFoundError forever, and on the account's per-group FIFO that head
158
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
159
+ // mailbox makes the delete moot: ack with a WARN.
160
+ const mailbox = await mailboxService
161
+ .get(accountId, mailboxId)
162
+ .catch((error: unknown) => {
163
+ if (isNotFoundError(error)) return null;
164
+ throw error;
165
+ });
166
+ if (!mailbox) {
167
+ log.warn(
168
+ { accountId, messageId, mailboxId },
169
+ "Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)",
170
+ );
171
+ return;
172
+ }
155
173
 
156
174
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
157
175
  // paused never even opens a connection. Optimization only — the
@@ -1,12 +1,30 @@
1
1
  import assert from "node:assert";
2
- import { describe, it, mock } from "node:test";
3
- import type { ThreadMessageItem } from "@remit/data-ports";
2
+ import { afterEach, describe, it, mock } from "node:test";
3
+ import { getClient } from "@remit/backend/client";
4
+ import type { AccountItem, ThreadMessageItem } from "@remit/data-ports";
5
+ import type { Logger } from "@remit/logger-lambda";
6
+ import type { MessageMoveEvent } from "../events.js";
4
7
  import {
5
8
  buildThreadMessageMoveUpdate,
6
9
  emitMoveResync,
10
+ handleMessageMove,
7
11
  moveThenResync,
8
12
  } from "./message-move.js";
9
13
 
14
+ const silentLogger = (() => {
15
+ const noop = () => {};
16
+ const log = {
17
+ info: noop,
18
+ warn: noop,
19
+ error: noop,
20
+ debug: noop,
21
+ fatal: noop,
22
+ trace: noop,
23
+ child: () => log,
24
+ } as unknown as Logger;
25
+ return log;
26
+ })();
27
+
10
28
  const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
11
29
  const destinationMailboxId = "destination-mailbox-aaaaa";
12
30
 
@@ -166,3 +184,61 @@ describe("moveThenResync (#1031)", () => {
166
184
  assert.equal(resync.mock.calls.length, 0);
167
185
  });
168
186
  });
187
+
188
+ describe("handleMessageMove — deleted mailbox is terminal (#287/#289)", () => {
189
+ const acctId = "mm-acc-zzz";
190
+
191
+ const cappedAccount = (): AccountItem =>
192
+ ({
193
+ accountId: acctId,
194
+ accountConfigId: "mm-cfg-zzz",
195
+ connectionState: "authenticated",
196
+ username: "mm@imap.example.com",
197
+ imapHost: "imap.example.com",
198
+ imapPort: 993,
199
+ imapTls: true,
200
+ passwordHash: JSON.stringify({
201
+ encryptedDek: "",
202
+ encryptedData: "",
203
+ iv: "",
204
+ authTag: "",
205
+ }),
206
+ }) as unknown as AccountItem;
207
+
208
+ const event: MessageMoveEvent = {
209
+ type: "MESSAGE_MOVE",
210
+ accountId: acctId,
211
+ accountConfigId: "mm-cfg-zzz",
212
+ messageId: "mm-msg-zzz",
213
+ sourceMailboxId: "mm-src-zzz",
214
+ sourceMailboxPath: "INBOX",
215
+ destinationMailboxId: "mm-dst-zzz",
216
+ destinationMailboxPath: "Archive",
217
+ uid: 10,
218
+ eventId: "mm-evt-zzz",
219
+ timestamp: 1700000000000,
220
+ } as MessageMoveEvent;
221
+
222
+ afterEach(() => mock.restoreAll());
223
+
224
+ it("acks without connecting when the source mailbox was deleted", async () => {
225
+ const client = await getClient();
226
+ mock.method(client.account, "get", async () => cappedAccount());
227
+ mock.method(client.secrets, "decrypt", async () => "fake-password");
228
+ mock.method(client.mailbox, "get", async () => {
229
+ throw Object.assign(new Error("Mailbox not found: mm-src-zzz"), {
230
+ name: "NotFoundError",
231
+ });
232
+ });
233
+ const updateUid = mock.method(client.message, "updateUid", async () => {});
234
+
235
+ // Must resolve, not reject — a deleted source folder makes the move moot.
236
+ await handleMessageMove(event, silentLogger);
237
+
238
+ assert.equal(
239
+ updateUid.mock.calls.length,
240
+ 0,
241
+ "a deleted mailbox never reaches the IMAP move",
242
+ );
243
+ });
244
+ });
@@ -11,6 +11,7 @@ import { isAccountDeleted } from "../account-check.js";
11
11
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
12
  import { emitEvent } from "../emit.js";
13
13
  import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
14
+ import { isNotFoundError } from "../is-not-found.js";
14
15
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
15
16
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
16
17
 
@@ -144,7 +145,24 @@ export const handleMessageMove = async (
144
145
  account,
145
146
  log,
146
147
  async (credentials) => {
147
- const mailbox = await mailboxService.get(accountId, sourceMailboxId);
148
+ // The source folder can be deleted between enqueue and this sync, leaving
149
+ // a queued event pointing at a gone row. The lookup then throws
150
+ // NotFoundError forever, and on the account's per-group FIFO that head
151
+ // message stalls the whole pipeline (issues #287, #289, #290). A deleted
152
+ // source mailbox makes the move moot: ack with a WARN.
153
+ const mailbox = await mailboxService
154
+ .get(accountId, sourceMailboxId)
155
+ .catch((error: unknown) => {
156
+ if (isNotFoundError(error)) return null;
157
+ throw error;
158
+ });
159
+ if (!mailbox) {
160
+ log.warn(
161
+ { accountId, messageId, mailboxId: sourceMailboxId },
162
+ "Skipping MESSAGE_MOVE: source mailbox no longer exists (deleted)",
163
+ );
164
+ return;
165
+ }
148
166
 
149
167
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
150
168
  // paused never even opens a connection. Optimization only — the
@@ -1,12 +1,30 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { afterEach, describe, it, mock } from "node:test";
3
+ import { getClient } from "@remit/backend/client";
4
+ import type { Logger } from "@remit/logger-lambda";
3
5
  import type { IImapConnection } from "@remit/mailbox-service";
6
+ import type { PlacementMovePushEvent } from "../events.js";
4
7
  import {
5
8
  attemptMove,
6
9
  getPlacementMoveMaxAttempts,
10
+ handlePlacementMovePush,
7
11
  PLACEMENT_MOVE_MAX_ATTEMPTS,
8
12
  } from "./placement-move-push.js";
9
13
 
14
+ const silentLogger = (() => {
15
+ const noop = () => {};
16
+ const log = {
17
+ info: noop,
18
+ warn: noop,
19
+ error: noop,
20
+ debug: noop,
21
+ fatal: noop,
22
+ trace: noop,
23
+ child: () => log,
24
+ } as unknown as Logger;
25
+ return log;
26
+ })();
27
+
10
28
  const buildConnection = (opts: {
11
29
  uidMap?: Map<number, number>;
12
30
  moveError?: Error;
@@ -262,3 +280,54 @@ describe("getPlacementMoveMaxAttempts — env-derived threshold (mirrors #1270's
262
280
  assert.ok(PLACEMENT_MOVE_MAX_ATTEMPTS > 0);
263
281
  });
264
282
  });
283
+
284
+ describe("handlePlacementMovePush — deleted mailbox is terminal (#287/#289)", () => {
285
+ const accountId = "pm-acc-zzz";
286
+ const messageId = "pm-msg-zzz";
287
+ const destinationMailboxId = "pm-dest-zzz";
288
+
289
+ const event: PlacementMovePushEvent = {
290
+ type: "PLACEMENT_MOVE_PUSH",
291
+ accountId,
292
+ accountConfigId: "pm-cfg-zzz",
293
+ messageId,
294
+ eventId: "pm-evt-zzz",
295
+ timestamp: 1700000000000,
296
+ };
297
+
298
+ afterEach(() => mock.restoreAll());
299
+
300
+ it("drops the marker and acks without pushing when a mailbox is gone", async () => {
301
+ const client = await getClient();
302
+ mock.method(client.account, "get", async () => ({
303
+ accountId,
304
+ accountConfigId: "pm-cfg-zzz",
305
+ }));
306
+ mock.method(client.message, "get", async () => ({
307
+ messageId,
308
+ mailboxId: destinationMailboxId,
309
+ }));
310
+ mock.method(client.mailbox, "get", async () => {
311
+ throw Object.assign(new Error("Mailbox not found: pm-src-zzz"), {
312
+ name: "NotFoundError",
313
+ });
314
+ });
315
+ mock.method(client.placementMove, "find", async () => ({
316
+ sourceMailboxId: "pm-src-zzz",
317
+ destinationMailboxId,
318
+ }));
319
+ mock.method(client.placementMove, "updateState", async () => {});
320
+ const deleteMarker = mock.method(
321
+ client.placementMove,
322
+ "delete",
323
+ async () => {},
324
+ );
325
+
326
+ // Must resolve, not reject — a deleted folder is an expected terminal
327
+ // outcome, not a fault to retry on the account's per-group FIFO.
328
+ await handlePlacementMovePush(event, silentLogger, 1);
329
+
330
+ assert.equal(deleteMarker.mock.calls.length, 1, "the marker is dropped");
331
+ assert.deepEqual(deleteMarker.mock.calls[0].arguments, [messageId]);
332
+ });
333
+ });
@@ -14,6 +14,7 @@ import { isAccountDeleted } from "../account-check.js";
14
14
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
15
  import { emitEvent } from "../emit.js";
16
16
  import type { PlacementMovePushEvent } from "../events.js";
17
+ import { isNotFoundError } from "../is-not-found.js";
17
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
18
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
19
20
  import {
@@ -227,14 +228,32 @@ export const handlePlacementMovePush = async (
227
228
  // died mid-flight already left it here).
228
229
  await markerService.updateState(messageId, "processing");
229
230
 
230
- const sourceMailbox = await mailboxService.get(
231
- accountId,
232
- marker.sourceMailboxId,
233
- );
234
- const destinationMailbox = await mailboxService.get(
235
- accountId,
236
- marker.destinationMailboxId,
237
- );
231
+ // Either folder can be deleted between enqueue and this push, leaving a
232
+ // marker pointing at a gone row. The lookup then throws NotFoundError forever,
233
+ // and on the account's per-group FIFO that head message stalls the whole
234
+ // pipeline (issues #287, #289, #290). The move is moot — drop the marker (as
235
+ // the superseded branch above does) and ack with a WARN.
236
+ const mailboxes = await Promise.all([
237
+ mailboxService.get(accountId, marker.sourceMailboxId),
238
+ mailboxService.get(accountId, marker.destinationMailboxId),
239
+ ]).catch((error: unknown) => {
240
+ if (isNotFoundError(error)) return null;
241
+ throw error;
242
+ });
243
+ if (!mailboxes) {
244
+ await markerService.delete(messageId);
245
+ log.warn(
246
+ {
247
+ messageId,
248
+ accountId,
249
+ sourceMailboxId: marker.sourceMailboxId,
250
+ destinationMailboxId: marker.destinationMailboxId,
251
+ },
252
+ "Skipping PLACEMENT_MOVE_PUSH: a mailbox no longer exists (deleted); marker dropped",
253
+ );
254
+ return;
255
+ }
256
+ const [sourceMailbox, destinationMailbox] = mailboxes;
238
257
 
239
258
  // Cheap frugal skip (epic #1281 invariant 6): either mailbox already known
240
259
  // paused never even borrows a connection. Optimization only — the
@@ -302,6 +302,54 @@ describe("syncMessageBody — DLQ propagation (integrated, #1270)", () => {
302
302
  );
303
303
  });
304
304
 
305
+ test("deleted mailbox: acks-and-skips without ever calling BodySyncService.syncBodies (#287/#289)", async () => {
306
+ // A SYNC_MESSAGE_BODY trigger outlived the folder it targeted. The mailbox
307
+ // lookup throws NotFoundError, which would poison the account's per-group
308
+ // body FIFO forever; the handler must resolve it terminally instead.
309
+ mockClient(SSMClient)
310
+ .on(GetParameterCommand)
311
+ .resolves({ Parameter: { Value: "true" } });
312
+
313
+ mock.method((await getClient()).account, "get", async () =>
314
+ cappedAccount(),
315
+ );
316
+ mock.method((await getClient()).mailbox, "get", async () => {
317
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
318
+ name: "NotFoundError",
319
+ });
320
+ });
321
+ mock.method(
322
+ (await getClient()).secrets,
323
+ "decrypt",
324
+ async () => "fake-password",
325
+ );
326
+ const syncBodies = mock.method(
327
+ BodySyncService.prototype,
328
+ "syncBodies",
329
+ async () => {
330
+ throw new Error("must not be called for a deleted mailbox");
331
+ },
332
+ );
333
+
334
+ const event: SyncMessageBodyEvent = {
335
+ ...baseEvent,
336
+ accountId,
337
+ mailboxId,
338
+ messageIds: ["msg-1"],
339
+ messages: [{ messageId: "msg-1", uid: 101 }],
340
+ };
341
+
342
+ // Must resolve, not reject — a deleted folder is an expected terminal
343
+ // outcome, not a fault to retry/DLQ.
344
+ await syncMessageBody(event, silentLogger, 1);
345
+
346
+ assert.equal(
347
+ syncBodies.mock.calls.length,
348
+ 0,
349
+ "a deleted mailbox must never reach the body fetch",
350
+ );
351
+ });
352
+
305
353
  test("cursor_invalid: acks-and-skips without ever calling BodySyncService.syncBodies (#1272)", async () => {
306
354
  // The cheap pre-check (isCursorRebuildNeeded, run before borrowing any
307
355
  // connection — frugal, epic #1281 invariant 6) catches an already-paused
@@ -19,6 +19,7 @@ import {
19
19
  createConnectionScopeWithCredentials,
20
20
  } from "../connection-scope.js";
21
21
  import type { SyncMessageBodyEvent } from "../events.js";
22
+ import { isNotFoundError } from "../is-not-found.js";
22
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
23
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
24
25
  import { workerVersion } from "../worker-version.js";
@@ -184,7 +185,25 @@ export const syncMessageBody = async (
184
185
  account,
185
186
  log,
186
187
  async (credentials) => {
187
- const mailbox = await mailboxService.get(accountId, mailboxId);
188
+ // A SYNC_MESSAGE_BODY trigger can outlive the mailbox it targets: deleting
189
+ // a folder that held mail leaves already-queued body events pointing at a
190
+ // row that is now gone, and the lookup then throws NotFoundError forever.
191
+ // Since the body queue carries MessageGroupId=accountId, that head message
192
+ // stalls the account's whole body pipeline (issues #287, #289, #290). A
193
+ // deleted folder is an expected terminal outcome: ack with a WARN.
194
+ const mailbox = await mailboxService
195
+ .get(accountId, mailboxId)
196
+ .catch((error: unknown) => {
197
+ if (isNotFoundError(error)) return null;
198
+ throw error;
199
+ });
200
+ if (!mailbox) {
201
+ log.warn(
202
+ { accountId, mailboxId, eventId: event.eventId },
203
+ "Skipping SYNC_MESSAGE_BODY: mailbox no longer exists (deleted)",
204
+ );
205
+ return;
206
+ }
188
207
 
189
208
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
190
209
  // paused never even borrows a connection. This is an optimization only
@@ -32,6 +32,7 @@ import type {
32
32
  SyncMessageBodyEvent,
33
33
  SyncMessagesEvent,
34
34
  } from "../events.js";
35
+ import { isNotFoundError } from "../is-not-found.js";
35
36
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
36
37
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
37
38
  import { workerVersion } from "../worker-version.js";
@@ -104,7 +105,7 @@ export const syncMessages = async (
104
105
  // other errors and still propagate to be retried.
105
106
  let account: AccountItem;
106
107
  const rawAccount = await accountService.get(event.accountId).catch((err) => {
107
- if ((err as { name?: string })?.name === "NotFoundError") return null;
108
+ if (isNotFoundError(err)) return null;
108
109
  throw err;
109
110
  });
110
111
  if (!rawAccount) {
@@ -144,7 +145,7 @@ export const syncMessages = async (
144
145
  .get(event.accountId, event.mailboxId)
145
146
  .then(() => true)
146
147
  .catch((err) => {
147
- if ((err as { name?: string })?.name === "NotFoundError") return false;
148
+ if (isNotFoundError(err)) return false;
148
149
  throw err;
149
150
  });
150
151
  if (!mailboxExists) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A repository lookup or write for a row that no longer exists rejects with a
3
+ * `NotFoundError` (name-matched, since the class crosses the data-ports adapter
4
+ * boundary — see `@remit/data-ports/errors`).
5
+ *
6
+ * A worker event that references a deliberately-deleted mailbox is completed or
7
+ * moot work, never a transient fault: every redelivery re-throws the same
8
+ * `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
9
+ * that permanently-failing head message stalls the whole account's per-group
10
+ * FIFO. Handlers use this predicate to resolve such an event terminally (ack
11
+ * with a WARN) instead of retrying forever (issues #287, #289, #290).
12
+ *
13
+ * The guard is narrow on purpose: only a genuine not-found terminates. Real
14
+ * IMAP/infra failures carry other errors and must still propagate to be retried.
15
+ */
16
+ export const isNotFoundError = (error: unknown): boolean =>
17
+ (error as { name?: string })?.name === "NotFoundError";