@remit/imap-worker 0.0.73 → 0.0.74

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.73",
3
+ "version": "0.0.74",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -42,6 +42,7 @@ interface Harness {
42
42
  } | null;
43
43
  connection: Connection;
44
44
  mailboxUpdateError?: Error;
45
+ mailboxRowGone?: boolean;
45
46
  disconnectCount: number;
46
47
  }
47
48
 
@@ -96,6 +97,13 @@ const deps = (): MailboxManagementDeps =>
96
97
  h.calls.push({ method: "mailbox.update", args });
97
98
  if (h.mailboxUpdateError) throw h.mailboxUpdateError;
98
99
  },
100
+ // The conditional write the folder state now goes through: a row
101
+ // that is gone matches nothing, so the loser gets a null rather
102
+ // than a throw and the handler raises the NotFoundError itself.
103
+ transition: async (...args: unknown[]) => {
104
+ h.calls.push({ method: "mailbox.transition", args });
105
+ return h.mailboxRowGone ? null : {};
106
+ },
99
107
  findByPathPrefix: async (...args: unknown[]) => {
100
108
  h.calls.push({ method: "mailbox.findByPathPrefix", args });
101
109
  return [];
@@ -147,6 +155,12 @@ const called = (method: string): Call[] =>
147
155
  const lastUpdate = (): Record<string, unknown> =>
148
156
  (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
149
157
 
158
+ const lastSettle = (): { to?: string; set?: Record<string, unknown> } =>
159
+ (called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
160
+ to?: string;
161
+ set?: Record<string, unknown>;
162
+ };
163
+
150
164
  describe("processMailboxManagement — MAILBOX_CREATE", () => {
151
165
  beforeEach(() => {
152
166
  h = fresh();
@@ -160,8 +174,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
160
174
  uidValidity: 7,
161
175
  uidNext: 42,
162
176
  messageCount: 3,
163
- syncStatus: "synced",
164
177
  });
178
+ assert.equal(lastSettle().to, "synced");
165
179
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
166
180
  });
167
181
 
@@ -183,7 +197,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
183
197
 
184
198
  await processMailboxManagement(createEvent, noopLogger, deps());
185
199
 
186
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
200
+ assert.equal(called("mailbox.update").length, 0);
201
+ assert.equal(lastSettle().to, "synced");
187
202
  });
188
203
 
189
204
  it("treats an already-existing folder as success rather than a failure", async () => {
@@ -193,7 +208,7 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
193
208
 
194
209
  await processMailboxManagement(createEvent, noopLogger, deps());
195
210
 
196
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
211
+ assert.equal(lastSettle().to, "synced");
197
212
  });
198
213
 
199
214
  it("marks the mailbox failed and rethrows on any other create error", async () => {
@@ -206,14 +221,14 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
206
221
  /server exploded/,
207
222
  );
208
223
 
209
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
224
+ assert.equal(lastSettle().to, "failed");
210
225
  assert.equal(h.disconnectCount, 1);
211
226
  });
212
227
 
213
228
  it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
214
- // The status write-back throws NotFoundError because the row is gone; the
215
- // create is moot and must not poison the account's FIFO.
216
- h.mailboxUpdateError = notFoundError();
229
+ // The settle matches no row because the row is gone; the create is moot
230
+ // and must not poison the account's FIFO.
231
+ h.mailboxRowGone = true;
217
232
 
218
233
  await processMailboxManagement(createEvent, noopLogger, deps());
219
234
 
@@ -247,17 +262,14 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
247
262
  h = fresh();
248
263
  });
249
264
 
250
- it("renames on the server and clears the pending oldPath", async () => {
265
+ it("renames on the server and settles the row", async () => {
251
266
  await processMailboxManagement(renameEvent, noopLogger, deps());
252
267
 
253
268
  assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
254
269
  "Archive",
255
270
  "Archive 2024",
256
271
  ]);
257
- assert.deepEqual(lastUpdate(), {
258
- oldPath: undefined,
259
- syncStatus: "synced",
260
- });
272
+ assert.equal(lastSettle().to, "synced");
261
273
  });
262
274
 
263
275
  it("drops the local row when the source folder is gone on the server", async () => {
@@ -268,14 +280,14 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
268
280
  await processMailboxManagement(renameEvent, noopLogger, deps());
269
281
 
270
282
  assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
271
- assert.equal(called("mailbox.update").length, 0);
283
+ assert.equal(called("mailbox.transition").length, 0);
272
284
  });
