@bobfrankston/mailx-types 0.1.111 → 0.1.113
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/address.d.ts +54 -0
- package/address.js +138 -0
- package/groups.js +6 -5
- package/headers.d.ts +87 -0
- package/headers.js +138 -0
- package/index.d.ts +5 -15
- package/index.js +22 -74
- package/list-identity.d.ts +13 -5
- package/list-identity.js +25 -22
- package/package.json +1 -1
- package/trust.js +28 -40
package/address.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email address parsing — the ONE place mailx reads addresses out of header
|
|
3
|
+
* values and decides whether a string is an address.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-26 — Claude Code (Opus 5.5), at Bob's direction ("fold the other
|
|
6
|
+
* address regexes into the shared functions"). Before this, about a dozen
|
|
7
|
+
* call sites each carried their own address regex: the send paths, trust,
|
|
8
|
+
* the store, list-identity, compose, the editor's link detection, contacts,
|
|
9
|
+
* groups and the CLI. They disagreed on which characters an address may
|
|
10
|
+
* hold, and the narrowest one (`[\w.+-]+@`) cut
|
|
11
|
+
* `bernardgreenberg=msn.com@groups.io` to `msn.com@groups.io` in RCPT TO.
|
|
12
|
+
* Parsing is structural here — split the header on top-level commas, take
|
|
13
|
+
* the `<…>` part or the bare entry — so no function guesses at the
|
|
14
|
+
* characters of a local part.
|
|
15
|
+
*
|
|
16
|
+
* Pure, no I/O, browser-safe.
|
|
17
|
+
*/
|
|
18
|
+
/** One parsed entry of an address header. */
|
|
19
|
+
export interface ParsedAddress {
|
|
20
|
+
/** Display name, unquoted and unescaped; "" when there is none. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** The addr-spec, as written (case kept). */
|
|
23
|
+
address: string;
|
|
24
|
+
}
|
|
25
|
+
/** Split an address header on top-level commas (outside quotes and angle
|
|
26
|
+
* brackets; `\` escapes inside quotes honored). Moved here from index.ts. */
|
|
27
|
+
export declare function splitAddressHeader(value: string): string[];
|
|
28
|
+
/** Every entry of an address header that carries an addr-spec, with its
|
|
29
|
+
* display name. Entries with no address (a bare name, an empty group like
|
|
30
|
+
* `Undisclosed recipients:;`) are left out. */
|
|
31
|
+
export declare function parseAddressList(value: string): ParsedAddress[];
|
|
32
|
+
/** The first entry of an address header value, or null. */
|
|
33
|
+
export declare function parseAddress(value: string): ParsedAddress | null;
|
|
34
|
+
/** The addr-spec of every entry — what SMTP RCPT TO / MAIL FROM must carry.
|
|
35
|
+
*
|
|
36
|
+
* 2026-10-25: replaced the `/[\w.+-]+@[\w.-]+/g` scan that all three send
|
|
37
|
+
* paths carried, which omitted characters RFC 5322 allows in a local part
|
|
38
|
+
* (`= ! # $ % & ' * / ? ^ { | } ~`) and so turned
|
|
39
|
+
* `bernardgreenberg=msn.com@groups.io` into `msn.com@groups.io`. */
|
|
40
|
+
export declare function envelopeAddresses(value: string): string[];
|
|
41
|
+
/** The first addr-spec in a header value (`"Name" <a@b>`, `a@b (comment)`,
|
|
42
|
+
* or a bare `a@b`), or "". Case kept; callers that compare lowercase it. */
|
|
43
|
+
export declare function firstAddress(value: string): string;
|
|
44
|
+
/** Is `s` — a bare addr-spec, no display name — plausibly deliverable?
|
|
45
|
+
* Something before the last `@`, a dotted domain after it, and no blanks or
|
|
46
|
+
* angle brackets anywhere. The rule all five copies of
|
|
47
|
+
* `/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/` encoded, written without a pattern
|
|
48
|
+
* whose domain part could be split two ways. Any character RFC 5322 allows
|
|
49
|
+
* in a local part passes. */
|
|
50
|
+
export declare function isValidAddress(s: string): boolean;
|
|
51
|
+
export declare const ADDRESS_IN_TEXT: string;
|
|
52
|
+
/** Every address-shaped run in free text. */
|
|
53
|
+
export declare function findAddressesInText(text: string): string[];
|
|
54
|
+
//# sourceMappingURL=address.d.ts.map
|
package/address.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email address parsing — the ONE place mailx reads addresses out of header
|
|
3
|
+
* values and decides whether a string is an address.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-26 — Claude Code (Opus 5.5), at Bob's direction ("fold the other
|
|
6
|
+
* address regexes into the shared functions"). Before this, about a dozen
|
|
7
|
+
* call sites each carried their own address regex: the send paths, trust,
|
|
8
|
+
* the store, list-identity, compose, the editor's link detection, contacts,
|
|
9
|
+
* groups and the CLI. They disagreed on which characters an address may
|
|
10
|
+
* hold, and the narrowest one (`[\w.+-]+@`) cut
|
|
11
|
+
* `bernardgreenberg=msn.com@groups.io` to `msn.com@groups.io` in RCPT TO.
|
|
12
|
+
* Parsing is structural here — split the header on top-level commas, take
|
|
13
|
+
* the `<…>` part or the bare entry — so no function guesses at the
|
|
14
|
+
* characters of a local part.
|
|
15
|
+
*
|
|
16
|
+
* Pure, no I/O, browser-safe.
|
|
17
|
+
*/
|
|
18
|
+
/** Split an address header on top-level commas (outside quotes and angle
|
|
19
|
+
* brackets; `\` escapes inside quotes honored). Moved here from index.ts. */
|
|
20
|
+
export function splitAddressHeader(value) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
let cur = "";
|
|
23
|
+
let inAngle = false;
|
|
24
|
+
let inQuote = false;
|
|
25
|
+
for (let i = 0; i < value.length; i++) {
|
|
26
|
+
const ch = value[i];
|
|
27
|
+
if (inQuote && ch === "\\" && i + 1 < value.length) {
|
|
28
|
+
cur += ch + value[++i];
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (ch === '"' && !inAngle)
|
|
32
|
+
inQuote = !inQuote;
|
|
33
|
+
else if (ch === "<" && !inQuote)
|
|
34
|
+
inAngle = true;
|
|
35
|
+
else if (ch === ">" && !inQuote)
|
|
36
|
+
inAngle = false;
|
|
37
|
+
if (ch === "," && !inAngle && !inQuote) {
|
|
38
|
+
parts.push(cur);
|
|
39
|
+
cur = "";
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
cur += ch;
|
|
43
|
+
}
|
|
44
|
+
if (cur.trim())
|
|
45
|
+
parts.push(cur);
|
|
46
|
+
return parts.map(p => p.trim()).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
/** Text on both sides of the last `@`: the least an addr-spec must have. */
|
|
49
|
+
function hasAddrShape(spec) {
|
|
50
|
+
const at = spec.lastIndexOf("@");
|
|
51
|
+
return at > 0 && at < spec.length - 1;
|
|
52
|
+
}
|
|
53
|
+
/** Every entry of an address header that carries an addr-spec, with its
|
|
54
|
+
* display name. Entries with no address (a bare name, an empty group like
|
|
55
|
+
* `Undisclosed recipients:;`) are left out. */
|
|
56
|
+
export function parseAddressList(value) {
|
|
57
|
+
if (!value)
|
|
58
|
+
return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const part of splitAddressHeader(value)) {
|
|
61
|
+
const angle = /<([^<>]*)>\s*$/.exec(part);
|
|
62
|
+
let name = "";
|
|
63
|
+
let spec;
|
|
64
|
+
if (angle) {
|
|
65
|
+
spec = angle[1].trim();
|
|
66
|
+
name = part.slice(0, angle.index).replace(/\([^()]*\)/g, "").trim();
|
|
67
|
+
if (name.length >= 2 && name.startsWith('"') && name.endsWith('"'))
|
|
68
|
+
name = name.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// No angle brackets: an addr-spec has no unquoted blanks, so when
|
|
72
|
+
// the entry has words around it ("mailing list x@y", a sloppy
|
|
73
|
+
// "Bob bob@x.com") the address is the word that has the shape.
|
|
74
|
+
const words = part.replace(/\([^()]*\)/g, " ").trim().split(/\s+/);
|
|
75
|
+
spec = words.find(hasAddrShape) || "";
|
|
76
|
+
}
|
|
77
|
+
if (hasAddrShape(spec))
|
|
78
|
+
out.push({ name, address: spec });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** The first entry of an address header value, or null. */
|
|
83
|
+
export function parseAddress(value) {
|
|
84
|
+
return parseAddressList(value)[0] || null;
|
|
85
|
+
}
|
|
86
|
+
/** The addr-spec of every entry — what SMTP RCPT TO / MAIL FROM must carry.
|
|
87
|
+
*
|
|
88
|
+
* 2026-10-25: replaced the `/[\w.+-]+@[\w.-]+/g` scan that all three send
|
|
89
|
+
* paths carried, which omitted characters RFC 5322 allows in a local part
|
|
90
|
+
* (`= ! # $ % & ' * / ? ^ { | } ~`) and so turned
|
|
91
|
+
* `bernardgreenberg=msn.com@groups.io` into `msn.com@groups.io`. */
|
|
92
|
+
export function envelopeAddresses(value) {
|
|
93
|
+
return parseAddressList(value).map(a => a.address);
|
|
94
|
+
}
|
|
95
|
+
/** The first addr-spec in a header value (`"Name" <a@b>`, `a@b (comment)`,
|
|
96
|
+
* or a bare `a@b`), or "". Case kept; callers that compare lowercase it. */
|
|
97
|
+
export function firstAddress(value) {
|
|
98
|
+
return parseAddressList(value)[0]?.address || "";
|
|
99
|
+
}
|
|
100
|
+
/** Is `s` — a bare addr-spec, no display name — plausibly deliverable?
|
|
101
|
+
* Something before the last `@`, a dotted domain after it, and no blanks or
|
|
102
|
+
* angle brackets anywhere. The rule all five copies of
|
|
103
|
+
* `/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/` encoded, written without a pattern
|
|
104
|
+
* whose domain part could be split two ways. Any character RFC 5322 allows
|
|
105
|
+
* in a local part passes. */
|
|
106
|
+
export function isValidAddress(s) {
|
|
107
|
+
const t = (s || "").trim();
|
|
108
|
+
if (!t || /[\s<>]/.test(t))
|
|
109
|
+
return false;
|
|
110
|
+
const at = t.lastIndexOf("@");
|
|
111
|
+
if (at <= 0)
|
|
112
|
+
return false;
|
|
113
|
+
const local = t.slice(0, at);
|
|
114
|
+
const domain = t.slice(at + 1);
|
|
115
|
+
if (local.includes("@") || domain.includes("@"))
|
|
116
|
+
return false;
|
|
117
|
+
const dot = domain.indexOf(".");
|
|
118
|
+
return dot > 0 && dot < domain.length - 1;
|
|
119
|
+
}
|
|
120
|
+
/** Regex SOURCE for an address appearing inside free text (prose, a display
|
|
121
|
+
* name) — where there is no header structure to split on, so a pattern is
|
|
122
|
+
* the only tool. Local part: the RFC 5322 atext characters plus dots; domain:
|
|
123
|
+
* dotted labels. Each piece matches one way only (`@` and `.` are not in the
|
|
124
|
+
* classes they separate). Exported as a source string so a caller can embed
|
|
125
|
+
* it in a larger alternation (the spell checker's skip spans).
|
|
126
|
+
* (2026-10-26, Claude Code (Opus 5.5): replaces the spell checker's
|
|
127
|
+
* `[\w.+\-]+@…` and displayNameClaimsOtherAddress's `[A-Za-z0-9._%+-]+@…`.) */
|
|
128
|
+
// The lookbehind lets a match START only where a run of local-part characters
|
|
129
|
+
// starts. Without it a long run with no `@` (a pasted base64 blob) was retried
|
|
130
|
+
// from every position inside it, re-reading the rest each time: 80,000
|
|
131
|
+
// characters took 5.3 s (measured 2026-10-26). With it each run is read once.
|
|
132
|
+
const LOCAL_CHARS = String.raw `\w.!#$%&'*+\/=?^\`{|}~\-`;
|
|
133
|
+
export const ADDRESS_IN_TEXT = String.raw `(?<![${LOCAL_CHARS}])[${LOCAL_CHARS}]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+`;
|
|
134
|
+
/** Every address-shaped run in free text. */
|
|
135
|
+
export function findAddressesInText(text) {
|
|
136
|
+
return (text || "").match(new RegExp(ADDRESS_IN_TEXT, "g")) || [];
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=address.js.map
|
package/groups.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* names of other groups (recursive aliasing). Cycles are detected; depth
|
|
9
9
|
* is capped at 10 nested levels.
|
|
10
10
|
*/
|
|
11
|
+
import { firstAddress } from "./address.js";
|
|
11
12
|
/** Split a comma-or-semicolon recipient string into tokens. Respects
|
|
12
13
|
* angle-bracket address blocks ("Name, Suffix <a@b>") so a comma inside
|
|
13
14
|
* the display name doesn't split the entry. */
|
|
@@ -40,15 +41,15 @@ export function splitRecipients(raw) {
|
|
|
40
41
|
* a display name)? Anything containing "@" with non-whitespace on both
|
|
41
42
|
* sides counts. */
|
|
42
43
|
export function isAddressToken(token) {
|
|
43
|
-
|
|
44
|
+
// 2026-10-26 — Claude Code (Opus 5.5): shared parser (address.ts) instead of
|
|
45
|
+
// its own patterns; "Name <a@b>" and a bare "a@b" both parse.
|
|
46
|
+
return !!firstAddress(token);
|
|
44
47
|
}
|
|
45
48
|
/** Extract the bare email address from a token. Returns the original token
|
|
46
49
|
* lowercased if it has no angle-bracket form. */
|
|
47
50
|
export function extractAddress(token) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return m[1].trim().toLowerCase();
|
|
51
|
-
return token.trim().toLowerCase();
|
|
51
|
+
// 2026-10-26 — Claude Code (Opus 5.5): shared parser (address.ts).
|
|
52
|
+
return (firstAddress(token) || token).trim().toLowerCase();
|
|
52
53
|
}
|
|
53
54
|
/** Expand a recipient string by resolving group names against `groups`.
|
|
54
55
|
* Group names take precedence: if a token matches a group name AND looks
|
package/headers.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message headers — the ONE place mailx reads a header section and names
|
|
3
|
+
* the header fields it cares about.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction ("Isn't there a
|
|
6
|
+
* single header parse that is shared?" / "the string cc shouldn't be strewn
|
|
7
|
+
* around the code"). There wasn't: trust.ts, list-identity.ts,
|
|
8
|
+
* outgoingEnvelope, the desktop store, Android's web-service, the CLI index
|
|
9
|
+
* rebuild (twice) and the spam-report CSV each read headers their own way —
|
|
10
|
+
* different rules for case, folding and repeated fields, and every one
|
|
11
|
+
* spelled the field names itself. One of them took the first `Delivered-To:`
|
|
12
|
+
* anywhere in the first 4 KB, body included; another missed `CC:` written in
|
|
13
|
+
* capitals. Field names live in HEADER below; reading lives in the functions.
|
|
14
|
+
*
|
|
15
|
+
* Pure, no I/O, browser-safe.
|
|
16
|
+
*/
|
|
17
|
+
/** Header field names, lowercase (lookups ignore case). Add a field here
|
|
18
|
+
* rather than writing its name at a call site. */
|
|
19
|
+
export declare const HEADER: {
|
|
20
|
+
readonly from: "from";
|
|
21
|
+
readonly to: "to";
|
|
22
|
+
readonly cc: "cc";
|
|
23
|
+
readonly bcc: "bcc";
|
|
24
|
+
readonly replyTo: "reply-to";
|
|
25
|
+
readonly sender: "sender";
|
|
26
|
+
readonly subject: "subject";
|
|
27
|
+
readonly date: "date";
|
|
28
|
+
readonly messageId: "message-id";
|
|
29
|
+
readonly returnPath: "return-path";
|
|
30
|
+
readonly deliveredTo: "delivered-to";
|
|
31
|
+
readonly xOriginalTo: "x-original-to";
|
|
32
|
+
readonly envelopeTo: "envelope-to";
|
|
33
|
+
readonly resentFrom: "resent-from";
|
|
34
|
+
readonly xOriginalFrom: "x-original-from";
|
|
35
|
+
readonly listId: "list-id";
|
|
36
|
+
readonly listPost: "list-post";
|
|
37
|
+
readonly listUnsubscribe: "list-unsubscribe";
|
|
38
|
+
readonly listUnsubscribePost: "list-unsubscribe-post";
|
|
39
|
+
readonly authenticationResults: "authentication-results";
|
|
40
|
+
readonly xSpamStatus: "x-spam-status";
|
|
41
|
+
readonly xSpamFlag: "x-spam-flag";
|
|
42
|
+
readonly xSpamReport: "x-spam-report";
|
|
43
|
+
readonly xMailxDraftId: "x-mailx-draft-id";
|
|
44
|
+
};
|
|
45
|
+
/** The fields whose addresses a message is sent to (SMTP RCPT TO). */
|
|
46
|
+
export declare const RECIPIENT_HEADERS: readonly ["to", "cc", "bcc"];
|
|
47
|
+
/** Receiving-server fields naming the address a copy was delivered to:
|
|
48
|
+
* qmail/Gmail, Postfix, Exim spellings of the same fact. */
|
|
49
|
+
export declare const DELIVERY_HEADERS: readonly ["delivered-to", "x-original-to", "envelope-to"];
|
|
50
|
+
/** One header field occurrence: lowercase name and the raw `Name: value`
|
|
51
|
+
* line (continuation lines included) — the shape of mailparser's
|
|
52
|
+
* `headerLines`, so a parsed message can be read with the same functions. */
|
|
53
|
+
export interface HeaderLine {
|
|
54
|
+
key: string;
|
|
55
|
+
line: string;
|
|
56
|
+
}
|
|
57
|
+
/** Offset where the header section of raw RFC 822 text ends (the first
|
|
58
|
+
* blank line), or -1 when there is no body. */
|
|
59
|
+
export declare function headerSectionEnd(raw: string): number;
|
|
60
|
+
/** Every header field of raw RFC 822 text, in order, repeated fields kept.
|
|
61
|
+
* Only the header section is read, so a `Cc:` line quoted in the body is
|
|
62
|
+
* never mistaken for a header. */
|
|
63
|
+
export declare function readHeaderLines(raw: string): HeaderLine[];
|
|
64
|
+
/** The line ending raw text uses ("\r\n" or "\n"). */
|
|
65
|
+
export declare function lineEnding(raw: string): string;
|
|
66
|
+
/** Every value of a field, unfolded and trimmed, in message order. */
|
|
67
|
+
export declare function headerValues(lines: readonly HeaderLine[], name: string): string[];
|
|
68
|
+
/** The first value of a field, or "". */
|
|
69
|
+
export declare function headerValue(lines: readonly HeaderLine[], name: string): string;
|
|
70
|
+
/** Every value of a field with its folding KEPT (continuation line breaks and
|
|
71
|
+
* their whitespace), trimmed at the ends. For fields whose line structure
|
|
72
|
+
* carries meaning: SpamAssassin's X-Spam-Report lists one rule per line, and
|
|
73
|
+
* trust.ts parses it by line. Most callers want headerValues. */
|
|
74
|
+
export declare function headerRawValues(lines: readonly HeaderLine[], name: string): string[];
|
|
75
|
+
/** The first value of a field, folding kept, or "". */
|
|
76
|
+
export declare function headerRawValue(lines: readonly HeaderLine[], name: string): string;
|
|
77
|
+
/** List-Unsubscribe (RFC 2369) split into its mailto: and http(s): targets,
|
|
78
|
+
* plus whether List-Unsubscribe-Post offers RFC 8058 one-click. Reads the
|
|
79
|
+
* raw header so BOTH targets are seen (mailparser's structured view kept
|
|
80
|
+
* only one). Moved here from mailx-store/store.ts 2026-10-27 so Android
|
|
81
|
+
* (which kept only the raw value) gets the same answer. */
|
|
82
|
+
export declare function listUnsubscribeOf(lines: readonly HeaderLine[]): {
|
|
83
|
+
mail: string;
|
|
84
|
+
http: string;
|
|
85
|
+
oneClick: boolean;
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=headers.d.ts.map
|
package/headers.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message headers — the ONE place mailx reads a header section and names
|
|
3
|
+
* the header fields it cares about.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-27 — Claude Code (Opus 5.5), at Bob's direction ("Isn't there a
|
|
6
|
+
* single header parse that is shared?" / "the string cc shouldn't be strewn
|
|
7
|
+
* around the code"). There wasn't: trust.ts, list-identity.ts,
|
|
8
|
+
* outgoingEnvelope, the desktop store, Android's web-service, the CLI index
|
|
9
|
+
* rebuild (twice) and the spam-report CSV each read headers their own way —
|
|
10
|
+
* different rules for case, folding and repeated fields, and every one
|
|
11
|
+
* spelled the field names itself. One of them took the first `Delivered-To:`
|
|
12
|
+
* anywhere in the first 4 KB, body included; another missed `CC:` written in
|
|
13
|
+
* capitals. Field names live in HEADER below; reading lives in the functions.
|
|
14
|
+
*
|
|
15
|
+
* Pure, no I/O, browser-safe.
|
|
16
|
+
*/
|
|
17
|
+
/** Header field names, lowercase (lookups ignore case). Add a field here
|
|
18
|
+
* rather than writing its name at a call site. */
|
|
19
|
+
export const HEADER = {
|
|
20
|
+
from: "from",
|
|
21
|
+
to: "to",
|
|
22
|
+
cc: "cc",
|
|
23
|
+
bcc: "bcc",
|
|
24
|
+
replyTo: "reply-to",
|
|
25
|
+
sender: "sender",
|
|
26
|
+
subject: "subject",
|
|
27
|
+
date: "date",
|
|
28
|
+
messageId: "message-id",
|
|
29
|
+
returnPath: "return-path",
|
|
30
|
+
deliveredTo: "delivered-to",
|
|
31
|
+
xOriginalTo: "x-original-to",
|
|
32
|
+
envelopeTo: "envelope-to",
|
|
33
|
+
resentFrom: "resent-from",
|
|
34
|
+
xOriginalFrom: "x-original-from",
|
|
35
|
+
listId: "list-id",
|
|
36
|
+
listPost: "list-post",
|
|
37
|
+
listUnsubscribe: "list-unsubscribe",
|
|
38
|
+
listUnsubscribePost: "list-unsubscribe-post",
|
|
39
|
+
authenticationResults: "authentication-results",
|
|
40
|
+
xSpamStatus: "x-spam-status",
|
|
41
|
+
xSpamFlag: "x-spam-flag",
|
|
42
|
+
xSpamReport: "x-spam-report",
|
|
43
|
+
xMailxDraftId: "x-mailx-draft-id",
|
|
44
|
+
};
|
|
45
|
+
/** The fields whose addresses a message is sent to (SMTP RCPT TO). */
|
|
46
|
+
export const RECIPIENT_HEADERS = [HEADER.to, HEADER.cc, HEADER.bcc];
|
|
47
|
+
/** Receiving-server fields naming the address a copy was delivered to:
|
|
48
|
+
* qmail/Gmail, Postfix, Exim spellings of the same fact. */
|
|
49
|
+
export const DELIVERY_HEADERS = [HEADER.deliveredTo, HEADER.xOriginalTo, HEADER.envelopeTo];
|
|
50
|
+
/** Offset where the header section of raw RFC 822 text ends (the first
|
|
51
|
+
* blank line), or -1 when there is no body. */
|
|
52
|
+
export function headerSectionEnd(raw) {
|
|
53
|
+
return (raw || "").search(/\r?\n\r?\n/);
|
|
54
|
+
}
|
|
55
|
+
/** Every header field of raw RFC 822 text, in order, repeated fields kept.
|
|
56
|
+
* Only the header section is read, so a `Cc:` line quoted in the body is
|
|
57
|
+
* never mistaken for a header. */
|
|
58
|
+
export function readHeaderLines(raw) {
|
|
59
|
+
const end = headerSectionEnd(raw);
|
|
60
|
+
const section = end === -1 ? (raw || "") : raw.slice(0, end);
|
|
61
|
+
const out = [];
|
|
62
|
+
// Split keeping each line's own ending, so a field rebuilt from `line`
|
|
63
|
+
// (outgoingEnvelope's Bcc strip) is byte-identical to what was read.
|
|
64
|
+
const parts = section.split(/(\r?\n)/);
|
|
65
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
66
|
+
const physical = parts[i];
|
|
67
|
+
const eol = parts[i - 1] ?? "";
|
|
68
|
+
if (/^[ \t]/.test(physical) && out.length) {
|
|
69
|
+
out[out.length - 1].line += eol + physical;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const colon = physical.indexOf(":");
|
|
73
|
+
if (colon <= 0)
|
|
74
|
+
continue;
|
|
75
|
+
out.push({ key: physical.slice(0, colon).trim().toLowerCase(), line: physical });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** The line ending raw text uses ("\r\n" or "\n"). */
|
|
80
|
+
export function lineEnding(raw) {
|
|
81
|
+
return /\r\n/.test(raw || "") ? "\r\n" : "\n";
|
|
82
|
+
}
|
|
83
|
+
/** Every value of a field, unfolded and trimmed, in message order. */
|
|
84
|
+
export function headerValues(lines, name) {
|
|
85
|
+
const want = name.toLowerCase();
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const h of lines || []) {
|
|
88
|
+
if ((h.key || "").toLowerCase() !== want)
|
|
89
|
+
continue;
|
|
90
|
+
const colon = h.line.indexOf(":");
|
|
91
|
+
if (colon >= 0)
|
|
92
|
+
out.push(h.line.slice(colon + 1).replace(/\r?\n[ \t]+/g, " ").trim());
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** The first value of a field, or "". */
|
|
97
|
+
export function headerValue(lines, name) {
|
|
98
|
+
return headerValues(lines, name)[0] ?? "";
|
|
99
|
+
}
|
|
100
|
+
/** Every value of a field with its folding KEPT (continuation line breaks and
|
|
101
|
+
* their whitespace), trimmed at the ends. For fields whose line structure
|
|
102
|
+
* carries meaning: SpamAssassin's X-Spam-Report lists one rule per line, and
|
|
103
|
+
* trust.ts parses it by line. Most callers want headerValues. */
|
|
104
|
+
export function headerRawValues(lines, name) {
|
|
105
|
+
const want = name.toLowerCase();
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const h of lines || []) {
|
|
108
|
+
if ((h.key || "").toLowerCase() !== want)
|
|
109
|
+
continue;
|
|
110
|
+
const colon = h.line.indexOf(":");
|
|
111
|
+
if (colon >= 0)
|
|
112
|
+
out.push(h.line.slice(colon + 1).trim());
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** The first value of a field, folding kept, or "". */
|
|
117
|
+
export function headerRawValue(lines, name) {
|
|
118
|
+
return headerRawValues(lines, name)[0] ?? "";
|
|
119
|
+
}
|
|
120
|
+
/** List-Unsubscribe (RFC 2369) split into its mailto: and http(s): targets,
|
|
121
|
+
* plus whether List-Unsubscribe-Post offers RFC 8058 one-click. Reads the
|
|
122
|
+
* raw header so BOTH targets are seen (mailparser's structured view kept
|
|
123
|
+
* only one). Moved here from mailx-store/store.ts 2026-10-27 so Android
|
|
124
|
+
* (which kept only the raw value) gets the same answer. */
|
|
125
|
+
export function listUnsubscribeOf(lines) {
|
|
126
|
+
let mail = "";
|
|
127
|
+
let http = "";
|
|
128
|
+
for (const m of headerValue(lines, HEADER.listUnsubscribe).matchAll(/<([^<>]+)>/g)) {
|
|
129
|
+
const url = m[1].trim();
|
|
130
|
+
if (!mail && /^mailto:/i.test(url))
|
|
131
|
+
mail = url;
|
|
132
|
+
else if (!http && /^https?:/i.test(url))
|
|
133
|
+
http = url;
|
|
134
|
+
}
|
|
135
|
+
const oneClick = /one-?click/i.test(headerValue(lines, HEADER.listUnsubscribePost));
|
|
136
|
+
return { mail, http, oneClick };
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=headers.js.map
|
package/index.d.ts
CHANGED
|
@@ -427,20 +427,6 @@ export declare function formatAddressListForHeader(list: {
|
|
|
427
427
|
name?: string;
|
|
428
428
|
address?: string;
|
|
429
429
|
}[] | undefined): string;
|
|
430
|
-
/** The addr-spec of every entry in an address header — what SMTP RCPT TO /
|
|
431
|
-
* MAIL FROM must carry. One per comma-separated entry (commas inside quotes
|
|
432
|
-
* and angle brackets don't split): the `<…>` part when there is one, else the
|
|
433
|
-
* entry itself minus any `(comment)`. Entries without an `@` are dropped.
|
|
434
|
-
*
|
|
435
|
-
* 2026-10-25 — Claude Code (Opus 5.5), at Bob's direction. Replaces the
|
|
436
|
-
* `/[\w.+-]+@[\w.-]+/g` scan that all three send paths (desktop mailx-imap,
|
|
437
|
-
* Android android-bootstrap and sync-manager) carried as copies. That class
|
|
438
|
-
* omits characters RFC 5322 allows in a local part (`= ! # $ % & ' * / ? ^ { | } ~`),
|
|
439
|
-
* so `bernardgreenberg=msn.com@groups.io` (groups.io's relay address for a
|
|
440
|
-
* member) matched as `msn.com@groups.io`: the To header was right, the
|
|
441
|
-
* envelope was wrong, and groups.io bounced "The group 'msn.com' does not
|
|
442
|
-
* exist". Taking the addr-spec whole avoids guessing at its characters. */
|
|
443
|
-
export declare function envelopeAddresses(value: string): string[];
|
|
444
430
|
/** What an SMTP send needs from a finished RFC 822 message. */
|
|
445
431
|
export interface OutgoingEnvelope {
|
|
446
432
|
/** RCPT TO: every addr-spec in To, Cc and Bcc. */
|
|
@@ -629,6 +615,10 @@ export declare function parseInvite(ics: string): ParsedInvite | null;
|
|
|
629
615
|
* case specially").
|
|
630
616
|
*/
|
|
631
617
|
export declare function inviteHasGoogleRsvp(bodyHtml: string): boolean;
|
|
632
|
-
export {
|
|
618
|
+
export { HEADER, RECIPIENT_HEADERS, DELIVERY_HEADERS, readHeaderLines, headerValue, headerValues, headerRawValue, headerRawValues, headerSectionEnd, lineEnding, listUnsubscribeOf } from "./headers.js";
|
|
619
|
+
export type { HeaderLine } from "./headers.js";
|
|
620
|
+
export { splitAddressHeader, parseAddressList, parseAddress, envelopeAddresses, firstAddress, isValidAddress, ADDRESS_IN_TEXT, findAddressesInText } from "./address.js";
|
|
621
|
+
export type { ParsedAddress } from "./address.js";
|
|
622
|
+
export { readListIdentity, groupsIoOriginal } from "./list-identity.js";
|
|
633
623
|
export type { ListIdentity, ListHeaderLine } from "./list-identity.js";
|
|
634
624
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -221,61 +221,6 @@ export function formatAddressForHeader(name, address) {
|
|
|
221
221
|
export function formatAddressListForHeader(list) {
|
|
222
222
|
return (list || []).map(a => formatAddressForHeader(a.name, a.address || "")).join(", ");
|
|
223
223
|
}
|
|
224
|
-
/** Split an address header on top-level commas (outside quotes and angle
|
|
225
|
-
* brackets; `\` escapes inside quotes honored). */
|
|
226
|
-
function splitAddressHeader(value) {
|
|
227
|
-
const parts = [];
|
|
228
|
-
let cur = "";
|
|
229
|
-
let inAngle = false;
|
|
230
|
-
let inQuote = false;
|
|
231
|
-
for (let i = 0; i < value.length; i++) {
|
|
232
|
-
const ch = value[i];
|
|
233
|
-
if (inQuote && ch === "\\" && i + 1 < value.length) {
|
|
234
|
-
cur += ch + value[++i];
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
if (ch === '"' && !inAngle)
|
|
238
|
-
inQuote = !inQuote;
|
|
239
|
-
else if (ch === "<" && !inQuote)
|
|
240
|
-
inAngle = true;
|
|
241
|
-
else if (ch === ">" && !inQuote)
|
|
242
|
-
inAngle = false;
|
|
243
|
-
if (ch === "," && !inAngle && !inQuote) {
|
|
244
|
-
parts.push(cur);
|
|
245
|
-
cur = "";
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
cur += ch;
|
|
249
|
-
}
|
|
250
|
-
if (cur.trim())
|
|
251
|
-
parts.push(cur);
|
|
252
|
-
return parts.map(p => p.trim()).filter(Boolean);
|
|
253
|
-
}
|
|
254
|
-
/** The addr-spec of every entry in an address header — what SMTP RCPT TO /
|
|
255
|
-
* MAIL FROM must carry. One per comma-separated entry (commas inside quotes
|
|
256
|
-
* and angle brackets don't split): the `<…>` part when there is one, else the
|
|
257
|
-
* entry itself minus any `(comment)`. Entries without an `@` are dropped.
|
|
258
|
-
*
|
|
259
|
-
* 2026-10-25 — Claude Code (Opus 5.5), at Bob's direction. Replaces the
|
|
260
|
-
* `/[\w.+-]+@[\w.-]+/g` scan that all three send paths (desktop mailx-imap,
|
|
261
|
-
* Android android-bootstrap and sync-manager) carried as copies. That class
|
|
262
|
-
* omits characters RFC 5322 allows in a local part (`= ! # $ % & ' * / ? ^ { | } ~`),
|
|
263
|
-
* so `bernardgreenberg=msn.com@groups.io` (groups.io's relay address for a
|
|
264
|
-
* member) matched as `msn.com@groups.io`: the To header was right, the
|
|
265
|
-
* envelope was wrong, and groups.io bounced "The group 'msn.com' does not
|
|
266
|
-
* exist". Taking the addr-spec whole avoids guessing at its characters. */
|
|
267
|
-
export function envelopeAddresses(value) {
|
|
268
|
-
if (!value)
|
|
269
|
-
return [];
|
|
270
|
-
const out = [];
|
|
271
|
-
for (const part of splitAddressHeader(value)) {
|
|
272
|
-
const angle = /<([^<>]*)>\s*$/.exec(part);
|
|
273
|
-
const spec = (angle ? angle[1] : part.replace(/\([^()]*\)/g, "")).trim();
|
|
274
|
-
if (spec.includes("@"))
|
|
275
|
-
out.push(spec);
|
|
276
|
-
}
|
|
277
|
-
return out;
|
|
278
|
-
}
|
|
279
224
|
/** Build the SMTP envelope and the transmitted copy of an outgoing message.
|
|
280
225
|
* ONE implementation for every send path (desktop mailx-imap sendRaw, Android
|
|
281
226
|
* android-bootstrap and sync-manager), which each used to carry their own copy
|
|
@@ -288,22 +233,18 @@ export function envelopeAddresses(value) {
|
|
|
288
233
|
* Bcc is stripped from the header section only, continuation lines included,
|
|
289
234
|
* so a quoted "Bcc:" in the body is never eaten. */
|
|
290
235
|
export function outgoingEnvelope(raw, fallbackSender) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
// line is left mid-headers; the final trim covers Bcc as the last header.
|
|
304
|
-
const stripBcc = (h) => h.replace(/^Bcc:[^\n]*(\r?\n[ \t][^\n]*)*(\r?\n|$)/mi, "").replace(/\r?\n$/, "");
|
|
305
|
-
const rawToSend = headerEnd === -1 ? stripBcc(raw) : stripBcc(headerRaw) + raw.slice(headerEnd);
|
|
306
|
-
return { recipients, sender, subject: field("Subject").trim(), messageId, rawToSend };
|
|
236
|
+
// 2026-10-27: headers read by the shared headers.ts (was a local regex
|
|
237
|
+
// per field, and a `^Bcc:` regex for the strip).
|
|
238
|
+
const lines = readHeaderLines(raw);
|
|
239
|
+
const recipients = RECIPIENT_HEADERS.flatMap(name => headerValues(lines, name).flatMap(envelopeAddresses));
|
|
240
|
+
const sender = envelopeAddresses(headerValue(lines, HEADER.from))[0] || fallbackSender;
|
|
241
|
+
const messageId = headerValue(lines, HEADER.messageId).match(/<[^<>]+>/)?.[0] ?? "";
|
|
242
|
+
// Bcc leaves the transmitted copy: the header section is rebuilt from the
|
|
243
|
+
// fields read above minus Bcc, each line byte-for-byte as it came in.
|
|
244
|
+
const end = headerSectionEnd(raw);
|
|
245
|
+
const kept = lines.filter(h => h.key !== HEADER.bcc).map(h => h.line).join(lineEnding(raw));
|
|
246
|
+
const rawToSend = end === -1 ? kept : kept + raw.slice(end);
|
|
247
|
+
return { recipients, sender, subject: headerValue(lines, HEADER.subject), messageId, rawToSend };
|
|
307
248
|
}
|
|
308
249
|
/** Entries of an address header that a reader would see as an address with
|
|
309
250
|
* no `@` — e.g. the "Frankston" in `Frankston, Bob <x@y>`. Empty when sound. */
|
|
@@ -667,8 +608,9 @@ export function displayNameClaimsOtherAddress(name, address) {
|
|
|
667
608
|
return null;
|
|
668
609
|
const real = (address || "").trim().toLowerCase();
|
|
669
610
|
// Deliberately loose: anything shaped like local@domain.tld inside the name.
|
|
670
|
-
|
|
671
|
-
|
|
611
|
+
// 2026-10-26 — Claude Code (Opus 5.5): shared free-text pattern (address.ts).
|
|
612
|
+
const m = findAddressesInText(n);
|
|
613
|
+
if (!m.length)
|
|
672
614
|
return null;
|
|
673
615
|
const realDomain = real.split("@").pop() || "";
|
|
674
616
|
for (const claimed of m) {
|
|
@@ -907,6 +849,12 @@ export function inviteHasGoogleRsvp(bodyHtml) {
|
|
|
907
849
|
return false;
|
|
908
850
|
return /calendar\.google\.com\/calendar\/event\?action=RESPOND/i.test(bodyHtml);
|
|
909
851
|
}
|
|
852
|
+
// Header parsing: one reader for the whole app, field names in HEADER.
|
|
853
|
+
import { HEADER, RECIPIENT_HEADERS, readHeaderLines, headerValue, headerValues, headerSectionEnd, lineEnding } from "./headers.js";
|
|
854
|
+
export { HEADER, RECIPIENT_HEADERS, DELIVERY_HEADERS, readHeaderLines, headerValue, headerValues, headerRawValue, headerRawValues, headerSectionEnd, lineEnding, listUnsubscribeOf } from "./headers.js";
|
|
855
|
+
// Address parsing: one splitter, one notion of "is this an address".
|
|
856
|
+
import { splitAddressHeader, envelopeAddresses, findAddressesInText } from "./address.js";
|
|
857
|
+
export { splitAddressHeader, parseAddressList, parseAddress, envelopeAddresses, firstAddress, isValidAddress, ADDRESS_IN_TEXT, findAddressesInText } from "./address.js";
|
|
910
858
|
// Delivery address + mailing-list identity for reply's From (desktop + Android).
|
|
911
|
-
export { readListIdentity } from "./list-identity.js";
|
|
859
|
+
export { readListIdentity, groupsIoOriginal } from "./list-identity.js";
|
|
912
860
|
//# sourceMappingURL=index.js.map
|
package/list-identity.d.ts
CHANGED
|
@@ -20,11 +20,9 @@
|
|
|
20
20
|
* two cannot drift apart again. Pure: header lines and body text in, strings
|
|
21
21
|
* out — no I/O, browser-safe.
|
|
22
22
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
line: string;
|
|
27
|
-
}
|
|
23
|
+
import { type HeaderLine } from "./headers.js";
|
|
24
|
+
/** Kept as an alias so existing imports keep working; the shape lives in headers.ts. */
|
|
25
|
+
export type ListHeaderLine = HeaderLine;
|
|
28
26
|
export interface ListIdentity {
|
|
29
27
|
/** Address the RECEIVING server recorded delivering this copy to
|
|
30
28
|
* (Delivered-To / X-Original-To / Envelope-To), or "". A mailing list's
|
|
@@ -48,6 +46,16 @@ export interface ListIdentity {
|
|
|
48
46
|
* real address is in Resent-From. (2026-10-26, Claude Code (Opus 5.5).) */
|
|
49
47
|
listAuthor: string;
|
|
50
48
|
}
|
|
49
|
+
/** groups.io's stand-in for a member's address, `local=domain@groups.io`,
|
|
50
|
+
* decoded back to `local@domain`; "" for anything else. groups.io refuses
|
|
51
|
+
* mail sent to the stand-in and names the decoded form as the one to use
|
|
52
|
+
* ("To reply to this person, send your message to bernardgreenberg@msn.com").
|
|
53
|
+
* The LAST `=` separates local part from domain. Real groups.io addresses
|
|
54
|
+
* (`group@`, `group+owner@`) carry no `=`, so they pass through.
|
|
55
|
+
* (2026-10-27, Claude Code (Opus 5.5): reply needs this for EVERY such
|
|
56
|
+
* address in To/Cc, not only the From — Bernard Cc's himself, and groups.io
|
|
57
|
+
* rewrites that Cc too.) */
|
|
58
|
+
export declare function groupsIoOriginal(address: string): string;
|
|
51
59
|
/** Read the delivery and list facts out of a parsed message. */
|
|
52
60
|
export declare function readListIdentity(headerLines: readonly ListHeaderLine[], bodyText: string, bodyHtml: string): ListIdentity;
|
|
53
61
|
//# sourceMappingURL=list-identity.d.ts.map
|
package/list-identity.js
CHANGED
|
@@ -20,21 +20,12 @@
|
|
|
20
20
|
* two cannot drift apart again. Pure: header lines and body text in, strings
|
|
21
21
|
* out — no I/O, browser-safe.
|
|
22
22
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
for (const h of lines || []) {
|
|
28
|
-
if ((h.key || "").toLowerCase() !== want)
|
|
29
|
-
continue;
|
|
30
|
-
const colon = h.line.indexOf(":");
|
|
31
|
-
if (colon >= 0)
|
|
32
|
-
out.push(h.line.slice(colon + 1).replace(/\r?\n[ \t]+/g, " ").trim());
|
|
33
|
-
}
|
|
34
|
-
return out;
|
|
35
|
-
}
|
|
23
|
+
import { firstAddress } from "./address.js";
|
|
24
|
+
import { HEADER, DELIVERY_HEADERS, headerValues } from "./headers.js";
|
|
25
|
+
// Header reading: shared headerValues (headers.ts), 2026-10-27.
|
|
26
|
+
/** First addr-spec in a header value, lowercased (shared parser: address.ts). */
|
|
36
27
|
function bareAddress(value) {
|
|
37
|
-
return (
|
|
28
|
+
return firstAddress(value || "").toLowerCase();
|
|
38
29
|
}
|
|
39
30
|
/** qmail/ezmlm, and groups.io after them, stamp `Delivered-To: mailing list
|
|
40
31
|
* <addr>` on the copy they fan out, to catch mail loops. It names the LIST,
|
|
@@ -48,12 +39,25 @@ const LIST_DELIVERED_TO = /^mailing list\s/i;
|
|
|
48
39
|
* optional anchor tail, then blanks, then the bracket), so a long body with
|
|
49
40
|
* no footer fails in a single pass. */
|
|
50
41
|
const GROUPS_IO_SUBSCRIBER = /groups\.io\/g\/[^\s"'<>\/]+\/leave\/[^\s"'<>]*(?:["']>Unsubscribe<\/a>)?\s*\[([^\s\[\]<>@]+@[^\s\[\]<>@]+)\]/i;
|
|
42
|
+
/** groups.io's stand-in for a member's address, `local=domain@groups.io`,
|
|
43
|
+
* decoded back to `local@domain`; "" for anything else. groups.io refuses
|
|
44
|
+
* mail sent to the stand-in and names the decoded form as the one to use
|
|
45
|
+
* ("To reply to this person, send your message to bernardgreenberg@msn.com").
|
|
46
|
+
* The LAST `=` separates local part from domain. Real groups.io addresses
|
|
47
|
+
* (`group@`, `group+owner@`) carry no `=`, so they pass through.
|
|
48
|
+
* (2026-10-27, Claude Code (Opus 5.5): reply needs this for EVERY such
|
|
49
|
+
* address in To/Cc, not only the From — Bernard Cc's himself, and groups.io
|
|
50
|
+
* rewrites that Cc too.) */
|
|
51
|
+
export function groupsIoOriginal(address) {
|
|
52
|
+
const m = (address || "").trim().match(/^(.+)=([^=@\s]+)@groups\.io$/i);
|
|
53
|
+
return m ? `${m[1]}@${m[2]}` : "";
|
|
54
|
+
}
|
|
51
55
|
/** Read the delivery and list facts out of a parsed message. */
|
|
52
56
|
export function readListIdentity(headerLines, bodyText, bodyHtml) {
|
|
53
57
|
// Receiving-server headers: each agent PREPENDS its own, so the first
|
|
54
58
|
// that is not a list's loop marker is the final delivery to this mailbox.
|
|
55
59
|
let deliveredTo = "";
|
|
56
|
-
for (const key of
|
|
60
|
+
for (const key of DELIVERY_HEADERS) {
|
|
57
61
|
for (const value of headerValues(headerLines, key)) {
|
|
58
62
|
if (LIST_DELIVERED_TO.test(value))
|
|
59
63
|
continue;
|
|
@@ -65,13 +69,13 @@ export function readListIdentity(headerLines, bodyText, bodyHtml) {
|
|
|
65
69
|
if (deliveredTo)
|
|
66
70
|
break;
|
|
67
71
|
}
|
|
68
|
-
const rawListId = headerValues(headerLines,
|
|
72
|
+
const rawListId = headerValues(headerLines, HEADER.listId)[0] || "";
|
|
69
73
|
const listId = ((rawListId.match(/<([^>]+)>/)?.[1]) || rawListId).trim().toLowerCase();
|
|
70
74
|
if (!listId)
|
|
71
75
|
return { deliveredTo, listId: "", listAddresses: [], listSubscriber: "", listAuthor: "" };
|
|
72
|
-
const listPost = (headerValues(headerLines,
|
|
76
|
+
const listPost = (headerValues(headerLines, HEADER.listPost)[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
|
|
73
77
|
const listAddresses = [];
|
|
74
|
-
for (const candidate of [listPost, headerValues(headerLines,
|
|
78
|
+
for (const candidate of [listPost, headerValues(headerLines, HEADER.replyTo)[0], headerValues(headerLines, HEADER.sender)[0]]) {
|
|
75
79
|
const addr = bareAddress(candidate || "");
|
|
76
80
|
if (addr && !listAddresses.includes(addr))
|
|
77
81
|
listAddresses.push(addr);
|
|
@@ -87,10 +91,9 @@ export function readListIdentity(headerLines, bodyText, bodyHtml) {
|
|
|
87
91
|
// A rewritten From is `local=domain@groups.io`. Resent-From carries
|
|
88
92
|
// the original; failing that, the rewrite itself encodes it (the
|
|
89
93
|
// LAST `=` separates local part from domain).
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
listAuthor = bareAddress(headerValues(headerLines, "resent-from")[0] || "") || `${rewritten[1]}@${rewritten[2]}`;
|
|
94
|
+
const decoded = groupsIoOriginal(bareAddress(headerValues(headerLines, HEADER.from)[0] || ""));
|
|
95
|
+
if (decoded)
|
|
96
|
+
listAuthor = bareAddress(headerValues(headerLines, HEADER.resentFrom)[0] || "") || decoded;
|
|
94
97
|
}
|
|
95
98
|
return { deliveredTo, listId, listAddresses, listSubscriber, listAuthor };
|
|
96
99
|
}
|
package/package.json
CHANGED
package/trust.js
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* be you. Forwarded mail fails SPF constantly, and flagging that would be
|
|
29
29
|
* exactly the cry-wolf banner this is trying not to be.
|
|
30
30
|
*/
|
|
31
|
+
import { firstAddress } from "./address.js";
|
|
32
|
+
import { HEADER, headerRawValue, headerRawValues } from "./headers.js";
|
|
31
33
|
/** What the reader can vouch for, and the allowlist.jsonc key each one is
|
|
32
34
|
* written to. One table, shared by the desktop service and the Android
|
|
33
35
|
* web-service, so "trust X" cannot land in different keys on the two
|
|
@@ -43,33 +45,19 @@ export const TRUST_LIST_KEYS = {
|
|
|
43
45
|
// 2026-09-30 — see TrustInput.trustedLinkHosts.
|
|
44
46
|
linkhost: "trustedLinkHosts",
|
|
45
47
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/** All values of a repeated header. */
|
|
58
|
-
function headerAll(lines, name) {
|
|
59
|
-
const want = name.toLowerCase();
|
|
60
|
-
const out = [];
|
|
61
|
-
for (const h of lines) {
|
|
62
|
-
if ((h.key || "").toLowerCase() !== want)
|
|
63
|
-
continue;
|
|
64
|
-
const colon = h.line.indexOf(":");
|
|
65
|
-
if (colon >= 0)
|
|
66
|
-
out.push(h.line.slice(colon + 1).trim());
|
|
67
|
-
}
|
|
68
|
-
return out;
|
|
69
|
-
}
|
|
48
|
+
// 2026-10-27 — Claude Code (Opus 5.5): header reading moved to the shared
|
|
49
|
+
// headers.ts (one parse for the whole app); these names stay as the local
|
|
50
|
+
// spelling the checks below use.
|
|
51
|
+
// The RAW readers: trust parses some fields by line (X-Spam-Report) and
|
|
52
|
+
// unfolds the rest itself (see unfold below), exactly as before the move.
|
|
53
|
+
const header = (lines, name) => headerRawValue(lines, name);
|
|
54
|
+
const headerAll = (lines, name) => headerRawValues(lines, name);
|
|
55
|
+
// 2026-10-26 — Claude Code (Opus 5.5): the addr-spec comes from the shared
|
|
56
|
+
// parser (address.ts) instead of a first-`@`-token scan. The scan took the
|
|
57
|
+
// first token holding an `@` ANYWHERE in the value, so `"ceo@bank.com"
|
|
58
|
+
// <x@evil.example>` read as ceo@bank.com — the display name, not the address.
|
|
70
59
|
function bare(addr) {
|
|
71
|
-
|
|
72
|
-
return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
60
|
+
return (firstAddress(addr || "") || addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
73
61
|
}
|
|
74
62
|
/** Do two hostnames belong to the same organisation?
|
|
75
63
|
*
|
|
@@ -208,7 +196,7 @@ function classifyRule(name, dmarcFailed, listRelayed = false) {
|
|
|
208
196
|
* author (Google Calendar signs d=google.com on an invitation From the
|
|
209
197
|
* organizer), which is exactly what makes it an intermediary. */
|
|
210
198
|
function dkimSigners(input) {
|
|
211
|
-
const auth = headerAll(input.headerLines,
|
|
199
|
+
const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
|
|
212
200
|
const out = [];
|
|
213
201
|
for (const part of auth.split(";")) {
|
|
214
202
|
if (!/\bdkim=pass\b/i.test(part))
|
|
@@ -232,7 +220,7 @@ function alignedPass(input) {
|
|
|
232
220
|
return "";
|
|
233
221
|
if (dkimSigners(input).some(d => sameOrg(d, fromDomain)))
|
|
234
222
|
return "dkim";
|
|
235
|
-
const auth = headerAll(input.headerLines,
|
|
223
|
+
const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
|
|
236
224
|
for (const part of auth.split(";")) {
|
|
237
225
|
if (!/\bspf=pass\b/i.test(part))
|
|
238
226
|
continue;
|
|
@@ -296,7 +284,7 @@ function provedSenderVia(input) {
|
|
|
296
284
|
* and `trustedLists` never counts on such a message. */
|
|
297
285
|
function listProof(input) {
|
|
298
286
|
const none = { id: "", proved: false, host: "" };
|
|
299
|
-
const raw = header(input.headerLines,
|
|
287
|
+
const raw = header(input.headerLines, HEADER.listId);
|
|
300
288
|
if (!raw)
|
|
301
289
|
return none;
|
|
302
290
|
// `Meeting Announcements <ieee-cs.mit.edu>` or bare `<ieee-cs.mit.edu>`;
|
|
@@ -304,9 +292,9 @@ function listProof(input) {
|
|
|
304
292
|
const id = (raw.match(/<([^>]+)>/)?.[1] || raw).trim().toLowerCase();
|
|
305
293
|
if (!id)
|
|
306
294
|
return none;
|
|
307
|
-
const senderDomain = (bare(header(input.headerLines,
|
|
295
|
+
const senderDomain = (bare(header(input.headerLines, HEADER.sender)).split("@")[1] || "").toLowerCase();
|
|
308
296
|
const hosts = [senderDomain, id].filter(Boolean);
|
|
309
|
-
const auth = headerAll(input.headerLines,
|
|
297
|
+
const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
|
|
310
298
|
for (const part of auth.split(";")) {
|
|
311
299
|
if (/\bspf=pass\b/i.test(part)) {
|
|
312
300
|
// iecc writes `spf.mailfrom=user@host`, Microsoft `smtp.mailfrom=host`.
|
|
@@ -324,7 +312,7 @@ function listProof(input) {
|
|
|
324
312
|
return { id, proved: false, host: "" };
|
|
325
313
|
}
|
|
326
314
|
function dmarcProof(input) {
|
|
327
|
-
const auth = headerAll(input.headerLines,
|
|
315
|
+
const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
|
|
328
316
|
if (!auth)
|
|
329
317
|
return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
|
|
330
318
|
// What the server caught failing, by name. An outright authentication
|
|
@@ -429,8 +417,8 @@ function linkHostTrusted(host, trusted) {
|
|
|
429
417
|
return trusted.some(t => h === t || h.endsWith("." + t));
|
|
430
418
|
}
|
|
431
419
|
function analyzeServerSpam(input) {
|
|
432
|
-
const status = unfold(header(input.headerLines,
|
|
433
|
-
const flag = header(input.headerLines,
|
|
420
|
+
const status = unfold(header(input.headerLines, HEADER.xSpamStatus));
|
|
421
|
+
const flag = header(input.headerLines, HEADER.xSpamFlag);
|
|
434
422
|
if (!status && !flag)
|
|
435
423
|
return null;
|
|
436
424
|
const score = Number(status.match(/score=(-?[\d.]+)/)?.[1]);
|
|
@@ -451,7 +439,7 @@ function analyzeServerSpam(input) {
|
|
|
451
439
|
const trustedLists = (input.trustedLists || []).map(l => (l || "").trim().toLowerCase());
|
|
452
440
|
const list = { ...listFound, trusted: listFound.proved && trustedLists.includes(listFound.id) };
|
|
453
441
|
const authFailures = list.proved && !dmarc.failed ? dmarc.authFailures.filter(f => f !== "DKIM") : dmarc.authFailures;
|
|
454
|
-
const report = parseSpamReport(header(input.headerLines,
|
|
442
|
+
const report = parseSpamReport(header(input.headerLines, HEADER.xSpamReport));
|
|
455
443
|
// Everything up to the first lowercase key=value — SpamAssassin's trailing
|
|
456
444
|
// shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
|
|
457
445
|
// rather than on whitespace is what keeps "...,WORD_INVIS shortcircuit=no"
|
|
@@ -638,7 +626,7 @@ function serverSpamVerdict(input) {
|
|
|
638
626
|
// The party that sent this on the author's behalf, when it signed: the
|
|
639
627
|
// Sender: header names it and a verified signature backs the claim. That
|
|
640
628
|
// is the one the reader can sensibly vouch for as an intermediary.
|
|
641
|
-
const senderDomain = (bare(header(input.headerLines,
|
|
629
|
+
const senderDomain = (bare(header(input.headerLines, HEADER.sender)).split("@")[1] || "").toLowerCase();
|
|
642
630
|
const relay = senderDomain ? a.signers.find(s => sameOrg(s, senderDomain)) : "";
|
|
643
631
|
const via = relay && !a.proved ? { via: relay } : {};
|
|
644
632
|
// Say WHICH party and HOW, in the finding itself (Bob 2026-09-08: "the
|
|
@@ -766,7 +754,7 @@ function selfSpoof(input) {
|
|
|
766
754
|
const own = new Set(input.ownAddresses.map(bare).filter(Boolean));
|
|
767
755
|
if (!own.has(from))
|
|
768
756
|
return null;
|
|
769
|
-
const auth = headerAll(input.headerLines,
|
|
757
|
+
const auth = headerAll(input.headerLines, HEADER.authenticationResults).join(" ; ");
|
|
770
758
|
if (!auth)
|
|
771
759
|
return null;
|
|
772
760
|
// The From domain passed DMARC, aligned, at the receiving server: the
|
|
@@ -800,7 +788,7 @@ function selfSpoof(input) {
|
|
|
800
788
|
if (!failed.length) {
|
|
801
789
|
if (/\b(spf|dkim|dmarc)=pass\b/i.test(auth))
|
|
802
790
|
return null;
|
|
803
|
-
const envelopeAddr = bare(header(input.headerLines,
|
|
791
|
+
const envelopeAddr = bare(header(input.headerLines, HEADER.returnPath));
|
|
804
792
|
const note = envelopeAddr && envelopeAddr !== from ? `; envelope sender ${envelopeAddr}` : "";
|
|
805
793
|
return {
|
|
806
794
|
id: "self-spoof",
|
|
@@ -811,7 +799,7 @@ function selfSpoof(input) {
|
|
|
811
799
|
}
|
|
812
800
|
// Name the envelope sender when it disagrees — that is the address that
|
|
813
801
|
// actually sent this, and seeing it beside the claim is the whole story.
|
|
814
|
-
const envelope = bare(header(input.headerLines,
|
|
802
|
+
const envelope = bare(header(input.headerLines, HEADER.returnPath));
|
|
815
803
|
const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
|
|
816
804
|
return {
|
|
817
805
|
id: "self-spoof",
|
|
@@ -1091,7 +1079,7 @@ function listRewrittenAuthor(input, fromDomain) {
|
|
|
1091
1079
|
const encoded = local.match(/^([^=]+)=([a-z0-9.-]+\.[a-z]{2,})$/i);
|
|
1092
1080
|
if (encoded)
|
|
1093
1081
|
return `${encoded[1]}@${encoded[2]}`.toLowerCase();
|
|
1094
|
-
for (const name of [
|
|
1082
|
+
for (const name of [HEADER.xOriginalFrom, HEADER.resentFrom]) {
|
|
1095
1083
|
const addr = bare(header(input.headerLines, name));
|
|
1096
1084
|
if (addr.includes("@"))
|
|
1097
1085
|
return addr;
|