@remit/mailbox-service 0.0.27 → 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.27",
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,
@@ -1,10 +1,18 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, it } from "node:test";
3
+ import type { IMailboxRepository } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
3
5
  import {
6
+ MailboxManagementService,
4
7
  parseMailboxPath,
5
8
  validateMailboxOperation,
6
9
  validateMailboxPath,
7
10
  } from "./mailbox-management.js";
11
+ import type {
12
+ FlatMailboxInfo,
13
+ IImapConnection,
14
+ ImapBoxStatus,
15
+ } from "./types.js";
8
16
 
9
17
  describe("parseMailboxPath", () => {
10
18
  it("parses simple path", () => {
@@ -76,6 +84,161 @@ describe("validateMailboxPath", () => {
76
84
  });
77
85
  });
78
86
 
87
+ const boxStatus = (path: string): ImapBoxStatus => ({
88
+ name: path,
89
+ readOnly: true,
90
+ uidvalidity: 42,
91
+ uidnext: 7,
92
+ flags: [],
93
+ permFlags: [],
94
+ persistentUIDs: true,
95
+ messages: { total: 0, new: 0 },
96
+ newKeywords: false,
97
+ });
98
+
99
+ const recordingRepo = () => {
100
+ const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
101
+ [];
102
+ const repo = {
103
+ update: async (
104
+ _accountId: string,
105
+ mailboxId: string,
106
+ patch: Record<string, unknown>,
107
+ ) => {
108
+ updates.push({ mailboxId, patch });
109
+ return {} as never;
110
+ },
111
+ } as unknown as IMailboxRepository;
112
+ return { repo, updates };
113
+ };
114
+
115
+ const stubConnection = (
116
+ createdPath: string,
117
+ listed: string[],
118
+ ): { connection: IImapConnection; opened: string[] } => {
119
+ const opened: string[] = [];
120
+ const connection = {
121
+ createMailbox: async (_path: string) => ({
122
+ path: createdPath,
123
+ created: true,
124
+ }),
125
+ subscribeMailbox: async () => undefined,
126
+ listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
127
+ listed.map((fullPath) => ({
128
+ fullPath,
129
+ name: fullPath.split("/").pop() ?? fullPath,
130
+ delimiter: "/",
131
+ attributes: [],
132
+ parentPath: null,
133
+ })),
134
+ openBox: async (path: string) => {
135
+ opened.push(path);
136
+ return boxStatus(path);
137
+ },
138
+ closeBox: async () => undefined,
139
+ } as unknown as IImapConnection;
140
+ return { connection, opened };
141
+ };
142
+
143
+ describe("MailboxManagementService.syncCreate — server path normalization", () => {
144
+ it("adopts the server-materialized path as the row's identity when it is prefixed", async () => {
145
+ const { repo, updates } = recordingRepo();
146
+ const { connection, opened } = stubConnection("INBOX/Notifications", [
147
+ "INBOX/Notifications",
148
+ ]);
149
+ const service = new MailboxManagementService(repo);
150
+
151
+ const result = await service.syncCreate(
152
+ "acc-1",
153
+ "mbx-1",
154
+ "Notifications",
155
+ async () => connection,
156
+ );
157
+
158
+ assert.strictEqual(result.success, true);
159
+ // The mailbox is opened at the server's path, not the requested leaf.
160
+ assert.deepStrictEqual(opened, ["INBOX/Notifications"]);
161
+ assert.strictEqual(updates.length, 1);
162
+ // The row keeps its id and takes on the prefixed path, so the next reconcile
163
+ // updates it in place instead of insert+delete — every reference survives.
164
+ assert.strictEqual(updates[0].mailboxId, "mbx-1");
165
+ assert.strictEqual(updates[0].patch.fullPath, "INBOX/Notifications");
166
+ assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
167
+ });
168
+
169
+ it("leaves the path untouched when the server keeps it as requested", async () => {
170
+ const { repo, updates } = recordingRepo();
171
+ const { connection } = stubConnection("Work", ["Work"]);
172
+ const service = new MailboxManagementService(repo);
173
+
174
+ await service.syncCreate("acc-1", "mbx-2", "Work", async () => connection);
175
+
176
+ assert.strictEqual(updates.length, 1);
177
+ assert.ok(!("fullPath" in updates[0].patch));
178
+ assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
179
+ });
180
+
181
+ it("still adopts the server path when the fresh list cannot resolve it", async () => {
182
+ const { repo, updates } = recordingRepo();
183
+ const { connection, opened } = stubConnection("INBOX/Notifications", []);
184
+ const service = new MailboxManagementService(repo);
185
+
186
+ await service.syncCreate(
187
+ "acc-1",
188
+ "mbx-3",
189
+ "Notifications",
190
+ async () => connection,
191
+ );
192
+
193
+ assert.deepStrictEqual(opened, []);
194
+ assert.strictEqual(updates.length, 1);
195
+ assert.strictEqual(updates[0].patch.fullPath, "INBOX/Notifications");
196
+ assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
197
+ });
198
+
199
+ it("falls back to the requested path when the create result carries none", async () => {
200
+ const { repo, updates } = recordingRepo();
201
+ const opened: string[] = [];
202
+ const subscribed: string[] = [];
203
+ const connection = {
204
+ createMailbox: async (_path: string) => ({ created: true }),
205
+ subscribeMailbox: async (p: string) => {
206
+ subscribed.push(p);
207
+ },
208
+ listMailboxes: async (): Promise<FlatMailboxInfo[]> => [
209
+ {
210
+ fullPath: "Archive",
211
+ name: "Archive",
212
+ delimiter: "/",
213
+ attributes: [],
214
+ parentPath: null,
215
+ },
216
+ ],
217
+ openBox: async (p: string) => {
218
+ opened.push(p);
219
+ return boxStatus(p);
220
+ },
221
+ closeBox: async () => undefined,
222
+ } as unknown as IImapConnection;
223
+ const service = new MailboxManagementService(repo);
224
+
225
+ await service.syncCreate(
226
+ "acc-1",
227
+ "mbx-4",
228
+ "Archive",
229
+ async () => connection,
230
+ true,
231
+ );
232
+
233
+ // A thin result never blanks fullPath — the requested path is used throughout.
234
+ assert.deepStrictEqual(subscribed, ["Archive"]);
235
+ assert.deepStrictEqual(opened, ["Archive"]);
236
+ assert.strictEqual(updates.length, 1);
237
+ assert.ok(!("fullPath" in updates[0].patch));
238
+ assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
239
+ });
240
+ });
241
+
79
242
  describe("validateMailboxOperation", () => {
80
243
  it("throws when deleting INBOX", () => {
81
244
  assert.throws(() => validateMailboxOperation("delete", "INBOX"), {
@@ -135,25 +135,41 @@ export class MailboxManagementService {
135
135
 
136
136
  const result = await connection.createMailbox(path);
137
137
 
138
+ // The server is free to materialize the requested path under a namespace
139
+ // prefix — a Dovecot INBOX namespace turns "Notifications" into
140
+ // "INBOX/Notifications". `mailboxCreate` reports the canonical path the
141
+ // server assigned; adopt it as this row's identity so the next mailbox
142
+ // reconcile matches it by fullPath and updates it in place, rather than
143
+ // inserting a fresh row for the prefixed path and deleting this one — which
144
+ // would strand every filter and placement that references this mailboxId.
145
+ // Fall back to the requested path when the result carries no usable path,
146
+ // so a thin result never blanks the row's fullPath to undefined.
147
+ const serverPath =
148
+ typeof result.path === "string" && result.path.length > 0
149
+ ? result.path
150
+ : path;
151
+ const pathUpdate = serverPath !== path ? { fullPath: serverPath } : {};
152
+
138
153
  this.log.info(
139
- { mailboxId, path, created: result.created },
154
+ { mailboxId, path, serverPath, created: result.created },
140
155
  "Created mailbox on IMAP server",
141
156
  );
142
157
 
143
158
  if (subscribe) {
144
- await connection.subscribeMailbox(path);
145
- this.log.info({ mailboxId, path }, "Subscribed to mailbox");
159
+ await connection.subscribeMailbox(serverPath);
160
+ this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
146
161
  }
147
162
 
148
163
  // Refresh mailbox list to get UIDVALIDITY and other attributes
149
164
  const mailboxes = await connection.listMailboxes();
150
- const mailboxInfo = mailboxes.find((m) => m.fullPath === path);
165
+ const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
151
166
 
152
167
  if (mailboxInfo) {
153
168
  // Open the mailbox to get UIDVALIDITY and other status info
154
- const status = await connection.openBox(path, true);
169
+ const status = await connection.openBox(serverPath, true);
155
170
 
156
171
  await this.mailboxService.update(accountId, mailboxId, {
172
+ ...pathUpdate,
157
173
  uidValidity: status.uidvalidity,
158
174
  uidNext: status.uidnext,
159
175
  messageCount: status.messages.total,
@@ -164,6 +180,7 @@ export class MailboxManagementService {
164
180
  } else {
165
181
  // Mark as synced even if we couldn't get full info
166
182
  await this.mailboxService.update(accountId, mailboxId, {
183
+ ...pathUpdate,
167
184
  syncStatus: MailboxSyncStatus.synced,
168
185
  });
169
186
  }
@@ -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
  }