273
285
 
274
286
  it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
275
287
  h.connection.renameMailbox = async () => {
276
288
  throw new Error("server exploded");
277
289
  };
278
- h.mailboxUpdateError = notFoundError();
290
+ h.mailboxRowGone = true;
279
291
 
280
292
  await processMailboxManagement(renameEvent, noopLogger, deps());
281
293
 
@@ -292,11 +304,8 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
292
304
  /server exploded/,
293
305
  );
294
306
 
295
- assert.deepEqual(lastUpdate(), {
296
- fullPath: "Archive",
297
- oldPath: undefined,
298
- syncStatus: "failed",
299
- });
307
+ assert.equal(lastSettle().to, "failed");
308
+ assert.equal(lastSettle().set?.fullPath, "Archive");
300
309
  });
301
310
  });
302
311
 
@@ -329,7 +338,7 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
329
338
 
330
339
  await processMailboxManagement(deleteEvent, noopLogger, deps());
331
340
 
332
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
341
+ assert.equal(lastSettle().to, "synced");
333
342
  assert.equal(called("mailbox.delete").length, 0);
334
343
  });
335
344
 
@@ -343,14 +352,14 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
343
352
  /server exploded/,
344
353
  );
345
354
 
346
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
355
+ assert.equal(lastSettle().to, "failed");
347
356
  });
348
357
 
349
358
  it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
350
359
  h.connection.deleteMailbox = async () => {
351
360
  throw new Error("server exploded");
352
361
  };
353
- h.mailboxUpdateError = notFoundError();
362
+ h.mailboxRowGone = true;
354
363
 
355
364
  await processMailboxManagement(deleteEvent, noopLogger, deps());
356
365
 
@@ -381,7 +390,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
381
390
  processMailboxManagement(deleteEvent, noopLogger, deps()),
382
391
  );
383
392
  assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
384
- assert.equal(called("mailbox.update").length, 0);
393
+ assert.equal(called("mailbox.transition").length, 0);
385
394
  });
386
395
 
387
396
  it("still marks failed and rethrows when the server fails for any other reason", async () => {
@@ -396,7 +405,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
396
405
  processMailboxManagement(deleteEvent, noopLogger, deps()),
397
406
  /Command failed/,
398
407
  );
399
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
408
+ assert.equal(lastSettle().to, "failed");
400
409
  });
401
410
 
402
411
  /**
@@ -416,7 +425,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
416
425
  await assert.doesNotReject(
417
426
  processMailboxManagement(createEvent, noopLogger, deps()),
418
427
  );
419
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
428
+ assert.equal(lastSettle().to, "synced");
420
429
  });
421
430
 
422
431
  it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
@@ -431,7 +440,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
431
440
  processMailboxManagement(createEvent, noopLogger, deps()),
432
441
  /Command failed/,
433
442
  );
434
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
443
+ assert.equal(lastSettle().to, "failed");
435
444
  });
436
445
 
437
446
  it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
@@ -460,6 +469,6 @@ describe("processMailboxManagement — a tagged NO the server means as success (
460
469
  processMailboxManagement(renameEvent, noopLogger, deps()),
461
470
  /Command failed/,
462
471
  );
463
- assert.equal(lastUpdate()?.syncStatus, "failed");
472
+ assert.equal(lastSettle().to, "failed");
464
473
  });
465
474
  });
@@ -1,8 +1,12 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import { isNotFoundError } from "@remit/data-ports/errors";
2
+ import type { IMailboxRepository } from "@remit/data-ports";
3
+ import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
3
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
4
5
  import type { Logger } from "@remit/logger-lambda";
5
- import { MailboxManagementService } from "@remit/mailbox-service";
6
+ import {
7
+ EVERY_MAILBOX_STATE,
8
+ MailboxManagementService,
9
+ } from "@remit/mailbox-service";
6
10
  import { isAccountDeleted } from "../account-check.js";
7
11
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
8
12
  import type {
@@ -59,6 +63,36 @@ const isMailboxAbsentUpstream = (error: unknown): boolean => {
59
63
  return /not found|does ?n.?t exist/i.test(saidByServer(error));
60
64
  };
61
65
 
66
+ /**
67
+ * Write the outcome of a folder operation back, as the conditional write that
68
+ * is the only way to write a folder's state
69
+ * (docs/architecture/folder-rename-and-delete.md D3).
70
+ *
71
+ * The from-set is every state because that is what these write-backs decide
72
+ * against today — they are the unconditional writes they replace, moved onto
73
+ * the door. #363 narrows each to the state its intent recorded, and gives a
74
+ * lost predicate its `superseded` outcome. A null means the row is gone, which
75
+ * is the NotFoundError the whole-chain guards below classify as the user having
76
+ * deleted the folder mid-sync.
77
+ */
78
+ const recordOutcome = async (
79
+ mailboxService: Pick<IMailboxRepository, "transition">,
80
+ accountId: string,
81
+ mailboxId: string,
82
+ to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
83
+ confirmedPath?: string,
84
+ ): Promise<void> => {
85
+ const written = await mailboxService.transition(accountId, mailboxId, {
86
+ from: EVERY_MAILBOX_STATE,
87
+ to,
88
+ // `failed` keeps whatever rename target the row carries, so the UI can
89
+ // name what the rename was aiming at (T6); every other outcome drops it,
90
+ // which the transition does on its own.
91
+ set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
92
+ });
93
+ if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
94
+ };
95
+
62
96
  /** The folder is already on the server: a create has nothing left to do. */
