@remit/mailbox-service 0.0.28 → 0.0.29

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/mailbox-service",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -148,6 +148,10 @@ export {
148
148
  validateMailboxOperation,
149
149
  validateMailboxPath,
150
150
  } from "./mailbox-management.js";
151
+ export {
152
+ isFolderOffServer,
153
+ isMailboxNotOnServer,
154
+ } from "./mailbox-presence.js";
151
155
  export {
152
156
  type CreateMailboxQueueInput,
153
157
  type MailboxQueueConfig,
@@ -0,0 +1,57 @@
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ import { MailboxSyncStatus } from "@remit/domain-enums";
3
+
4
+ const isNotFoundError = (error: unknown): boolean =>
5
+ error instanceof Error && error.name === "NotFoundError";
6
+
7
+ /**
8
+ * Whether the IMAP server is not known to hold this folder — the row is gone,
9
+ * its creation has not reached the server yet, or its deletion has been asked
10
+ * for and not finished. Folders are written locally first and reconciled to the
11
+ * server by the worker, so both ends of that lifecycle are states in which
12
+ * syncing the folder can only fail.
13
+ *
14
+ * This is the terminal test for a sync event whose folder is not there. The
15
+ * row's absence alone does not decide it, at either end: on the way out the row
16
+ * is removed only after the IMAP folder is, so an event that fails against the
17
+ * server still sees a live row for as long as that write takes; on the way in
18
+ * the row exists before the folder does. `pending` and `deleting` are written by
19
+ * the request itself, before the event that resolves it is enqueued, which is
20
+ * what makes them sound proxies for presence: neither can be observed unless a
21
+ * create has yet to land or a delete has been asked for.
22
+ *
23
+ * `failed` is deliberately not one of them, though it is the fourth state a
24
+ * mailbox row can carry. It records that the last management operation failed
25
+ * and says nothing about whether the folder is on the server: `handleDelete` and
26
+ * `handleRename` set it on folders that exist and hold the user's mail, and
27
+ * nothing ever clears it — `syncStatus` returns to `synced` only from a
28
+ * subsequent successful create or rename, never from the sweep. Treating it as
29
+ * off-server would stop syncing such a folder permanently and silently, which is
30
+ * worse than the bounded stall it would remove. A `failed` row whose folder is
31
+ * genuinely absent is reaped by the sweep's own cleanup, which runs before the
32
+ * fan-out that would enqueue work for it.
33
+ *
34
+ * Mailbox management is judged differently — it is what establishes and removes
35
+ * the folder, so it terminates on a not-found row alone.
36
+ */
37
+ export const isFolderOffServer = (
38
+ mailbox: Pick<MailboxItem, "syncStatus">,
39
+ ): boolean =>
40
+ mailbox.syncStatus === MailboxSyncStatus.pending ||
41
+ mailbox.syncStatus === MailboxSyncStatus.deleting;
42
+
43
+ /** {@link isFolderOffServer} for a mailbox that has to be read first, an absent row included. */
44
+ export const isMailboxNotOnServer = async (
45
+ mailboxService: Pick<IMailboxRepository, "get">,
46
+ accountId: string,
47
+ mailboxId: string,
48
+ ): Promise<boolean> => {
49
+ const mailbox = await mailboxService
50
+ .get(accountId, mailboxId)
51
+ .catch((error: unknown) => {
52
+ if (isNotFoundError(error)) return undefined;
53
+ throw error;
54
+ });
55
+ if (!mailbox) return true;
56
+ return isFolderOffServer(mailbox);
57
+ };
@@ -4,6 +4,7 @@ import type {
4
4
  IMailboxRepository,
5
5
  IMailboxSpecialUseRepository,
6
6
  } from "@remit/data-ports";
7
+ import { NotFoundError } from "@remit/data-ports/errors";
7
8
  import {
8
9
  MailboxCursorState,
9
10
  MailboxSpecialUse,
@@ -309,3 +310,161 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
309
310
  assert.equal(result.deleted, 1);
310
311
  });
311
312
  });
