@remit/mailbox-service 0.0.62 → 0.0.64

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.62",
3
+ "version": "0.0.64",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -71,7 +71,7 @@ describe("classifyDisplayNameCorrespondence", () => {
71
71
  );
72
72
  });
73
73
 
74
- it("claims nothing when the display name is the address itself", () => {
74
+ it("claims nothing when the display name is an address at the sending domain", () => {
75
75
  assert.equal(
76
76
  classifyDisplayNameCorrespondence(
77
77
  "billing@serviceupdatebank.atlassian.net",
@@ -81,6 +81,60 @@ describe("classifyDisplayNameCorrespondence", () => {
81
81
  );
82
82
  });
83
83
 
84
+ it("claims nothing when the display name spells the envelope address itself", () => {
85
+ assert.equal(
86
+ classifyDisplayNameCorrespondence("matthijs@ischen.nl", "ischen.nl"),
87
+ DisplayNameCorrespondence.NoClaim,
88
+ );
89
+ });
90
+
91
+ it("claims nothing when the display name is an address on a sibling subdomain", () => {
92
+ assert.equal(
93
+ classifyDisplayNameCorrespondence(
94
+ "noreply@example.co.uk",
95
+ "mail.example.co.uk",
96
+ ),
97
+ DisplayNameCorrespondence.NoClaim,
98
+ );
99
+ });
100
+
101
+ it("is a foreign address when the display name spells an address over another domain", () => {
102
+ assert.equal(
103
+ classifyDisplayNameCorrespondence(
104
+ "matthijs@ischen.nl",
105
+ "secresaludguaviare.gov.co",
106
+ ),
107
+ DisplayNameCorrespondence.ForeignAddress,
108
+ );
109
+ });
110
+
111
+ it("is a foreign address when the spelled-out address is decorated with a name", () => {
112
+ assert.equal(
113
+ classifyDisplayNameCorrespondence(
114
+ "Matthijs (matthijs@ischen.nl)",
115
+ "secresaludguaviare.gov.co",
116
+ ),
117
+ DisplayNameCorrespondence.ForeignAddress,
118
+ );
119
+ });
120
+
121
+ it("compares an at sign that spells no address as an ordinary name", () => {
122
+ assert.equal(
123
+ classifyDisplayNameCorrespondence(
124
+ "Support @ InfoMedics",
125
+ "infomedics.nl",
126
+ ),
127
+ DisplayNameCorrespondence.Corresponds,
128
+ );
129
+ assert.equal(
130
+ classifyDisplayNameCorrespondence(
131
+ "Support @ InfoMedics",
132
+ "serviceupdatebank.atlassian.net",
133
+ ),
134
+ DisplayNameCorrespondence.Unrelated,
135
+ );
136
+ });
137
+
84
138
  it("does not read the public suffix as a match for a brand containing it", () => {
85
139
  assert.equal(
86
140
  classifyDisplayNameCorrespondence("Netflix", "mailer.example.net"),
@@ -213,6 +267,25 @@ describe("extractSenderMismatch", () => {
213
267
  );
214
268
  });
215
269
 
270
+ it("flags a display name spelling the recipient's own address over a foreign domain", async () => {
271
+ const parsed = await parse([
272
+ "From: matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>",
273
+ "To: matthijs@ischen.nl",
274
+ "Subject: Re: factura",
275
+ "X-HalOne-Spam-Probability: 1",
276
+ "",
277
+ "hola",
278
+ ]);
279
+ assert.equal(
280
+ extractSenderMismatch(parsed, {
281
+ fromDomain: "secresaludguaviare.gov.co",
282
+ spamClassified: true,
283
+ bulkSender: false,
284
+ }).displayNameCorrespondence,
285
+ DisplayNameCorrespondence.ForeignAddress,
286
+ );
287
+ });
288
+
216
289
  it("leaves the display name uncompared for a bulk sender", async () => {
217
290
  const parsed = await parse([
218
291
  "From: Dutch Cycling Weekly <bounce-9f2@mailer.esp.example>",
@@ -76,6 +76,22 @@ const editDistance = (a: string, b: string): number => {
76
76
  return previous[b.length];
77
77
  };
78
78
 
79
+ const ADDRESS_SHAPE = /[^\s<>@,;:"]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/i;
80
+
81
+ const registrableDomain = (host: string): string =>
82
+ getDomain(host) ?? host.toLowerCase();
83
+
84
+ /**
85
+ * The registrable domain of the address a display name spells out, or
86
+ * `undefined` when it holds an `@` without an address behind it — `Bob @ Acme`
87
+ * asserts no address and is left to the ordinary comparison.
88
+ */
89
+ const claimedAddressDomain = (displayName: string): string | undefined => {
90
+ const host = ADDRESS_SHAPE.exec(displayName)?.[1];
91
+ if (host === undefined) return undefined;
92
+ return registrableDomain(host);
93
+ };
94
+
79
95
  /**
80
96
  * The distance a name of this length may be from a domain label and still be
81
97
  * read as an imitation of it. Tight on purpose: an unrelated brand name and an
@@ -109,14 +125,27 @@ const lookalikeThreshold = (length: number): number => {
109
125
  * A bounded edit distance is the secondary test, and only reaches names that
110
126
  * nearly match a label — `InfoMedics` against `1nfomedics.nl`. It cannot promote
111
127
  * an unrelated name on its own.
128
+ *
129
+ * A name that spells out an address is decided before any of that. Over the
130
+ * sending domain it claims nothing — `billing@` shown over
131
+ * `serviceupdatebank.atlassian.net` names the same party the envelope does.
132
+ * Over any other registrable domain it is the strongest non-correspondence
133
+ * signal there is: the message asserts, in the one field the recipient reads,
134
+ * that it comes from an address that cannot have sent it.
112
135
  */
113
136
  export const classifyDisplayNameCorrespondence = (
114
137
  displayName: string | undefined,
115
138
  fromDomain: string,
116
139
  ): CorrespondenceValue => {
117
140
  const raw = (displayName ?? "").trim();
118
- if (raw === "" || raw.includes("@")) {
119
- return DisplayNameCorrespondence.NoClaim;
141
+ if (raw === "") return DisplayNameCorrespondence.NoClaim;
142
+
143
+ const claimed = claimedAddressDomain(raw);
144
+ if (claimed !== undefined) {
145
+ if (claimed === registrableDomain(fromDomain)) {
146
+ return DisplayNameCorrespondence.NoClaim;
147
+ }
148
+ return DisplayNameCorrespondence.ForeignAddress;
120
149
  }
121
150
 
122
151
  const name = normalize(raw);
@@ -342,6 +342,152 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
342
342
  });
343
343
  });
344
344
 
345
+ describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the user keeps mail in (#837)", () => {
346
+ const namespaces: ImapNamespaces = {
347
+ personal: [{ prefix: "", delimiter: "/" }],
348
+ other: [],
349
+ shared: [],
350
+ };
351
+
352
+ const serverConnection = (
353
+ folders: Array<{ fullPath: string; attributes: string[] }>,
354
+ ): IImapConnection =>
355
+ ({
356
+ getNamespaces: async () => namespaces,
357
+ listMailboxes: async () =>
358
+ folders.map((folder) => ({
359
+ fullPath: folder.fullPath,
360
+ name: folder.fullPath.split("/").pop() ?? folder.fullPath,
361
+ delimiter: "/",
362
+ attributes: folder.attributes,
363
+ parentPath: null,
364
+ })),
365
+ getMailboxStatus: async () => ({
366
+ messages: 0,
367
+ recent: 0,
368
+ unseen: 0,
369
+ uidNext: 1,
370
+ uidValidity: 1,
371
+ highestModseq: "0",
372
+ deletedCount: 0,
373
+ }),
374
+ }) as unknown as IImapConnection;
375
+
376
+ const buildServices = (
377
+ existing: Array<{ mailboxId: string; fullPath: string }>,
378
+ ) => {
379
+ const deleted: string[] = [];
380
+ const mailboxService = {
381
+ listByAccount: async () => ({
382
+ items: existing.map((m) => ({
383
+ mailboxId: m.mailboxId,
384
+ fullPath: m.fullPath,
385
+ uidNext: 1,
386
+ uidValidity: 1,
387
+ messageCount: 0,
388
+ unseenCount: 0,
389
+ deletedCount: 0,
390
+ highestModseq: "0",
391
+ specialUse: undefined,
392
+ syncStatus: MailboxSyncStatus.synced,
393
+ })),
394
+ continuationToken: undefined,
395
+ }),
396
+ update: async () => ({}),
397
+ delete: async (_accountId: string, mailboxId: string) => {
398
+ deleted.push(mailboxId);
399
+ },
400
+ create: async () => ({}),
401
+ } as unknown as IMailboxRepository;
402
+
403
+ const specialUseService = {
404
+ listByMailboxId: async () => [],
405
+ deleteByMailboxId: async () => undefined,
406
+ createMany: async () => undefined,
407
+ } as unknown as IMailboxSpecialUseRepository;
408
+
409
+ return { mailboxService, specialUseService, deleted };
410
+ };
411
+
412
+ const syncWith = async (
413
+ folders: Array<{ fullPath: string; attributes: string[] }>,
414
+ existing: Array<{ mailboxId: string; fullPath: string }>,
415
+ ): Promise<string[]> => {
416
+ const { mailboxService, specialUseService, deleted } =
417
+ buildServices(existing);
418
+ const service = new MailboxSyncService(
419
+ mailboxService,
420
+ specialUseService,
421
+ silentLogger,
422
+ );
423
+ await service.syncMailboxes(
424
+ { accountId: "acc-1" },
425
+ serverConnection(folders),
426
+ );
427
+ return deleted;
428
+ };
429
+
430
+ it("keeps a folder called Deleted beside the folder the server flagged", async () => {
431
+ // `deleted` and `bin` were Trash names in this file's own copy of the hint
432
+ // table long after #843 dropped them from the shared one. Every sync of an
433
+ // account holding a flagged Trash and a user folder called `Deleted` took
434
+ // the user's folder, and its mail, out of the client.
435
+ const deleted = await syncWith(
436
+ [
437
+ { fullPath: "INBOX", attributes: [] },
438
+ { fullPath: "Trash", attributes: ["\\Trash"] },
439
+ { fullPath: "Deleted", attributes: [] },
440
+ { fullPath: "Bin", attributes: [] },
441
+ ],
442
+ [
443
+ { mailboxId: "inbox", fullPath: "INBOX" },
444
+ { mailboxId: "trash", fullPath: "Trash" },
445
+ { mailboxId: "keepsakes", fullPath: "Deleted" },
446
+ { mailboxId: "bin", fullPath: "Bin" },
447
+ ],
448
+ );
449
+
450
+ assert.deepEqual(deleted, []);
451
+ });
452
+
453
+ it("still hides the unflagged twin of a folder the server flagged", async () => {
454
+ const deleted = await syncWith(
455
+ [
456
+ { fullPath: "INBOX", attributes: [] },
457
+ { fullPath: "[Gmail]/Trash", attributes: ["\\Trash"] },
458
+ { fullPath: "Trash", attributes: [] },
459
+ ],
460
+ [
461
+ { mailboxId: "inbox", fullPath: "INBOX" },
462
+ { mailboxId: "gmail-trash", fullPath: "[Gmail]/Trash" },
463
+ { mailboxId: "lookalike", fullPath: "Trash" },
464
+ ],
465
+ );
466
+
467
+ assert.deepEqual(deleted, ["lookalike"]);
468
+ });
469
+
470
+ it("keeps a folder that holds a role of its own", async () => {
471
+ // `all mail` is a conventional name for Archive as well as for All, and the
472
+ // account already has a flagged Archive. The folder holds the All role, so
473
+ // it is a folder in its own right rather than a lookalike of Archive.
474
+ const deleted = await syncWith(
475
+ [
476
+ { fullPath: "INBOX", attributes: [] },
477
+ { fullPath: "Archive", attributes: ["\\Archive"] },
478
+ { fullPath: "INBOX/All Mail", attributes: [] },
479
+ ],
480
+ [
481
+ { mailboxId: "inbox", fullPath: "INBOX" },
482
+ { mailboxId: "archive", fullPath: "Archive" },
483
+ { mailboxId: "all-mail", fullPath: "INBOX/All Mail" },
484
+ ],
485
+ );
486
+
487
+ assert.deepEqual(deleted, []);
488
+ });
489
+ });
490
+
345
491
  /**
346
492
  * The folder set can also change under a running sweep: a delete asked for while
347
493
  * the account is enumerating lands between the LIST and one folder's STATUS.
@@ -10,9 +10,15 @@ import type {
10
10
  IMailboxSpecialUseRepository,
11
11
  MailboxItem,
12
12
  } from "@remit/data-ports";
13
+ import {
14
+ CANONICAL_ROLES,
15
+ type CanonicalMailboxRoleValue,
16
+ ROLE_NAME_HINTS,
17
+ ROLE_SPECIAL_USE,
18
+ } from "@remit/data-ports/folder-role";
13
19
  import {
14
20
  MailboxCursorState,
15
- MailboxSpecialUse,
21
+ type MailboxSpecialUse,
16
22
  MailboxSyncStatus,
17
23
  NamespaceType,
18
24
  } from "@remit/domain-enums";
@@ -51,23 +57,27 @@ const areSpecialUseSetsEqual = (
51
57
  };
52
58
 
53
59
  /**
54
- * Map common folder names to their expected special-use designation.
55
- * Used to detect duplicate folders (e.g., "Trash" vs "[Gmail]/Trash").
60
+ * The role a folder's own leaf name is most conventionally for, read from the
61
+ * one hint table every special-folder lookup shares (`@remit/data-ports`,
62
+ * #837). A name that several roles list goes to the role that ranks it highest,
63
+ * so `All Mail` is an All folder rather than a lookalike of Archive.
64
+ *
65
+ * A second copy of these names lived here, and it is the one that could destroy
66
+ * something: it still called `Deleted` and `Bin` Trash names, which #843 dropped
67
+ * precisely because they are ordinary folders a user keeps mail in — and this
68
+ * lookup does not merely skip a folder, it deletes the row and everything the
69
+ * client can see in it.
56
70
  */
57
- const FOLDER_NAME_TO_SPECIAL_USE: Record<string, MailboxSpecialUseValue> = {
58
- trash: MailboxSpecialUse.Trash,
59
- "deleted items": MailboxSpecialUse.Trash,
60
- deleted: MailboxSpecialUse.Trash,
61
- bin: MailboxSpecialUse.Trash,
62
- drafts: MailboxSpecialUse.Drafts,
63
- draft: MailboxSpecialUse.Drafts,
64
- sent: MailboxSpecialUse.Sent,
65
- "sent items": MailboxSpecialUse.Sent,
66
- "sent mail": MailboxSpecialUse.Sent,
67
- junk: MailboxSpecialUse.Junk,
68
- spam: MailboxSpecialUse.Junk,
69
- archive: MailboxSpecialUse.Archive,
70
- archives: MailboxSpecialUse.Archive,
71
+ const roleForFolderName = (
72
+ name: string,
73
+ ): CanonicalMailboxRoleValue | undefined => {
74
+ let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
75
+ for (const role of CANONICAL_ROLES) {
76
+ const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
77
+ if (rank < 0) continue;
78
+ if (!best || rank < best.rank) best = { role, rank };
79
+ }
80
+ return best?.role;
71
81
  };
72
82
 
73
83
  /**
@@ -451,8 +461,8 @@ export class MailboxSyncService {
451
461
 
452
462
  const mailbox = await this.mailboxService.create(input);
453
463
 
454
- // Keep the MailboxSpecialUseEntry table in sync — other services (e.g.
455
- // MessageMoveService.findTrashMailbox) still query by entry. Denormalized
464
+ // Keep the MailboxSpecialUseEntry table in sync — every backend and worker
465
+ // special-folder lookup reads it through MailboxSpecialUseRepo. Denormalized
456
466
  // copy on Mailbox is the read-side optimization, the entries remain the
457
467
  // authoritative join source for cross-mailbox lookups.
458
468
  if (parsed.specialUse.length > 0) {
@@ -653,21 +663,28 @@ export class MailboxSyncService {
653
663
  const folderName = mailbox.fullPath.split(mailbox.delimiter).pop() ?? "";
654
664
  const normalizedName = folderName.toLowerCase();
655
665
 
656
- // Check if this folder name maps to a special-use designation
657
- const expectedSpecialUse = FOLDER_NAME_TO_SPECIAL_USE[normalizedName];
658
- if (!expectedSpecialUse) {
666
+ // Check if this folder name is a conventional name for a role
667
+ const role = roleForFolderName(normalizedName);
668
+ if (!role) {
659
669
  return false; // Not a special-use folder name
660
670
  }
661
671
 
672
+ const expectedSpecialUse = ROLE_SPECIAL_USE[role];
673
+ if (!expectedSpecialUse) {
674
+ return false; // The role has no SPECIAL-USE flag to be a duplicate of
675
+ }
676
+
662
677
  // Check if this mailbox has the special-use attribute
663
678
  const parsed = parseImapAttributes(mailbox.attributes);
664
- if (parsed.specialUse.includes(expectedSpecialUse)) {
679
+ if (parsed.specialUse.some((use) => use === expectedSpecialUse)) {
665
680
  return false; // This IS the canonical folder
666
681
  }
667
682
 
668
683
  // Check if another folder already claimed this special-use
669
- const claimedPath = claimedSpecialUse.get(expectedSpecialUse);
670
- if (!claimedPath) {
684
+ const claimed = [...claimedSpecialUse].find(
685
+ ([specialUse]) => specialUse === expectedSpecialUse,
686
+ );
687
+ if (!claimed) {
671
688
  return false; // No other folder has this special-use
672
689
  }
673
690
 
@@ -675,7 +692,7 @@ export class MailboxSyncService {
675
692
  this.log.debug(
676
693
  {
677
694
  fullPath: mailbox.fullPath,
678
- claimedPath,
695
+ claimedPath: claimed[1],
679
696
  specialUse: expectedSpecialUse,
680
697
  },
681
698
  "Skipping duplicate folder",