63
97
  const isMailboxPresentUpstream = (error: unknown): boolean => {
64
98
  if (!(error instanceof Error)) return false;
@@ -158,13 +192,19 @@ const handleCreate = async (
158
192
  { accountId, mailboxId, path },
159
193
  "Mailbox already exists, marking as synced",
160
194
  );
161
- await mailboxService.update(accountId, mailboxId, {
162
- syncStatus: MailboxSyncStatus.synced,
163
- });
195
+ await recordOutcome(
196
+ mailboxService,
197
+ accountId,
198
+ mailboxId,
199
+ MailboxSyncStatus.synced,
200
+ );
164
201
  } else {
165
- await mailboxService.update(accountId, mailboxId, {
166
- syncStatus: MailboxSyncStatus.failed,
167
- });
202
+ await recordOutcome(
203
+ mailboxService,
204
+ accountId,
205
+ mailboxId,
206
+ MailboxSyncStatus.failed,
207
+ );
168
208
  throw error;
169
209
  }
170
210
  })
@@ -269,11 +309,13 @@ const handleRename = async (
269
309
  await mailboxService.delete(accountId, mailboxId);
270
310
  } else {
271
311
  // Rollback local rename by restoring old path
272
- await mailboxService.update(accountId, mailboxId, {
273
- fullPath: oldPath,
274
- oldPath: undefined,
275
- syncStatus: MailboxSyncStatus.failed,
276
- });
312
+ await recordOutcome(
313
+ mailboxService,
314
+ accountId,
315
+ mailboxId,
316
+ MailboxSyncStatus.failed,
317
+ oldPath,
318
+ );
277
319
  throw error;
278
320
  }
279
321
  })
@@ -370,9 +412,12 @@ const handleDelete = async (
370
412
  error.message.includes("Cannot delete INBOX")
371
413
  ) {
372
414
  // Restore the mailbox
373
- await mailboxService.update(accountId, mailboxId, {
374
- syncStatus: MailboxSyncStatus.synced,
375
- });
415
+ await recordOutcome(
416
+ mailboxService,
417
+ accountId,
418
+ mailboxId,
419
+ MailboxSyncStatus.synced,
420
+ );
376
421
  log.error(
377
422
  { accountId, mailboxId, path },
378
423
  "Cannot delete INBOX, restoring mailbox",
@@ -380,9 +425,12 @@ const handleDelete = async (
380
425
  // Don't rethrow - this is an expected error
381
426
  } else {
382
427
  // Restore the mailbox on other errors
383
- await mailboxService.update(accountId, mailboxId, {
384
- syncStatus: MailboxSyncStatus.failed,
385
- });
428
+ await recordOutcome(
429
+ mailboxService,
430
+ accountId,
431
+ mailboxId,
432
+ MailboxSyncStatus.failed,
433
+ );
386
434
  throw error;
387
435
  }
388
436
  })
