@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 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
- return /^[^@\s][^@]*@[^@\s]+(\s|$)|<[^>]+@[^>]+>/.test(token) || /^[^\s<>@]+@[^\s<>@]+$/.test(token);
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
- const m = token.match(/<([^>]+)>/);
49
- if (m)
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 { readListIdentity } from "./list-identity.js";
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
- const headerEnd = raw.search(/\r?\n\r?\n/);
292
- const headerRaw = headerEnd === -1 ? raw : raw.slice(0, headerEnd);
293
- const headerSec = headerRaw.replace(/\r?\n[ \t]+/g, " ");
294
- const field = (name) => headerSec.match(new RegExp(`^${name}:[ \\t]*(.*)$`, "mi"))?.[1] ?? "";
295
- const recipients = [
296
- ...envelopeAddresses(field("To")),
297
- ...envelopeAddresses(field("Cc")),
298
- ...envelopeAddresses(field("Bcc")),
299
- ];
300
- const sender = envelopeAddresses(field("From"))[0] || fallbackSender;
301
- const messageId = field("Message-ID").match(/<[^<>]+>/)?.[0] ?? "";
302
- // The `(\r?\n|$)` tail takes the line ending with the header so no blank
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
- const m = n.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g);
671
- if (!m)
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
@@ -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
- /** A header line as mailparser's `headerLines` gives it: lowercase key, raw `Key: value` line. */
24
- export interface ListHeaderLine {
25
- key: string;
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
- /** Value of every occurrence of a header, unfolded, in message order. */
24
- function headerValues(lines, name) {
25
- const want = name.toLowerCase();
26
- const out = [];
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 ((value || "").match(/[^\s<>,;:"]+@[^\s<>,;:"]+/)?.[0] || "").toLowerCase();
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 ["delivered-to", "x-original-to", "envelope-to"]) {
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, "list-id")[0] || "";
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, "list-post")[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
76
+ const listPost = (headerValues(headerLines, HEADER.listPost)[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
73
77
  const listAddresses = [];
74
- for (const candidate of [listPost, headerValues(headerLines, "reply-to")[0], headerValues(headerLines, "sender")[0]]) {
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 from = bareAddress(headerValues(headerLines, "from")[0] || "");
91
- const rewritten = from.match(/^(.+)=([^=@]+)@groups\.io$/);
92
- if (rewritten)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.111",
3
+ "version": "0.1.113",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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
- /** First value of a header, case-insensitive. */
47
- function header(lines, name) {
48
- const want = name.toLowerCase();
49
- for (const h of lines) {
50
- if ((h.key || "").toLowerCase() !== want)
51
- continue;
52
- const colon = h.line.indexOf(":");
53
- return colon < 0 ? "" : h.line.slice(colon + 1).trim();
54
- }
55
- return "";
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
- const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
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, "authentication-results")[0] || "";
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, "authentication-results")[0] || "";
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, "list-id");
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, "sender")).split("@")[1] || "").toLowerCase();
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, "authentication-results")[0] || "";
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, "authentication-results")[0] || "";
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, "x-spam-status"));
433
- const flag = header(input.headerLines, "x-spam-flag");
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, "x-spam-report"));
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, "sender")).split("@")[1] || "").toLowerCase();
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, "authentication-results").join(" ; ");
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, "return-path"));
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, "return-path"));
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 ["x-original-from", "resent-from"]) {
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;