@remit/data-ports 0.0.32 → 0.0.33

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/data-ports",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -29,6 +29,10 @@
29
29
  "types": "./src/display-name.ts",
30
30
  "default": "./src/display-name.ts"
31
31
  },
32
+ "./mailbox-name": {
33
+ "types": "./src/mailbox-name.ts",
34
+ "default": "./src/mailbox-name.ts"
35
+ },
32
36
  "./wellknown": {
33
37
  "types": "./src/wellknown.ts",
34
38
  "default": "./src/wellknown.ts"
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { resolveMailboxByLeafName } from "./mailbox-name.js";
4
+
5
+ const JUNK_NAMES = ["junk", "spam", "junk e-mail", "junk email", "bulk mail"];
6
+
7
+ const mailbox = (
8
+ mailboxId: string,
9
+ fullPath: string,
10
+ hierarchyDelimiter = "/",
11
+ ) => ({ mailboxId, fullPath, hierarchyDelimiter });
12
+
13
+ describe("resolveMailboxByLeafName", () => {
14
+ it("resolves a Junk folder nested under INBOX", () => {
15
+ // The layout that broke report-spam's name fallback: an INBOX-prefixed
16
+ // namespace where Junk is `INBOX/Spam`, which never equals "spam".
17
+ const found = resolveMailboxByLeafName(
18
+ [
19
+ mailbox("m-inbox", "INBOX"),
20
+ mailbox("m-spam", "INBOX/Spam"),
21
+ mailbox("m-sent", "INBOX/Sent"),
22
+ ],
23
+ JUNK_NAMES,
24
+ );
25
+ assert.equal(found?.mailboxId, "m-spam");
26
+ });
27
+
28
+ it("resolves under a non-slash delimiter and a non-INBOX prefix", () => {
29
+ const found = resolveMailboxByLeafName(
30
+ [mailbox("m-junk", "Mail.Junk E-mail", ".")],
31
+ JUNK_NAMES,
32
+ );
33
+ assert.equal(found?.mailboxId, "m-junk");
34
+ });
35
+
36
+ it("treats a flat namespace with no delimiter as its own leaf", () => {
37
+ const found = resolveMailboxByLeafName(
38
+ [mailbox("m-junk", "Junk", "")],
39
+ JUNK_NAMES,
40
+ );
41
+ assert.equal(found?.mailboxId, "m-junk");
42
+ });
43
+
44
+ it("prefers the shallowest match over a deeper one", () => {
45
+ const found = resolveMailboxByLeafName(
46
+ [
47
+ mailbox("m-buried", "Archive/2019/Spam"),
48
+ mailbox("m-real", "INBOX/Junk"),
49
+ ],
50
+ JUNK_NAMES,
51
+ );
52
+ assert.equal(found?.mailboxId, "m-real");
53
+ });
54
+
55
+ it("prefers the better name at equal depth", () => {
56
+ const found = resolveMailboxByLeafName(
57
+ [mailbox("m-bulk", "INBOX/Bulk Mail"), mailbox("m-junk", "INBOX/Junk")],
58
+ JUNK_NAMES,
59
+ );
60
+ assert.equal(found?.mailboxId, "m-junk");
61
+ });
62
+
63
+ it("answers null when no folder carries a conventional name", () => {
64
+ const found = resolveMailboxByLeafName(
65
+ [mailbox("m-inbox", "INBOX"), mailbox("m-work", "INBOX/Work")],
66
+ JUNK_NAMES,
67
+ );
68
+ assert.equal(found, null);
69
+ });
70
+ });
@@ -0,0 +1,54 @@
1
+ export interface MailboxNameCandidate {
2
+ mailboxId: string;
3
+ fullPath: string;
4
+ hierarchyDelimiter: string;
5
+ }
6
+
7
+ // A flat mailbox namespace reports no delimiter at all — ImapFlow gives `""`
8
+ // for a NIL LIST delimiter — and splitting on "" returns single characters, so
9
+ // the path is its own leaf.
10
+ const segments = (mailbox: MailboxNameCandidate): string[] =>
11
+ mailbox.hierarchyDelimiter.length === 0
12
+ ? [mailbox.fullPath]
13
+ : mailbox.fullPath.split(mailbox.hierarchyDelimiter);
14
+
15
+ const rank = (
16
+ mailbox: MailboxNameCandidate,
17
+ names: readonly string[],
18
+ ): number | null => {
19
+ const parts = segments(mailbox);
20
+ const leaf = parts[parts.length - 1] ?? mailbox.fullPath;
21
+ const nameIndex = names.indexOf(leaf.toLowerCase());
22
+ if (nameIndex < 0) return null;
23
+ // Depth outranks the name: a "Spam" buried under Trash or an archive must
24
+ // never beat the account's real "Junk" one level up.
25
+ return parts.length * names.length + nameIndex;
26
+ };
27
+
28
+ /**
29
+ * A special-use folder by conventional name, for servers that advertise no
30
+ * special-use flag for it. Matches the folder's own leaf segment against its
31
+ * mailbox's own hierarchy delimiter, so it resolves at any depth under any
32
+ * prefix (`INBOX/Spam`, `Mail.Junk E-mail`, `[Gmail]/Spam`) without knowing
33
+ * which prefixes a server uses. `names` is ordered best-first and must be
34
+ * lower case.
35
+ */
36
+ export const resolveMailboxByLeafName = <T extends MailboxNameCandidate>(
37
+ mailboxes: T[],
38
+ names: readonly string[],
39
+ ): T | null => {
40
+ let best: { mailbox: T; rank: number } | null = null;
41
+ for (const mailbox of mailboxes) {
42
+ const candidate = rank(mailbox, names);
43
+ if (candidate === null) continue;
44
+ if (
45
+ !best ||
46
+ candidate < best.rank ||
47
+ (candidate === best.rank &&
48
+ mailbox.fullPath.localeCompare(best.mailbox.fullPath) < 0)
49
+ ) {
50
+ best = { mailbox, rank: candidate };
51
+ }
52
+ }
53
+ return best?.mailbox ?? null;
54
+ };