@@ -6,7 +6,7 @@ import {
6
6
  BodySyncService,
7
7
  guardConnectionCursor,
8
8
  isCursorRebuildNeeded,
9
- isFolderOffServer,
9
+ isFolderMutationInFlight,
10
10
  MailboxCursorPausedError,
11
11
  PlacementMoveService,
12
12
  QuarantineService,
@@ -179,10 +179,10 @@ export const syncMessageBody = async (
179
179
  // A folder still `pending` is terminal for the same reason: the batch
180
180
  // was cut before the folder reached the server, so there is nothing
181
181
  // there to fetch from and no retry that changes it.
182
- if (!mailbox || isFolderOffServer(mailbox)) {
182
+ if (!mailbox || isFolderMutationInFlight(mailbox)) {
183
183
  log.warn(
184
184
  { accountId, mailboxId, eventId: event.eventId },
185
- "Skipping SYNC_MESSAGE_BODY: the server does not hold this folder",
185
+ "Skipping SYNC_MESSAGE_BODY: a folder mutation is in flight",
186
186
  );
187
187
  return;
188
188
  }
@@ -320,7 +320,7 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
320
320
  "a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
321
321
  );
322
322
  const skip = warns.find((w) =>
323
- w.msg.includes("the server does not hold this folder"),
323
+ w.msg.includes("a folder mutation is in flight"),
324
324
  );
325
325
  assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
326
326
  assert.equal(skip.fields.accountId, "acc-1");
@@ -371,9 +371,7 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
371
371
 
372
372
  assert.equal(harness.lifecycleCalls, 0);
373
373
  assert.ok(
374
- warns.find((w) =>
375
- w.msg.includes("the server does not hold this folder"),
376
- ),
374
+ warns.find((w) => w.msg.includes("a folder mutation is in flight")),
377
375
  );
378
376
  });
379
377
  }
@@ -19,7 +19,7 @@ import { type Logger, recordImapFailure } from "@remit/logger-lambda";
19
19
  import { RefreshTokenError } from "@remit/mail-oauth-service";
20
20
  import {
21
21
  createManagedConnectionFactory,
22
- isMailboxNotOnServer,
22
+ isMailboxMutationInFlight,
23
23
  MailConnectionError,
24
24
  type MailCredentials,
25
25
  MessageSyncService,
@@ -133,17 +133,23 @@ export const syncMessages = async (
133
133
  return;
134
134
  }
135
135
 
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.
136
+ // A SYNC_MESSAGES trigger can address a folder a mutation still owns: the
137
+ // fan-out enqueues one for a folder whose own create has not landed yet, a
138
+ // `hasMore` next-batch or a periodic tick outlives a folder the user
139
+ // deleted, and a rename in flight leaves the folder on the server but under
140
+ // the worker that is moving it. Such an event can never succeed on retry,
141
+ // and the account's per-group FIFO ordering lets that one poison message
142
+ // stall the whole account's message pipeline for the full visibility window
143
+ // (issue #287, #339). A folder with a create, rename or delete outstanding
144
+ // is an expected terminal outcome, not an infra failure: ack the event with
145
+ // a WARN. The rename case is conservative rather than necessary — syncing it
146
+ // would be safe — and keeps the guard a single expression (D12).
145
147
  if (
146
- await isMailboxNotOnServer(mailboxService, event.accountId, event.mailboxId)
148
+ await isMailboxMutationInFlight(
149
+ mailboxService,
150
+ event.accountId,
151
+ event.mailboxId,
152
+ )
147
153
  ) {
148
154
  log.warn(
149
155
  {
@@ -152,7 +158,7 @@ export const syncMessages = async (
152
158
  eventId: event.eventId,
153
159
  event: event.type,
154
160
  },
155
- "Skipping SYNC_MESSAGES: the server does not hold this folder",
161
+ "Skipping SYNC_MESSAGES: a folder mutation is in flight",
156
162
  );
157
163
  return;
158
164
  }
@@ -214,7 +220,7 @@ export const syncMessages = async (
214
220
  // never become the failure that is reported. A read that cannot answer
215
221
  // leaves the round on the loud path below, with the real error and the
216
222
  // state write that goes with it intact.
217
- const folderIsGone = await isMailboxNotOnServer(
223
+ const folderIsGone = await isMailboxMutationInFlight(
218
224
  mailboxService,
219
225
  event.accountId,
220
226
  event.mailboxId,