@remit/mailbox-service 0.0.26 → 0.0.28

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.26",
3
+ "version": "0.0.28",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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
  }
@@ -4,7 +4,11 @@ import type {
4
4
  IMailboxRepository,
5
5
  IMailboxSpecialUseRepository,
6
6
  } from "@remit/data-ports";
7
- import { MailboxCursorState, MailboxSpecialUse } from "@remit/domain-enums";
7
+ import {
8
+ MailboxCursorState,
9
+ MailboxSpecialUse,
10
+ MailboxSyncStatus,
11
+ } from "@remit/domain-enums";
8
12
  import { parseImapAttributes } from "./attribute-mapper.js";
9
13
  import { MailboxSyncService } from "./mailbox-sync.js";
10
14
  import type { IImapConnection, ImapNamespaces } from "./types.js";
@@ -180,3 +184,128 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
180
184
  assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
181
185
  });
182
186
  });
187
+
188
+ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending folders (#290)", () => {
189
+ const namespaces: ImapNamespaces = {
190
+ personal: [{ prefix: "", delimiter: "/" }],
191
+ other: [],
192
+ shared: [],
193
+ };
194
+
195
+ // The server lists only INBOX: neither user folder is on it yet.
196
+ const serverConnection = (): IImapConnection =>
197
+ ({
198
+ getNamespaces: async () => namespaces,
199
+ listMailboxes: async () => [
200
+ {
201
+ fullPath: "INBOX",
202
+ name: "INBOX",
203
+ delimiter: "/",
204
+ attributes: [],
205
+ parentPath: null,
206
+ },
207
+ ],
208
+ getMailboxStatus: async () => ({
209
+ messages: 0,
210
+ recent: 0,
211
+ unseen: 0,
212
+ uidNext: 1,
213
+ uidValidity: 1,
214
+ highestModseq: "0",
215
+ deletedCount: 0,
216
+ }),
217
+ }) as unknown as IImapConnection;
218
+
219
+ const buildServices = (
220
+ existing: Array<{
221
+ mailboxId: string;
222
+ fullPath: string;
223
+ syncStatus?: string;
224
+ }>,
225
+ ) => {
226
+ const deleted: string[] = [];
227
+ const mailboxService = {
228
+ listByAccount: async () => ({
229
+ items: existing.map((m) => ({
230
+ mailboxId: m.mailboxId,
231
+ fullPath: m.fullPath,
232
+ uidNext: 1,
233
+ uidValidity: 1,
234
+ messageCount: 0,
235
+ unseenCount: 0,
236
+ deletedCount: 0,
237
+ highestModseq: "0",
238
+ specialUse: undefined,
239
+ syncStatus: m.syncStatus,
240
+ })),
241
+ continuationToken: undefined,
242
+ }),
243
+ update: async () => ({}),
244
+ delete: async (_accountId: string, mailboxId: string) => {
245
+ deleted.push(mailboxId);
246
+ },
247
+ create: async () => ({}),
248
+ } as unknown as IMailboxRepository;
249
+
250
+ const specialUseService = {
251
+ listByMailboxId: async () => [],
252
+ deleteByMailboxId: async () => undefined,
253
+ createMany: async () => undefined,
254
+ } as unknown as IMailboxSpecialUseRepository;
255
+
256
+ return { mailboxService, specialUseService, deleted };
257
+ };
258
+
259
+ it("keeps a pending folder the server has not listed yet", async () => {
260
+ // The folder was just created locally; MAILBOX_CREATE has not reached the
261
+ // server, so the LIST omits it. Deleting the row here races the create and
262
+ // wedges the account's mailbox-sync FIFO group for a full visibility window.
263
+ const { mailboxService, specialUseService, deleted } = buildServices([
264
+ {
265
+ mailboxId: "inbox",
266
+ fullPath: "INBOX",
267
+ syncStatus: MailboxSyncStatus.synced,
268
+ },
269
+ {
270
+ mailboxId: "pending-folder",
271
+ fullPath: "New Folder",
272
+ syncStatus: MailboxSyncStatus.pending,
273
+ },
274
+ ]);
275
+ const service = new MailboxSyncService(mailboxService, specialUseService);
276
+
277
+ const result = await service.syncMailboxes(
278
+ { accountId: "acc-1" },
279
+ serverConnection(),
280
+ );
281
+
282
+ assert.deepEqual(deleted, []);
283
+ assert.equal(result.deleted, 0);
284
+ });
285
+
286
+ it("still deletes a synced folder that has left the server", async () => {
287
+ // A folder that was confirmed on the server and is now gone is a genuine
288
+ // server-side deletion; the reconcile must still reap it.
289
+ const { mailboxService, specialUseService, deleted } = buildServices([
290
+ {
291
+ mailboxId: "inbox",
292
+ fullPath: "INBOX",
293
+ syncStatus: MailboxSyncStatus.synced,
294
+ },
295
+ {
296
+ mailboxId: "synced-gone",
297
+ fullPath: "Old Folder",
298
+ syncStatus: MailboxSyncStatus.synced,
299
+ },
300
+ ]);
301
+ const service = new MailboxSyncService(mailboxService, specialUseService);
302
+
303
+ const result = await service.syncMailboxes(
304
+ { accountId: "acc-1" },
305
+ serverConnection(),
306
+ );
307
+
308
+ assert.deepEqual(deleted, ["synced-gone"]);
309
+ assert.equal(result.deleted, 1);
310
+ });
311
+ });
@@ -13,6 +13,7 @@ import type {
13
13
  import {
14
14
  MailboxCursorState,
15
15
  MailboxSpecialUse,
16
+ MailboxSyncStatus,
16
17
  NamespaceType,
17
18
  } from "@remit/domain-enums";
18
19
  import pMap from "p-map";
@@ -198,13 +199,21 @@ export class MailboxSyncService {
198
199
 
199
200
  // Handle deleted mailboxes (exist in DB but not on server)
200
201
  for (const existing of existingMailboxes) {
201
- if (!seenPaths.has(existing.fullPath)) {
202
- await this.mailboxService.delete(account.accountId, existing.mailboxId);
203
- console.info(
204
- `Deleted mailbox: ${existing.mailboxId} (${existing.fullPath})`,
205
- );
206
- result.deleted++;
207
- }
202
+ if (seenPaths.has(existing.fullPath)) continue;
203
+ // A `pending` row is a folder the user just created (or renamed) whose
204
+ // MAILBOX_CREATE/RENAME has not yet reached the server, so its absence
205
+ // from the LIST is expected, not a server-side deletion. Deleting it
206
+ // races the create: the row vanishes, then MAILBOX_CREATE fails with
207
+ // NotFoundError trying to mark it synced, and — sharing this account's
208
+ // mailboxes FIFO group — that un-acked failure stalls every later
209
+ // mailbox sync for the queue's whole visibility window (#290). Leave
210
+ // pending rows to the create/rename flow that owns them.
211
+ if (existing.syncStatus === MailboxSyncStatus.pending) continue;
212
+ await this.mailboxService.delete(account.accountId, existing.mailboxId);
213
+ console.info(
214
+ `Deleted mailbox: ${existing.mailboxId} (${existing.fullPath})`,
215
+ );
216
+ result.deleted++;
208
217
  }
209
218
 
210
219
  return result;