@remit/mailbox-service 0.0.101 → 0.0.102

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.101",
3
+ "version": "0.0.102",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -2,6 +2,7 @@ import assert from "node:assert";
2
2
  import { describe, it } from "node:test";
3
3
  import type {
4
4
  IMailboxRepository,
5
+ MailboxItem,
5
6
  MailboxTransitionIntent,
6
7
  } from "@remit/data-ports";
7
8
  import { MailboxSyncStatus } from "@remit/domain-enums";
@@ -99,7 +100,10 @@ const boxStatus = (path: string): ImapBoxStatus => ({
99
100
  newKeywords: false,
100
101
  });
101
102
 
102
- const recordingRepo = () => {
103
+ const recordingRepo = (
104
+ held: Pick<MailboxItem, "mailboxId" | "fullPath">[] = [],
105
+ ) => {
106
+ const removed: string[] = [];
103
107
  const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
104
108
  [];
105
109
  const settles: Array<{
@@ -108,7 +112,11 @@ const recordingRepo = () => {
108
112
  }> = [];
109
113
  const repo: Pick<
110
114
  IMailboxRepository,
111
- "update" | "transition" | "findByPathPrefix"
115
+ | "update"
116
+ | "transition"
117
+ | "findByPathPrefix"
118
+ | "findByPath"
119
+ | "deleteMailboxWithMail"
112
120
  > = {
113
121
  update: async (
114
122
  _accountId: string,
@@ -127,8 +135,13 @@ const recordingRepo = () => {
127
135
  return {} as never;
128
136
  },
129
137
  findByPathPrefix: async () => [],
138
+ findByPath: async (_accountId: string, fullPath: string) =>
139
+ (held.find((row) => row.fullPath === fullPath) as MailboxItem) ?? null,
140
+ deleteMailboxWithMail: async (_accountId: string, mailboxId: string) => {
141
+ removed.push(mailboxId);
142
+ },
130
143
  };
131
- return { repo: repo as IMailboxRepository, updates, settles };
144
+ return { repo: repo as IMailboxRepository, updates, settles, removed };
132
145
  };
133
146
 
134
147
  const stubConnection = (
@@ -185,6 +198,38 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
185
198
  assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
186
199
  });
187
200
 
201
+ it("drops the pending row when the prefixed path already has a folder (#318)", async () => {
202
+ const { repo, settles, updates, removed } = recordingRepo([
203
+ { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
204
+ ]);
205
+ const connection = {
206
+ createMailbox: async (_path: string) => ({
207
+ path: "INBOX/Notifications",
208
+ created: false,
209
+ }),
210
+ subscribeMailbox: async () => undefined,
211
+ listMailboxes: async (): Promise<FlatMailboxInfo[]> => [],
212
+ openBox: async (path: string) => boxStatus(path),
213
+ closeBox: async () => undefined,
214
+ } as unknown as IImapConnection;
215
+ const service = new MailboxManagementService(repo);
216
+
217
+ const result = await service.syncCreate(
218
+ "acc-1",
219
+ "mbx-recreated",
220
+ "Notifications",
221
+ async () => connection,
222
+ );
223
+
224
+ assert.deepStrictEqual(result, {
225
+ success: true,
226
+ existing: { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
227
+ });
228
+ assert.deepStrictEqual(removed, ["mbx-recreated"]);
229
+ assert.deepStrictEqual(settles, []);
230
+ assert.deepStrictEqual(updates, []);
231
+ });
232
+
188
233
  it("leaves the path untouched when the server keeps it as requested", async () => {
189
234
  const { repo, settles } = recordingRepo();
190
235
  const { connection } = stubConnection("Work", ["Work"]);
@@ -48,6 +48,7 @@ export interface MailboxManagementSyncResult {
48
48
  * which is how a caller tells the two apart without a second read.
49
49
  */
50
50
  renamed?: { oldPath: string; newPath: string; delimiter: string };
51
+ existing?: { mailboxId: string; fullPath: string };
51
52
  }
52
53
 
53
54
  /**
@@ -304,6 +305,30 @@ export class MailboxManagementService {
304
305
  this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
305
306
  }
306
307
 
308
+ const holder =
309
+ confirmedPath === undefined
310
+ ? null
311
+ : await this.mailboxService.findByPath(accountId, confirmedPath);
312
+ if (holder && holder.mailboxId !== mailboxId) {
313
+ await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
314
+ this.log.info(
315
+ {
316
+ accountId,
317
+ mailboxId,
318
+ intent: "create",
319
+ path,
320
+ serverPath,
321
+ existingMailboxId: holder.mailboxId,
322
+ outcome: "already-exists",
323
+ },
324
+ "Folder already exists at the path the server resolved",
325
+ );
326
+ return {
327
+ success: true,
328
+ existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
329
+ };
330
+ }
331
+
307
332
  // Refresh mailbox list to get UIDVALIDITY and other attributes
308
333
  const mailboxes = await connection.listMailboxes();
309
334
  const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
3
  import type {
4
4
  CreateMailboxInput,
5
+ CreateMailboxResult,
5
6
  IMailboxRepository,
6
7
  MailboxItem,
7
8
  } from "@remit/data-ports";
@@ -120,18 +121,19 @@ export class MailboxQueueService {
120
121
  * @param input - The mailbox creation input (without syncStatus)
121
122
  * @param accountId - The account ID for the IMAP sync event
122
123
  * @param subscribe - Whether to subscribe to the mailbox after creation
123
- * @returns The created mailbox
124
+ * @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
124
125
  */
125
126
  createMailbox = async (
126
127
  input: CreateMailboxQueueInput,
127
128
  accountId: string,
128
129
  subscribe?: boolean,
129
- ): Promise<MailboxItem> => {
130
- // Create local mailbox with pending status
131
- const mailbox = await this.mailboxService.create({
130
+ ): Promise<CreateMailboxResult> => {
131
+ const created = await this.mailboxService.create({
132
132
  ...input,
133
133
  syncStatus: MailboxSyncStatus.pending,
134
134
  });
135
+ if (created.outcome === "PathTaken") return created;
136
+ const { mailbox } = created;
135
137
 
136
138
  this.log.info(
137
139
  { mailboxId: mailbox.mailboxId, path: mailbox.fullPath },
@@ -149,7 +151,7 @@ export class MailboxQueueService {
149
151
  subscribe,
150
152
  });
151
153
 
152
- return mailbox;
154
+ return created;
153
155
  };
154
156
 
155
157
  /**
@@ -13,7 +13,11 @@ import {
13
13
  import { parseImapAttributes } from "./attribute-mapper.js";
14
14
  import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
15
15
 
16
- const silentLogger: MailboxSyncLogger = { info: () => {}, debug: () => {} };
16
+ const silentLogger: MailboxSyncLogger = {
17
+ info: () => {},
18
+ debug: () => {},
19
+ error: () => {},
20
+ };
17
21
 
18
22
  import type { IImapConnection, ImapNamespaces } from "./types.js";
19
23
 
@@ -110,7 +114,10 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
110
114
  return {};
111
115
  },
112
116
  delete: async () => undefined,
113
- create: async () => ({}),
117
+ create: async () => ({
118
+ outcome: "Created",
119
+ mailbox: { mailboxId: "new" },
120
+ }),
114
121
  } as unknown as IMailboxRepository;
115
122
 
116
123
  const specialUseService = {
@@ -257,6 +264,7 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
257
264
  syncStatus?: string;
258
265
  pendingPath?: string;
259
266
  }>,
267
+ createOutcome: "Created" | "PathTaken" = "Created",
260
268
  ) => {
261
269
  const deleted: string[] = [];
262
270
  const created: Array<Record<string, unknown>> = [];
@@ -283,7 +291,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
283
291
  },
284
292
  create: async (input: Record<string, unknown>) => {
285
293
  created.push(input);
286
- return { ...input, mailboxId: `new-${created.length}` };
294
+ if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
295
+ return {
296
+ outcome: "Created",
297
+ mailbox: { ...input, mailboxId: `new-${created.length}` },
298
+ };
287
299
  },
288
300
  } as unknown as IMailboxRepository;
289
301
 
@@ -401,6 +413,35 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
401
413
  assert.deepEqual(deleted, []);
402
414
  });
403
415
 
416
+ it("counts no insert and keeps the account sweep going when the path is already taken", async () => {
417
+ const { mailboxService, specialUseService, created } = buildServices(
418
+ [
419
+ {
420
+ mailboxId: "inbox",
421
+ fullPath: "INBOX",
422
+ syncStatus: MailboxSyncStatus.synced,
423
+ },
424
+ ],
425
+ "PathTaken",
426
+ );
427
+ const errors: Array<Record<string, unknown>> = [];
428
+ const service = new MailboxSyncService(mailboxService, specialUseService, {
429
+ ...silentLogger,
430
+ error: (obj) => {
431
+ errors.push(obj);
432
+ },
433
+ });
434
+
435
+ const result = await service.syncMailboxes(
436
+ { accountId: "acc-1" },
437
+ serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
438
+ );
439
+
440
+ assert.equal(created.length, 1);
441
+ assert.equal(result.created, 0);
442
+ assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
443
+ });
444
+
404
445
  it("lets a failed rename's target be discovered as a folder of its own", async () => {
405
446
  // A failed rename keeps its target so the UI can name what it was aiming
406
447
  // at (T6), and nothing clears it until the user retries or dismisses. If
@@ -539,7 +580,10 @@ describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the u
539
580
  delete: async (_accountId: string, mailboxId: string) => {
540
581
  deleted.push(mailboxId);
541
582
  },
542
- create: async () => ({}),
583
+ create: async () => ({
584
+ outcome: "Created",
585
+ mailbox: { mailboxId: "new" },
586
+ }),
543
587
  } as unknown as IMailboxRepository;
544
588
 
545
589
  const specialUseService = {
@@ -717,7 +761,10 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
717
761
  return {};
718
762
  },
719
763
  delete: async () => undefined,
720
- create: async () => ({}),
764
+ create: async () => ({
765
+ outcome: "Created",
766
+ mailbox: { mailboxId: "new" },
767
+ }),
721
768
  } as unknown as IMailboxRepository;
722
769
 
723
770
  const specialUseService = {
@@ -82,6 +82,7 @@ export interface SyncAccountInfo {
82
82
  export interface MailboxSyncLogger {
83
83
  info(obj: Record<string, unknown>, msg: string): void;
84
84
  debug(obj: Record<string, unknown>, msg: string): void;
85
+ error(obj: Record<string, unknown>, msg: string): void;
85
86
  }
86
87
 
87
88
  /**
@@ -263,13 +264,13 @@ export class MailboxSyncService {
263
264
  result.updated++;
264
265
  }
265
266
  } else if (!claimedByPendingRename.has(mailboxInfo.fullPath)) {
266
- await this.createMailbox(
267
+ const created = await this.createMailbox(
267
268
  account.accountId,
268
269
  mailboxInfo,
269
270
  namespaces,
270
271
  connection,
271
272
  );
272
- result.created++;
273
+ if (created) result.created++;
273
274
  }
274
275
  },
275
276
  { concurrency: 3 },
@@ -432,7 +433,7 @@ export class MailboxSyncService {
432
433
  },
433
434
  _namespaces: ImapNamespaces,
434
435
  connection: IImapConnection,
435
- ): Promise<MailboxItem> => {
436
+ ): Promise<MailboxItem | null> => {
436
437
  // Fetch mailbox status using STATUS command (doesn't require SELECT/EXAMINE)
437
438
  // This gets us message counts including unseen without opening the mailbox
438
439
  const status = await connection.getMailboxStatus(mailboxInfo.fullPath);
@@ -472,7 +473,15 @@ export class MailboxSyncService {
472
473
  // parentMailboxId would need to be resolved from parentPath
473
474
  };
474
475
 
475
- const mailbox = await this.mailboxService.create(input);
476
+ const created = await this.mailboxService.create(input);
477
+ if (created.outcome === "PathTaken") {
478
+ this.log.error(
479
+ { accountId, fullPath: mailboxInfo.fullPath },
480
+ "Sweep found no row for a folder path the account already holds",
481
+ );
482
+ return null;
483
+ }
484
+ const { mailbox } = created;
476
485
 
477
486
  // Keep the MailboxSpecialUseEntry table in sync — every backend and worker
478
487
  // special-folder lookup reads it through MailboxSpecialUseRepo. Denormalized