@remit/data-ports 0.0.31 → 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 +5 -1
- package/src/display-name.test.ts +0 -13
- package/src/display-name.ts +13 -59
- package/src/mailbox-name.test.ts +70 -0
- package/src/mailbox-name.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/data-ports",
|
|
3
|
-
"version": "0.0.
|
|
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"
|
package/src/display-name.test.ts
CHANGED
|
@@ -5,9 +5,6 @@ import { storedDisplayName } from "./display-name.js";
|
|
|
5
5
|
const OWN = "matthijs@ischen.nl";
|
|
6
6
|
const SPOOFED = "aramirez@secresaludguaviare.gov.co";
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
* The name is a stranger's address and nothing else, so nothing survives it.
|
|
10
|
-
*/
|
|
11
8
|
const emptied: ReadonlyArray<readonly [string, string]> = [
|
|
12
9
|
["the incident", "matthijs@ischen.nl"],
|
|
13
10
|
["quoted", '"matthijs@ischen.nl"'],
|
|
@@ -21,11 +18,6 @@ const emptied: ReadonlyArray<readonly [string, string]> = [
|
|
|
21
18
|
["a non-ASCII local part", "Özcan@example.com"],
|
|
22
19
|
];
|
|
23
20
|
|
|
24
|
-
/**
|
|
25
|
-
* The name says something besides the address, and that something is what its
|
|
26
|
-
* recipient knows the sender by. Removing it to remove the address destroys
|
|
27
|
-
* real text on a live instance.
|
|
28
|
-
*/
|
|
29
21
|
const stripped: ReadonlyArray<readonly [string, string, string]> = [
|
|
30
22
|
["parenthesised", "Support (support@acme.com)", "Support"],
|
|
31
23
|
["angle-bracketed", "Matthijs <matthijs@ischen.nl>", "Matthijs"],
|
|
@@ -84,11 +76,6 @@ describe("a display name that claims to be an address", () => {
|
|
|
84
76
|
assert.equal(storedDisplayName(name, OWN), name);
|
|
85
77
|
});
|
|
86
78
|
|
|
87
|
-
/**
|
|
88
|
-
* SQLite folds `A-Z` and stops; JS folds the whole of Unicode. A rule spelled
|
|
89
|
-
* once in SQL and once in TypeScript disagrees exactly here, and the
|
|
90
|
-
* disagreement rewrites a name on a live database.
|
|
91
|
-
*/
|
|
92
79
|
it("folds case beyond ASCII on both sides", () => {
|
|
93
80
|
assert.equal(
|
|
94
81
|
storedDisplayName("Özcan@example.com", "özcan@example.com"),
|
package/src/display-name.ts
CHANGED
|
@@ -1,86 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A display name that claims to be an email address (issue #826).
|
|
3
|
-
*
|
|
4
|
-
* The name on an envelope address is free text its sender chooses; the address
|
|
5
|
-
* is verifiable. A spam envelope of
|
|
6
|
-
* `matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>` therefore labels a
|
|
7
|
-
* stranger's address with the reader's own, and every surface that shows a name
|
|
8
|
-
* — the message list, the message header, contact autocomplete — repeats the
|
|
9
|
-
* claim. On 2026-08-18 autocomplete offered such a row back and private mail
|
|
10
|
-
* left the instance.
|
|
11
|
-
*
|
|
12
|
-
* The rule is one function because it has to hold in two places at once: the
|
|
13
|
-
* harvest that decides what to store, and the repair that rewrites what is
|
|
14
|
-
* already stored. Two spellings of it disagree at the edges — SQL `lower()`
|
|
15
|
-
* folds ASCII only where JS folds all of Unicode — and every disagreement
|
|
16
|
-
* rewrites a name on a live database that holds the only copy of it.
|
|
17
|
-
*
|
|
18
|
-
* What comes out is the name minus the claim, not the empty string. A sender
|
|
19
|
-
* signing itself `Support (support@acme.com)` from `noreply@acme.com` is
|
|
20
|
-
* ordinary mail, and `Support` is the name its recipient knows it by; deleting
|
|
21
|
-
* that to remove the address takes real text off a live instance. Only the
|
|
22
|
-
* address is removed, with the punctuation that was holding it, and a name that
|
|
23
|
-
* is nothing but the claim comes back empty.
|
|
24
|
-
*
|
|
25
|
-
* An address the name shares with the address it labels is not a claim about
|
|
26
|
-
* anyone else, so it stays — `Matthijs <matthijs@ischen.nl>` on
|
|
27
|
-
* `matthijs@ischen.nl` is the same information twice.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
1
|
const EMBEDDED_ADDRESS =
|
|
31
2
|
/[^\s@<>()[\],;:"'\\]+@[^\s@<>()[\],;:"'\\]+\.[^\s@<>()[\],;:"'\\.]{2,}/gu;
|
|
32
3
|
|
|
33
|
-
|
|
34
|
-
const EMPTIED_PAIR = /[<([{"']\s*[>)\]}"']/gu;
|
|
4
|
+
const DELIMITER_PAIR_LEFT_EMPTY = /[<([{"']\s*[>)\]}"']/gu;
|
|
35
5
|
|
|
36
|
-
|
|
37
|
-
* What is left holding the address once it is gone: the separators around it.
|
|
38
|
-
* Brackets and quotes are not trimmed here — EMPTIED_PAIR has already taken the
|
|
39
|
-
* ones that were wrapping the address, and a name can legitimately end in one.
|
|
40
|
-
*/
|
|
41
|
-
const EDGE_SEPARATORS = /^[\s,;:|/\\-]+|[\s,;:|/\\-]+$/gu;
|
|
6
|
+
const LEADING_OR_TRAILING_SEPARATORS = /^[\s,;:|/\\-]+|[\s,;:|/\\-]+$/gu;
|
|
42
7
|
|
|
43
|
-
const
|
|
8
|
+
const tidyAfterAddressRemoval = (text: string): string =>
|
|
44
9
|
text
|
|
45
|
-
.replace(
|
|
10
|
+
.replace(DELIMITER_PAIR_LEFT_EMPTY, " ")
|
|
46
11
|
.replace(/\s+/gu, " ")
|
|
47
|
-
.replace(
|
|
12
|
+
.replace(LEADING_OR_TRAILING_SEPARATORS, "")
|
|
48
13
|
.trim();
|
|
49
14
|
|
|
50
|
-
|
|
51
|
-
* `normalizedEmail` absent means the envelope carried no address this name
|
|
52
|
-
* could be describing, so any address in the name is a claim about nobody.
|
|
53
|
-
*/
|
|
54
|
-
const claimsAnotherAddress = (
|
|
15
|
+
const carriesAddressOtherThan = (
|
|
55
16
|
text: string,
|
|
56
|
-
|
|
17
|
+
ownAddress: string | undefined,
|
|
57
18
|
): boolean => {
|
|
58
19
|
for (const [address] of text.matchAll(EMBEDDED_ADDRESS)) {
|
|
59
|
-
if (address.toLowerCase() !==
|
|
20
|
+
if (address.toLowerCase() !== ownAddress) return true;
|
|
60
21
|
}
|
|
61
22
|
return false;
|
|
62
23
|
};
|
|
63
24
|
|
|
64
|
-
/**
|
|
65
|
-
* The display name as it should be stored, for both the harvest and the repair.
|
|
66
|
-
* Unchanged when the name claims nothing.
|
|
67
|
-
*/
|
|
68
25
|
export const storedDisplayName = (
|
|
69
26
|
displayName: string | undefined,
|
|
70
27
|
normalizedEmail: string | undefined,
|
|
71
28
|
): string => {
|
|
72
29
|
if (!displayName) return "";
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
30
|
+
const ownAddress = normalizedEmail?.toLowerCase();
|
|
31
|
+
if (!carriesAddressOtherThan(displayName, ownAddress)) return displayName;
|
|
75
32
|
|
|
76
|
-
const remainder =
|
|
33
|
+
const remainder = tidyAfterAddressRemoval(
|
|
77
34
|
displayName.replace(EMBEDDED_ADDRESS, (address) =>
|
|
78
|
-
address.toLowerCase() ===
|
|
35
|
+
address.toLowerCase() === ownAddress ? address : " ",
|
|
79
36
|
),
|
|
80
37
|
);
|
|
81
|
-
|
|
82
|
-
// backstop rather than a case: nothing that still claims another address is
|
|
83
|
-
// ever stored, whatever the shape it was hiding in.
|
|
84
|
-
if (!remainder || claimsAnotherAddress(remainder, own)) return "";
|
|
38
|
+
if (!remainder || carriesAddressOtherThan(remainder, ownAddress)) return "";
|
|
85
39
|
return remainder;
|
|
86
40
|
};
|
|
@@ -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
|
+
};
|