@remit/mailbox-service 0.0.101 → 0.0.103

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.
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  IAccountConfigRepository,
3
+ IAccountRepository,
3
4
  IAddressRepository,
4
5
  IMessageRepository,
5
6
  IThreadMessageRepository,
@@ -11,6 +12,11 @@ import { parseMessageBody } from "./body-parse.js";
11
12
  import { extractSnippet } from "./body-sync.js";
12
13
  import { classifyParsedMessage } from "./classify-message.js";
13
14
  import { extractListId } from "./filters/list-id.js";
15
+ import {
16
+ type FolderPlacementLookup,
17
+ resolveAccountFolderPlacements,
18
+ } from "./folder-placement.js";
19
+ import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
14
20
  import {
15
21
  denormalizeMessageCategory,
16
22
  hasDecidedCategory,
@@ -44,6 +50,8 @@ export interface ClassificationBackfillCheckpointStore {
44
50
 
45
51
  export interface ClassificationBackfillDeps {
46
52
  accountConfigService: Pick<IAccountConfigRepository, "listAll">;
53
+ accountService: Pick<IAccountRepository, "listAllByAccountConfig">;
54
+ mailboxSpecialUseService: FolderPlacementLookup;
47
55
  /** Reads the sender's `Address.flags.category` override (issue #299). */
48
56
  addressService: Pick<IAddressRepository, "getAddress">;
49
57
  threadMessageService: Pick<
@@ -116,6 +124,7 @@ const classifyAndApply = async (
116
124
  accountConfigId: string,
117
125
  row: ThreadMessageItem,
118
126
  bodyStorageKey: string,
127
+ placement: FolderPlacement,
119
128
  ): Promise<void> => {
120
129
  const body = await deps.storageService.retrieve(bodyStorageKey);
121
130
  const parsed = await parseMessageBody(body);
@@ -123,6 +132,7 @@ const classifyAndApply = async (
123
132
  deps.addressService,
124
133
  accountConfigId,
125
134
  parsed,
135
+ placement,
126
136
  );
127
137
 
128
138
  // The same denormalized fields the body-store path writes (see
@@ -223,6 +233,13 @@ export const backfillClassifications = async (
223
233
  const account = accounts[accountIndex];
224
234
  let continuationToken: string | undefined =
225
235
  accountIndex === startIndex ? startContinuationToken : undefined;
236
+ const mailAccounts = await deps.accountService.listAllByAccountConfig(
237
+ account.accountConfigId,
238
+ );
239
+ const placements = await resolveAccountFolderPlacements(
240
+ deps.mailboxSpecialUseService,
241
+ mailAccounts.map((mailAccount) => mailAccount.accountId),
242
+ );
226
243
 
227
244
  do {
228
245
  const page = await deps.threadMessageService.listByAccount(
@@ -297,6 +314,7 @@ export const backfillClassifications = async (
297
314
  account.accountConfigId,
298
315
  row,
299
316
  message.bodyStorageKey,
317
+ placements.get(row.mailboxId) ?? "other",
300
318
  ).then(
301
319
  () => null,
302
320
  (error: unknown) => error,
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
13
13
  import { describe, it } from "node:test";
14
14
  import type { AddressItem, IAddressRepository } from "@remit/data-ports";
15
15
  import { NotFoundError } from "@remit/data-ports/errors";
16
+ import { DisplayNameCorrespondence } from "@remit/domain-enums";
16
17
  import { parseMessageBody } from "./body-parse.js";
17
18
  import {
18
19
  classifyParsedMessage,
@@ -43,6 +44,18 @@ const LIST_EML = Buffer.from(
43
44
  ].join("\r\n"),
44
45
  );
45
46
 
47
+ const IMPERSONATION_EML = Buffer.from(
48
+ [
49
+ "From: InfoMedics <billing@serviceupdatebank.atlassian.net>",
50
+ "To: me@example.com",
51
+ "Subject: Openstaande factuur",
52
+ "DKIM-Signature: v=1; a=rsa-sha256; d=atlassian.net; s=sel; b=xxx",
53
+ "Content-Type: text/html",
54
+ "",
55
+ '<a href="https://betaal-vordering.example.org/pay">Betaal uw factuur</a>',
56
+ ].join("\r\n"),
57
+ );
58
+
46
59
  const addressService = (
47
60
  behavior: (accountConfigId: string, addressId: string) => unknown,
48
61
  ): Pick<IAddressRepository, "getAddress"> => ({
@@ -68,6 +81,7 @@ describe("classifyParsedMessage", () => {
68
81
  }),
69
82
  "acc-1",
70
83
  parsed,
84
+ "inbox",
71
85
  );
72
86
 
73
87
  assert.equal(classification.category, "newsletter");
@@ -81,6 +95,7 @@ describe("classifyParsedMessage", () => {
81
95
  addressService(withFlags({ category: { value: "marketing", setAt: 1 } })),
82
96
  "acc-1",
83
97
  parsed,
98
+ "inbox",
84
99
  );
85
100
 
86
101
  // The header table alone would answer `personal`; the override wins
@@ -98,6 +113,7 @@ describe("classifyParsedMessage", () => {
98
113
  }),
99
114
  "acc-1",
100
115
  parsed,
116
+ "inbox",
101
117
  ),
102
118
  /throttled/,
103
119
  );
@@ -124,6 +140,7 @@ describe("classifyParsedMessage", () => {
124
140
  }),
125
141
  "acc-1",
126
142
  parsed,
143
+ "inbox",
127
144
  );
128
145
 
129
146
  assert.equal(reads, 0);
@@ -131,6 +148,48 @@ describe("classifyParsedMessage", () => {
131
148
  });
132
149
  });
133
150
 
151
+ describe("classifyParsedMessage sender mismatch", () => {
152
+ const noAddress = addressService(async () => {
153
+ throw new NotFoundError("no Address row");
154
+ });
155
+
156
+ it("compares the sender of mail in Junk the provider rated clean", async () => {
157
+ const parsed = await parseMessageBody(IMPERSONATION_EML);
158
+
159
+ const classification = await classifyParsedMessage(
160
+ noAddress,
161
+ "acc-1",
162
+ parsed,
163
+ "junk",
164
+ );
165
+
166
+ assert.equal(
167
+ classification.authenticity?.displayNameCorrespondence,
168
+ DisplayNameCorrespondence.Unrelated,
169
+ );
170
+ assert.deepEqual(classification.authenticity?.offDomainLinkDomains, [
171
+ "example.org",
172
+ ]);
173
+ });
174
+
175
+ it("compares nothing for the same mail outside Junk without a provider verdict", async () => {
176
+ const parsed = await parseMessageBody(IMPERSONATION_EML);
177
+
178
+ const classification = await classifyParsedMessage(
179
+ noAddress,
180
+ "acc-1",
181
+ parsed,
182
+ "inbox",
183
+ );
184
+
185
+ assert.equal(
186
+ classification.authenticity?.displayNameCorrespondence,
187
+ undefined,
188
+ );
189
+ assert.equal(classification.authenticity?.offDomainLinkDomains, undefined);
190
+ });
191
+ });
192
+
134
193
  describe("extractPrimaryFromEmail", () => {
135
194
  it("reads the first From address", async () => {
136
195
  const parsed = await parseMessageBody(PLAIN_EML);
@@ -13,6 +13,7 @@ import {
13
13
  extractHasListUnsubscribe,
14
14
  extractProviderSpam,
15
15
  } from "./heuristics/classifyByHeaders.js";
16
+ import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
16
17
  import { extractSenderMismatch } from "./heuristics/senderMismatch.js";
17
18
 
18
19
  type ThreadMessageCategory = ThreadMessageItem["category"];
@@ -75,6 +76,7 @@ export const classifyParsedMessage = async (
75
76
  addressService: Pick<IAddressRepository, "getAddress">,
76
77
  accountConfigId: string,
77
78
  parsed: ParsedMail,
79
+ placement: FolderPlacement,
78
80
  ): Promise<UpdateMessageInput & { category: ThreadMessageCategory }> => {
79
81
  const headerCategory = classifyByHeaders(parsed);
80
82
  const authenticity = extractAuthenticity(parsed);
@@ -86,13 +88,14 @@ export const classifyParsedMessage = async (
86
88
  // identity the message claims — on a shared-tenant host the verified
87
89
  // subdomain belongs to whoever signed up. These two comparisons say
88
90
  // whether the claim holds, and run only over mail the provider already
89
- // called spam.
91
+ // called spam or that sits in the Junk folder.
90
92
  const senderMismatch =
91
93
  authenticity === null
92
94
  ? {}
93
95
  : extractSenderMismatch(parsed, {
94
96
  fromDomain: authenticity.fromDomain,
95
- spamClassified: providerSpam?.classified === true,
97
+ providerSpamClassified: providerSpam?.classified === true,
98
+ placement,
96
99
  bulkSender: hasListUnsubscribe,
97
100
  });
98
101
 
@@ -0,0 +1,56 @@
1
+ import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
2
+ import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
3
+
4
+ export type FolderPlacementLookup = Pick<
5
+ IMailboxSpecialUseRepository,
6
+ "findJunkMailbox" | "findInboxMailbox"
7
+ >;
8
+
9
+ type RoleMailbox = Awaited<
10
+ ReturnType<IMailboxSpecialUseRepository["findJunkMailbox"]>
11
+ >;
12
+
13
+ export interface FolderRoleMailboxes {
14
+ junkMailbox: RoleMailbox;
15
+ inboxMailbox: RoleMailbox;
16
+ }
17
+
18
+ export const resolveFolderRoleMailboxes = async (
19
+ lookup: FolderPlacementLookup,
20
+ accountId: string,
21
+ ): Promise<FolderRoleMailboxes> => ({
22
+ junkMailbox: await lookup.findJunkMailbox(accountId),
23
+ inboxMailbox: await lookup.findInboxMailbox(accountId),
24
+ });
25
+
26
+ export const placementOf = (
27
+ mailboxId: string,
28
+ { junkMailbox, inboxMailbox }: FolderRoleMailboxes,
29
+ ): FolderPlacement => {
30
+ if (junkMailbox?.mailboxId === mailboxId) return "junk";
31
+ if (inboxMailbox?.mailboxId === mailboxId) return "inbox";
32
+ return "other";
33
+ };
34
+
35
+ export const resolveFolderPlacement = async (
36
+ lookup: FolderPlacementLookup,
37
+ accountId: string,
38
+ mailboxId: string,
39
+ ): Promise<FolderPlacement> =>
40
+ placementOf(mailboxId, await resolveFolderRoleMailboxes(lookup, accountId));
41
+
42
+ export const resolveAccountFolderPlacements = async (
43
+ lookup: FolderPlacementLookup,
44
+ accountIds: readonly string[],
45
+ ): Promise<ReadonlyMap<string, FolderPlacement>> => {
46
+ const placements = new Map<string, FolderPlacement>();
47
+ for (const accountId of accountIds) {
48
+ const { junkMailbox, inboxMailbox } = await resolveFolderRoleMailboxes(
49
+ lookup,
50
+ accountId,
51
+ );
52
+ if (inboxMailbox) placements.set(inboxMailbox.mailboxId, "inbox");
53
+ if (junkMailbox) placements.set(junkMailbox.mailboxId, "junk");
54
+ }
55
+ return placements;
56
+ };
@@ -245,7 +245,8 @@ describe("extractSenderMismatch", () => {
245
245
  assert.deepEqual(
246
246
  extractSenderMismatch(parsed, {
247
247
  fromDomain: "serviceupdatebank.atlassian.net",
248
- spamClassified: true,
248
+ providerSpamClassified: true,
249
+ placement: "other",
249
250
  bulkSender: false,
250
251
  }),
251
252
  {
@@ -255,18 +256,53 @@ describe("extractSenderMismatch", () => {
255
256
  );
256
257
  });
257
258
 
258
- it("compares nothing when the provider did not call the message spam", async () => {
259
+ it("flags a message sitting in Junk that the provider rated clean", async () => {
259
260
  const parsed = await parse(infoMedicsPhish);
260
261
  assert.deepEqual(
261
262
  extractSenderMismatch(parsed, {
262
263
  fromDomain: "serviceupdatebank.atlassian.net",
263
- spamClassified: false,
264
+ providerSpamClassified: false,
265
+ placement: "junk",
264
266
  bulkSender: false,
265
267
  }),
266
- {},
268
+ {
269
+ displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
270
+ offDomainLinkDomains: ["example.org"],
271
+ },
267
272
  );
268
273
  });
269
274
 
275
+ it("flags a provider-classified message outside Junk", async () => {
276
+ const parsed = await parse(infoMedicsPhish);
277
+ assert.deepEqual(
278
+ extractSenderMismatch(parsed, {
279
+ fromDomain: "serviceupdatebank.atlassian.net",
280
+ providerSpamClassified: true,
281
+ placement: "inbox",
282
+ bulkSender: false,
283
+ }),
284
+ {
285
+ displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
286
+ offDomainLinkDomains: ["example.org"],
287
+ },
288
+ );
289
+ });
290
+
291
+ for (const placement of ["inbox", "other"] as const) {
292
+ it(`compares nothing in ${placement} when the provider did not call the message spam`, async () => {
293
+ const parsed = await parse(infoMedicsPhish);
294
+ assert.deepEqual(
295
+ extractSenderMismatch(parsed, {
296
+ fromDomain: "serviceupdatebank.atlassian.net",
297
+ providerSpamClassified: false,
298
+ placement,
299
+ bulkSender: false,
300
+ }),
301
+ {},
302
+ );
303
+ });
304
+ }
305
+
270
306
  it("flags a display name spelling the recipient's own address over a foreign domain", async () => {
271
307
  const parsed = await parse([
272
308
  "From: matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>",
@@ -279,7 +315,8 @@ describe("extractSenderMismatch", () => {
279
315
  assert.equal(
280
316
  extractSenderMismatch(parsed, {
281
317
  fromDomain: "secresaludguaviare.gov.co",
282
- spamClassified: true,
318
+ providerSpamClassified: true,
319
+ placement: "other",
283
320
  bulkSender: false,
284
321
  }).displayNameCorrespondence,
285
322
  DisplayNameCorrespondence.ForeignAddress,
@@ -299,7 +336,8 @@ describe("extractSenderMismatch", () => {
299
336
  ]);
300
337
  const signals = extractSenderMismatch(parsed, {
301
338
  fromDomain: "mailer.esp.example",
302
- spamClassified: true,
339
+ providerSpamClassified: true,
340
+ placement: "other",
303
341
  bulkSender: true,
304
342
  });
305
343
  assert.equal(signals.displayNameCorrespondence, undefined);
@@ -1,6 +1,7 @@
1
1
  import { DisplayNameCorrespondence } from "@remit/domain-enums";
2
2
  import type { ParsedMail } from "mailparser";
3
3
  import { getDomain, parse as parseHost } from "tldts";
4
+ import type { FolderPlacement } from "./classifyPlacement.js";
4
5
 
5
6
  type CorrespondenceValue =
6
7
  (typeof DisplayNameCorrespondence)[keyof typeof DisplayNameCorrespondence];
@@ -10,7 +11,8 @@ type CorrespondenceValue =
10
11
  * that belongs to nobody at the sending domain, and body links that leave it.
11
12
  *
12
13
  * Both are deliberately aggressive, and both are computed only for mail the
13
- * provider's own filter already called spam (see {@link extractSenderMismatch}).
14
+ * provider's own filter already called spam or that sits in the Junk folder
15
+ * (see {@link extractSenderMismatch}).
14
16
  * On a shared-tenant host — a free Atlassian, Salesforce or Zendesk instance —
15
17
  * the verified subdomain is chosen by whoever signed up, so a passing signature
16
18
  * proves the domain and nothing about the identity the message claims.
@@ -22,7 +24,8 @@ export interface SenderMismatchSignals {
22
24
 
23
25
  export interface SenderMismatchContext {
24
26
  fromDomain: string;
25
- spamClassified: boolean;
27
+ providerSpamClassified: boolean;
28
+ placement: FolderPlacement;
26
29
  /**
27
30
  * List-Unsubscribe present. Bulk mail routinely shows a brand name over an
28
31
  * ESP's sending domain, so the display-name comparison says nothing there and
@@ -223,7 +226,8 @@ export const extractOffDomainLinkDomains = (
223
226
  };
224
227
 
225
228
  /**
226
- * Both signals, gated on the provider's own spam verdict.
229
+ * Both signals, gated on a spam verdict: the provider's own, or the message
230
+ * sitting in the Junk folder.
227
231
  *
228
232
  * The gate is what lets the checks be this aggressive: ordinary mail never
229
233
  * reaches them, so a brand name over an ESP domain or a newsletter full of
@@ -234,7 +238,9 @@ export const extractSenderMismatch = (
234
238
  parsed: ParsedMail,
235
239
  context: SenderMismatchContext,
236
240
  ): SenderMismatchSignals => {
237
- if (!context.spamClassified) return {};
241
+ if (!context.providerSpamClassified && context.placement !== "junk") {
242
+ return {};
243
+ }
238
244
 
239
245
  const offDomainLinkDomains = extractOffDomainLinkDomains(
240
246
  parsed,
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  parseMessageBody,
26
26
  } from "./body-parse.js";
27
27
  export {
28
+ type BodySyncFolder,
28
29
  type BodySyncLogger,
29
30
  BodySyncService,
30
31
  type ConnectionGetter,
@@ -125,6 +126,11 @@ export {
125
126
  type UpdateFlagsInput,
126
127
  type UpdateFlagsResult,
127
128
  } from "./flag-queue.js";
129
+ export {
130
+ type FolderPlacementLookup,
131
+ resolveAccountFolderPlacements,
132
+ resolveFolderPlacement,
133
+ } from "./folder-placement.js";
128
134
  export {
129
135
  classifyByHeaders,
130
136
  extractAuthenticity,
@@ -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 = {