313
+
314
+ /**
315
+ * The folder set can also change under a running sweep: a delete asked for while
316
+ * the account is enumerating lands between the LIST and one folder's STATUS.
317
+ * Failing the whole account's enumeration over that one folder stalls every
318
+ * later sync for the account, on a per-account FIFO queue, for the queue's whole
319
+ * visibility window (issue #339).
320
+ */
321
+ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)", () => {
322
+ const namespaces: ImapNamespaces = {
323
+ personal: [{ prefix: "", delimiter: "/" }],
324
+ other: [],
325
+ shared: [],
326
+ };
327
+
328
+ const status = () => ({
329
+ messages: 3,
330
+ recent: 0,
331
+ unseen: 0,
332
+ uidNext: 100,
333
+ uidValidity: 1,
334
+ highestModseq: "0",
335
+ deletedCount: 0,
336
+ });
337
+
338
+ const buildConnection = (
339
+ statusFor: (path: string) => Promise<ReturnType<typeof status>>,
340
+ ): { connection: IImapConnection; statusPaths: string[] } => {
341
+ const statusPaths: string[] = [];
342
+ const connection = {
343
+ getNamespaces: async () => namespaces,
344
+ listMailboxes: async () =>
345
+ ["INBOX", "Doomed"].map((fullPath) => ({
346
+ fullPath,
347
+ name: fullPath,
348
+ delimiter: "/",
349
+ attributes: [],
350
+ parentPath: null,
351
+ })),
352
+ getMailboxStatus: async (path: string) => {
353
+ statusPaths.push(path);
354
+ return statusFor(path);
355
+ },
356
+ } as unknown as IImapConnection;
357
+ return { connection, statusPaths };
358
+ };
359
+
360
+ const buildServices = (options: {
361
+ doomedSyncStatus?: string;
362
+ getDoomed?: () => Promise<unknown>;
363
+ }) => {
364
+ const updatedIds: string[] = [];
365
+ const row = (mailboxId: string, fullPath: string, syncStatus?: string) => ({
366
+ mailboxId,
367
+ fullPath,
368
+ uidNext: 1,
369
+ uidValidity: 1,
370
+ messageCount: 0,
371
+ unseenCount: 0,
372
+ deletedCount: 0,
373
+ highestModseq: "0",
374
+ specialUse: undefined,
375
+ syncStatus,
376
+ });
377
+
378
+ const mailboxService = {
379
+ listByAccount: async () => ({
380
+ items: [
381
+ row("mbx-inbox", "INBOX", MailboxSyncStatus.synced),
382
+ row("mbx-doomed", "Doomed", options.doomedSyncStatus),
383
+ ],
384
+ continuationToken: undefined,
385
+ }),
386
+ get: async (_accountId: string, mailboxId: string) => {
387
+ if (mailboxId === "mbx-doomed" && options.getDoomed) {
388
+ return options.getDoomed();
389
+ }
390
+ return row(mailboxId, mailboxId, MailboxSyncStatus.synced);
391
+ },
392
+ update: async (
393
+ _accountId: string,
394
+ mailboxId: string,
395
+ _patch: Record<string, unknown>,
396
+ ) => {
397
+ updatedIds.push(mailboxId);
398
+ return {};
399
+ },
400
+ delete: async () => undefined,
401
+ create: async () => ({}),
402
+ } as unknown as IMailboxRepository;
403
+
404
+ const specialUseService = {
405
+ listByMailboxId: async () => [],
406
+ deleteByMailboxId: async () => undefined,
407
+ createMany: async () => undefined,
408
+ } as unknown as IMailboxSpecialUseRepository;
409
+
410
+ return { mailboxService, specialUseService, updatedIds };
411
+ };
412
+
413
+ for (const syncStatus of [
414
+ MailboxSyncStatus.pending,
415
+ MailboxSyncStatus.deleting,
416
+ ]) {
417
+ it(`leaves a \`${syncStatus}\` folder untouched — no STATUS, no write`, async () => {
418
+ const { mailboxService, specialUseService, updatedIds } = buildServices({
419
+ doomedSyncStatus: syncStatus,
420
+ });
421
+ const { connection, statusPaths } = buildConnection(async () => status());
422
+ const service = new MailboxSyncService(mailboxService, specialUseService);
423
+
424
+ await service.syncMailboxes({ accountId: "acc-1" }, connection);
425
+
426
+ assert.deepEqual(statusPaths, ["INBOX"]);
427
+ assert.deepEqual(updatedIds, ["mbx-inbox"]);
428
+ });
429
+ }
430
+
431
+ it("finishes the sweep when a folder's STATUS fails and that folder has since been deleted", async () => {
432
+ const { mailboxService, specialUseService, updatedIds } = buildServices({
433
+ doomedSyncStatus: MailboxSyncStatus.synced,
434
+ getDoomed: async () => {
435
+ throw new NotFoundError("Mailbox not found: mbx-doomed");
436
+ },
437
+ });
438
+ const { connection } = buildConnection(async (path) => {
439
+ if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
440
+ return status();
441
+ });
442
+ const service = new MailboxSyncService(mailboxService, specialUseService);
443
+
444
+ await assert.doesNotReject(
445
+ service.syncMailboxes({ accountId: "acc-1" }, connection),
446
+ );
447
+ assert.deepEqual(updatedIds, ["mbx-inbox"]);
448
+ });
449
+
450
+ it("fails the sweep when a folder's STATUS fails and the folder is still live", async () => {
451
+ const { mailboxService, specialUseService } = buildServices({
452
+ doomedSyncStatus: MailboxSyncStatus.synced,
453
+ getDoomed: async () => ({
454
+ mailboxId: "mbx-doomed",
455
+ fullPath: "Doomed",
456
+ syncStatus: MailboxSyncStatus.synced,
457
+ }),
458
+ });
459
+ const { connection } = buildConnection(async (path) => {
460
+ if (path === "Doomed") throw new Error("connection reset by peer");
461
+ return status();
462
+ });
463
+ const service = new MailboxSyncService(mailboxService, specialUseService);
464
+
465
+ await assert.rejects(
466
+ service.syncMailboxes({ accountId: "acc-1" }, connection),
467
+ /connection reset by peer/,
468
+ );
469
+ });
470
+ });
@@ -19,6 +19,7 @@ import {
19
19
  import pMap from "p-map";
20
20
  import { isNoSelect, parseImapAttributes } from "./attribute-mapper.js";
21
21
  import { isCursorRebuildNeeded } from "./mailbox-cursor.js";
22
+ import { isMailboxNotOnServer } from "./mailbox-presence.js";
22
23
  import type {
23
24
  FlatMailboxInfo,
24
25
  IImapConnection,
@@ -175,12 +176,39 @@ export class MailboxSyncService {
175
176
  const existing = existingByPath.get(mailboxInfo.fullPath);
176
177
 
177
178
  if (existing) {
179
+ // A folder at either end of its lifecycle is left alone: the worker
180
+ // that establishes or removes it writes its own identity back, and
181
+ // reading its status meanwhile is work whose only possible outcome is
182
+ // a failure that fails this whole account's fan-out with it.
183
+ if (
184
+ existing.syncStatus === MailboxSyncStatus.pending ||
185
+ existing.syncStatus === MailboxSyncStatus.deleting
186
+ ) {
187
+ return;
188
+ }
189
+ // The folder set can change under this sweep. A delete that lands
190
+ // between the LIST above and this mailbox's STATUS fails on the
191
+ // server or on the row, and that would abort the enumeration of every
192
+ // other folder and the SYNC_MESSAGES fan-out behind it — on a
193
+ // per-account FIFO queue, for the whole visibility window (issue
194
+ // #339). One folder leaving is not a failed account sync. Anything
195
+ // else still propagates.
178
196
  const updated = await this.updateMailbox(
179
197
  account.accountId,
180
198
  existing,
181
199
  mailboxInfo,
182
200
  connection,
183
- );
201
+ ).catch(async (error: unknown) => {
202
+ // The read only classifies the failure in hand; one that cannot
203
+ // answer must not replace it.
204
+ const gone = await isMailboxNotOnServer(
205
+ this.mailboxService,
206
+ account.accountId,
207
+ existing.mailboxId,
208
+ ).catch(() => false);
209
+ if (gone) return null;
210
+ throw error;
211
+ });
184
212
  if (updated) {
185
213
  result.updated++;
186
214
  }