@remit/data-ports 0.0.30 → 0.0.31
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 +114 -0
- package/src/display-name.ts +86 -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.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./src/account-settings.ts",
|
|
26
26
|
"default": "./src/account-settings.ts"
|
|
27
27
|
},
|
|
28
|
+
"./display-name": {
|
|
29
|
+
"types": "./src/display-name.ts",
|
|
30
|
+
"default": "./src/display-name.ts"
|
|
31
|
+
},
|
|
28
32
|
"./wellknown": {
|
|
29
33
|
"types": "./src/wellknown.ts",
|
|
30
34
|
"default": "./src/wellknown.ts"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { storedDisplayName } from "./display-name.js";
|
|
4
|
+
|
|
5
|
+
const OWN = "matthijs@ischen.nl";
|
|
6
|
+
const SPOOFED = "aramirez@secresaludguaviare.gov.co";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The name is a stranger's address and nothing else, so nothing survives it.
|
|
10
|
+
*/
|
|
11
|
+
const emptied: ReadonlyArray<readonly [string, string]> = [
|
|
12
|
+
["the incident", "matthijs@ischen.nl"],
|
|
13
|
+
["quoted", '"matthijs@ischen.nl"'],
|
|
14
|
+
["angle-bracketed", "<matthijs@ischen.nl>"],
|
|
15
|
+
["BATV-wrapped", "prvs=0068b51f37=matthijs@ischen.nl"],
|
|
16
|
+
["upper case", "MATTHIJS@ISCHEN.NL"],
|
|
17
|
+
["a subdomain apart", "matthijs@mail.ischen.nl"],
|
|
18
|
+
["separated by a tab", "\tmatthijs@ischen.nl"],
|
|
19
|
+
["separated by a no-break space", " matthijs@ischen.nl"],
|
|
20
|
+
["separated by a zero-width space", "matthijs@ischen.nl"],
|
|
21
|
+
["a non-ASCII local part", "Özcan@example.com"],
|
|
22
|
+
];
|
|
23
|
+
|
|
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
|
+
const stripped: ReadonlyArray<readonly [string, string, string]> = [
|
|
30
|
+
["parenthesised", "Support (support@acme.com)", "Support"],
|
|
31
|
+
["angle-bracketed", "Matthijs <matthijs@ischen.nl>", "Matthijs"],
|
|
32
|
+
["comma-separated", "matthijs@ischen.nl, team", "team"],
|
|
33
|
+
["semicolon-separated", "Team; matthijs@ischen.nl", "Team"],
|
|
34
|
+
["colon-separated", "Reply to: matthijs@ischen.nl", "Reply to"],
|
|
35
|
+
["leading and parenthesised", "(matthijs@ischen.nl) Support", "Support"],
|
|
36
|
+
["mid-name", "Support <matthijs@ischen.nl> Team", "Support Team"],
|
|
37
|
+
["with a trailing verb", "matthijs@ischen.nl wrote", "wrote"],
|
|
38
|
+
[
|
|
39
|
+
"quoted around a name",
|
|
40
|
+
'Bob "The Builder" <bob@acme.com>',
|
|
41
|
+
'Bob "The Builder"',
|
|
42
|
+
],
|
|
43
|
+
["a tab away from the name", "Support\tmatthijs@ischen.nl", "Support"],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const kept: ReadonlyArray<readonly [string, string]> = [
|
|
47
|
+
["an ordinary human name", "Matthijs van Henten"],
|
|
48
|
+
["a name with a full stop", "Dr. M. van Henten"],
|
|
49
|
+
["a company name", "ING Bank N.V."],
|
|
50
|
+
["a bare word", "Newsletter"],
|
|
51
|
+
["an empty name", ""],
|
|
52
|
+
["a name with an at-sign but no domain", "me @ home"],
|
|
53
|
+
["a social handle", "@matthijs"],
|
|
54
|
+
["a version string", "release 2.11"],
|
|
55
|
+
["a domain with no local part", "ischen.nl"],
|
|
56
|
+
["a single-letter tld", "a@b.c"],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
describe("a display name that claims to be an address", () => {
|
|
60
|
+
for (const [what, name] of emptied) {
|
|
61
|
+
it(`empties ${what}`, () => {
|
|
62
|
+
assert.equal(storedDisplayName(name, SPOOFED), "");
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const [what, name, remainder] of stripped) {
|
|
67
|
+
it(`keeps what is left after ${what}`, () => {
|
|
68
|
+
assert.equal(storedDisplayName(name, SPOOFED), remainder);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [what, name] of kept) {
|
|
73
|
+
it(`leaves ${what} alone`, () => {
|
|
74
|
+
assert.equal(storedDisplayName(name, OWN), name);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it("keeps the address it labels", () => {
|
|
79
|
+
assert.equal(storedDisplayName(OWN, OWN), OWN);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps its own address inside a longer name", () => {
|
|
83
|
+
const name = "Matthijs <matthijs@ischen.nl>";
|
|
84
|
+
assert.equal(storedDisplayName(name, OWN), name);
|
|
85
|
+
});
|
|
86
|
+
|
|
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
|
+
it("folds case beyond ASCII on both sides", () => {
|
|
93
|
+
assert.equal(
|
|
94
|
+
storedDisplayName("Özcan@example.com", "özcan@example.com"),
|
|
95
|
+
"Özcan@example.com",
|
|
96
|
+
);
|
|
97
|
+
assert.equal(
|
|
98
|
+
storedDisplayName("ÖZCAN@EXAMPLE.COM", "özcan@example.com"),
|
|
99
|
+
"ÖZCAN@EXAMPLE.COM",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("removes any address when the envelope carried none", () => {
|
|
104
|
+
assert.equal(storedDisplayName(`Support ${OWN}`, undefined), "Support");
|
|
105
|
+
assert.equal(storedDisplayName("Matthijs", undefined), "Matthijs");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("removes a second address and keeps its own", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
storedDisplayName(`${OWN} on behalf of ${SPOOFED}`, OWN),
|
|
111
|
+
`${OWN} on behalf of`,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
const EMBEDDED_ADDRESS =
|
|
31
|
+
/[^\s@<>()[\],;:"'\\]+@[^\s@<>()[\],;:"'\\]+\.[^\s@<>()[\],;:"'\\.]{2,}/gu;
|
|
32
|
+
|
|
33
|
+
/** `( )`, `< >`, `""` — a delimiter pair the removed address was sitting in. */
|
|
34
|
+
const EMPTIED_PAIR = /[<([{"']\s*[>)\]}"']/gu;
|
|
35
|
+
|
|
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;
|
|
42
|
+
|
|
43
|
+
const tidy = (text: string): string =>
|
|
44
|
+
text
|
|
45
|
+
.replace(EMPTIED_PAIR, " ")
|
|
46
|
+
.replace(/\s+/gu, " ")
|
|
47
|
+
.replace(EDGE_SEPARATORS, "")
|
|
48
|
+
.trim();
|
|
49
|
+
|
|
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 = (
|
|
55
|
+
text: string,
|
|
56
|
+
own: string | undefined,
|
|
57
|
+
): boolean => {
|
|
58
|
+
for (const [address] of text.matchAll(EMBEDDED_ADDRESS)) {
|
|
59
|
+
if (address.toLowerCase() !== own) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
|
|
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
|
+
export const storedDisplayName = (
|
|
69
|
+
displayName: string | undefined,
|
|
70
|
+
normalizedEmail: string | undefined,
|
|
71
|
+
): string => {
|
|
72
|
+
if (!displayName) return "";
|
|
73
|
+
const own = normalizedEmail?.toLowerCase();
|
|
74
|
+
if (!claimsAnotherAddress(displayName, own)) return displayName;
|
|
75
|
+
|
|
76
|
+
const remainder = tidy(
|
|
77
|
+
displayName.replace(EMBEDDED_ADDRESS, (address) =>
|
|
78
|
+
address.toLowerCase() === own ? address : " ",
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
// Stripping leaves at most the row's own address behind, so the re-check is a
|
|
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 "";
|
|
85
|
+
return remainder;
|
|
86
|
+
};
|