@bobfrankston/mailx-types 0.1.112 → 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/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
@@ -615,8 +615,10 @@ export declare function parseInvite(ics: string): ParsedInvite | null;
615
615
  * case specially").
616
616
  */
617
617
  export declare function inviteHasGoogleRsvp(bodyHtml: string): boolean;
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";
618
620
  export { splitAddressHeader, parseAddressList, parseAddress, envelopeAddresses, firstAddress, isValidAddress, ADDRESS_IN_TEXT, findAddressesInText } from "./address.js";
619
621
  export type { ParsedAddress } from "./address.js";
620
- export { readListIdentity } from "./list-identity.js";
622
+ export { readListIdentity, groupsIoOriginal } from "./list-identity.js";
621
623
  export type { ListIdentity, ListHeaderLine } from "./list-identity.js";
622
624
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -233,22 +233,18 @@ export function formatAddressListForHeader(list) {
233
233
  * Bcc is stripped from the header section only, continuation lines included,
234
234
  * so a quoted "Bcc:" in the body is never eaten. */
235
235
  export function outgoingEnvelope(raw, fallbackSender) {
236
- const headerEnd = raw.search(/\r?\n\r?\n/);
237
- const headerRaw = headerEnd === -1 ? raw : raw.slice(0, headerEnd);
238
- const headerSec = headerRaw.replace(/\r?\n[ \t]+/g, " ");
239
- const field = (name) => headerSec.match(new RegExp(`^${name}:[ \\t]*(.*)$`, "mi"))?.[1] ?? "";
240
- const recipients = [
241
- ...envelopeAddresses(field("To")),
242
- ...envelopeAddresses(field("Cc")),
243
- ...envelopeAddresses(field("Bcc")),
244
- ];
245
- const sender = envelopeAddresses(field("From"))[0] || fallbackSender;
246
- const messageId = field("Message-ID").match(/<[^<>]+>/)?.[0] ?? "";
247
- // The `(\r?\n|$)` tail takes the line ending with the header so no blank
248
- // line is left mid-headers; the final trim covers Bcc as the last header.
249
- const stripBcc = (h) => h.replace(/^Bcc:[^\n]*(\r?\n[ \t][^\n]*)*(\r?\n|$)/mi, "").replace(/\r?\n$/, "");
250
- const rawToSend = headerEnd === -1 ? stripBcc(raw) : stripBcc(headerRaw) + raw.slice(headerEnd);
251
- 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 };
252
248
  }
253
249
  /** Entries of an address header that a reader would see as an address with
254
250
  * no `@` — e.g. the "Frankston" in `Frankston, Bob <x@y>`. Empty when sound. */
@@ -853,9 +849,12 @@ export function inviteHasGoogleRsvp(bodyHtml) {
853
849
  return false;
854
850
  return /calendar\.google\.com\/calendar\/event\?action=RESPOND/i.test(bodyHtml);
855
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";
856
855
  // Address parsing: one splitter, one notion of "is this an address".
857
856
  import { splitAddressHeader, envelopeAddresses, findAddressesInText } from "./address.js";
858
857
  export { splitAddressHeader, parseAddressList, parseAddress, envelopeAddresses, firstAddress, isValidAddress, ADDRESS_IN_TEXT, findAddressesInText } from "./address.js";
859
858
  // Delivery address + mailing-list identity for reply's From (desktop + Android).
860
- export { readListIdentity } from "./list-identity.js";
859
+ export { readListIdentity, groupsIoOriginal } from "./list-identity.js";
861
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
@@ -21,19 +21,8 @@
21
21
  * out — no I/O, browser-safe.
22
22
  */
23
23
  import { firstAddress } from "./address.js";
24
- /** Value of every occurrence of a header, unfolded, in message order. */
25
- function headerValues(lines, name) {
26
- const want = name.toLowerCase();
27
- const out = [];
28
- for (const h of lines || []) {
29
- if ((h.key || "").toLowerCase() !== want)
30
- continue;
31
- const colon = h.line.indexOf(":");
32
- if (colon >= 0)
33
- out.push(h.line.slice(colon + 1).replace(/\r?\n[ \t]+/g, " ").trim());
34
- }
35
- return out;
36
- }
24
+ import { HEADER, DELIVERY_HEADERS, headerValues } from "./headers.js";
25
+ // Header reading: shared headerValues (headers.ts), 2026-10-27.
37
26
  /** First addr-spec in a header value, lowercased (shared parser: address.ts). */
38
27
  function bareAddress(value) {
39
28
  return firstAddress(value || "").toLowerCase();
@@ -50,12 +39,25 @@ const LIST_DELIVERED_TO = /^mailing list\s/i;
50
39
  * optional anchor tail, then blanks, then the bracket), so a long body with
51
40
  * no footer fails in a single pass. */
52
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
+ }
53
55
  /** Read the delivery and list facts out of a parsed message. */
54
56
  export function readListIdentity(headerLines, bodyText, bodyHtml) {
55
57
  // Receiving-server headers: each agent PREPENDS its own, so the first
56
58
  // that is not a list's loop marker is the final delivery to this mailbox.
57
59
  let deliveredTo = "";
58
- for (const key of ["delivered-to", "x-original-to", "envelope-to"]) {
60
+ for (const key of DELIVERY_HEADERS) {
59
61
  for (const value of headerValues(headerLines, key)) {
60
62
  if (LIST_DELIVERED_TO.test(value))
61
63
  continue;
@@ -67,13 +69,13 @@ export function readListIdentity(headerLines, bodyText, bodyHtml) {
67
69
  if (deliveredTo)
68
70
  break;
69
71
  }
70
- const rawListId = headerValues(headerLines, "list-id")[0] || "";
72
+ const rawListId = headerValues(headerLines, HEADER.listId)[0] || "";
71
73
  const listId = ((rawListId.match(/<([^>]+)>/)?.[1]) || rawListId).trim().toLowerCase();
72
74
  if (!listId)
73
75
  return { deliveredTo, listId: "", listAddresses: [], listSubscriber: "", listAuthor: "" };
74
- const listPost = (headerValues(headerLines, "list-post")[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
76
+ const listPost = (headerValues(headerLines, HEADER.listPost)[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
75
77
  const listAddresses = [];
76
- 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]]) {
77
79
  const addr = bareAddress(candidate || "");
78
80
  if (addr && !listAddresses.includes(addr))
79
81
  listAddresses.push(addr);
@@ -89,10 +91,9 @@ export function readListIdentity(headerLines, bodyText, bodyHtml) {
89
91
  // A rewritten From is `local=domain@groups.io`. Resent-From carries
90
92
  // the original; failing that, the rewrite itself encodes it (the
91
93
  // LAST `=` separates local part from domain).
92
- const from = bareAddress(headerValues(headerLines, "from")[0] || "");
93
- const rewritten = from.match(/^(.+)=([^=@]+)@groups\.io$/);
94
- if (rewritten)
95
- 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;
96
97
  }
97
98
  return { deliveredTo, listId, listAddresses, listSubscriber, listAuthor };
98
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.js CHANGED
@@ -29,6 +29,7 @@
29
29
  * exactly the cry-wolf banner this is trying not to be.
30
30
  */
31
31
  import { firstAddress } from "./address.js";
32
+ import { HEADER, headerRawValue, headerRawValues } from "./headers.js";
32
33
  /** What the reader can vouch for, and the allowlist.jsonc key each one is
33
34
  * written to. One table, shared by the desktop service and the Android
34
35
  * web-service, so "trust X" cannot land in different keys on the two
@@ -44,30 +45,13 @@ export const TRUST_LIST_KEYS = {
44
45
  // 2026-09-30 — see TrustInput.trustedLinkHosts.
45
46
  linkhost: "trustedLinkHosts",
46
47
  };
47
- /** First value of a header, case-insensitive. */
48
- function header(lines, name) {
49
- const want = name.toLowerCase();
50
- for (const h of lines) {
51
- if ((h.key || "").toLowerCase() !== want)
52
- continue;
53
- const colon = h.line.indexOf(":");
54
- return colon < 0 ? "" : h.line.slice(colon + 1).trim();
55
- }
56
- return "";
57
- }
58
- /** All values of a repeated header. */
59
- function headerAll(lines, name) {
60
- const want = name.toLowerCase();
61
- const out = [];
62
- for (const h of lines) {
63
- if ((h.key || "").toLowerCase() !== want)
64
- continue;
65
- const colon = h.line.indexOf(":");
66
- if (colon >= 0)
67
- out.push(h.line.slice(colon + 1).trim());
68
- }
69
- return out;
70
- }
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);
71
55
  // 2026-10-26 — Claude Code (Opus 5.5): the addr-spec comes from the shared
72
56
  // parser (address.ts) instead of a first-`@`-token scan. The scan took the
73
57
  // first token holding an `@` ANYWHERE in the value, so `"ceo@bank.com"
@@ -212,7 +196,7 @@ function classifyRule(name, dmarcFailed, listRelayed = false) {
212
196
  * author (Google Calendar signs d=google.com on an invitation From the
213
197
  * organizer), which is exactly what makes it an intermediary. */
214
198
  function dkimSigners(input) {
215
- const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
199
+ const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
216
200
  const out = [];
217
201
  for (const part of auth.split(";")) {
218
202
  if (!/\bdkim=pass\b/i.test(part))
@@ -236,7 +220,7 @@ function alignedPass(input) {
236
220
  return "";
237
221
  if (dkimSigners(input).some(d => sameOrg(d, fromDomain)))
238
222
  return "dkim";
239
- const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
223
+ const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
240
224
  for (const part of auth.split(";")) {
241
225
  if (!/\bspf=pass\b/i.test(part))
242
226
  continue;
@@ -300,7 +284,7 @@ function provedSenderVia(input) {
300
284
  * and `trustedLists` never counts on such a message. */
301
285
  function listProof(input) {
302
286
  const none = { id: "", proved: false, host: "" };
303
- const raw = header(input.headerLines, "list-id");
287
+ const raw = header(input.headerLines, HEADER.listId);
304
288
  if (!raw)
305
289
  return none;
306
290
  // `Meeting Announcements <ieee-cs.mit.edu>` or bare `<ieee-cs.mit.edu>`;
@@ -308,9 +292,9 @@ function listProof(input) {
308
292
  const id = (raw.match(/<([^>]+)>/)?.[1] || raw).trim().toLowerCase();
309
293
  if (!id)
310
294
  return none;
311
- const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
295
+ const senderDomain = (bare(header(input.headerLines, HEADER.sender)).split("@")[1] || "").toLowerCase();
312
296
  const hosts = [senderDomain, id].filter(Boolean);
313
- const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
297
+ const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
314
298
  for (const part of auth.split(";")) {
315
299
  if (/\bspf=pass\b/i.test(part)) {
316
300
  // iecc writes `spf.mailfrom=user@host`, Microsoft `smtp.mailfrom=host`.
@@ -328,7 +312,7 @@ function listProof(input) {
328
312
  return { id, proved: false, host: "" };
329
313
  }
330
314
  function dmarcProof(input) {
331
- const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
315
+ const auth = headerAll(input.headerLines, HEADER.authenticationResults)[0] || "";
332
316
  if (!auth)
333
317
  return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
334
318
  // What the server caught failing, by name. An outright authentication
@@ -433,8 +417,8 @@ function linkHostTrusted(host, trusted) {
433
417
  return trusted.some(t => h === t || h.endsWith("." + t));
434
418
  }
435
419
  function analyzeServerSpam(input) {
436
- const status = unfold(header(input.headerLines, "x-spam-status"));
437
- 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);
438
422
  if (!status && !flag)
439
423
  return null;
440
424
  const score = Number(status.match(/score=(-?[\d.]+)/)?.[1]);
@@ -455,7 +439,7 @@ function analyzeServerSpam(input) {
455
439
  const trustedLists = (input.trustedLists || []).map(l => (l || "").trim().toLowerCase());
456
440
  const list = { ...listFound, trusted: listFound.proved && trustedLists.includes(listFound.id) };
457
441
  const authFailures = list.proved && !dmarc.failed ? dmarc.authFailures.filter(f => f !== "DKIM") : dmarc.authFailures;
458
- const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
442
+ const report = parseSpamReport(header(input.headerLines, HEADER.xSpamReport));
459
443
  // Everything up to the first lowercase key=value — SpamAssassin's trailing
460
444
  // shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
461
445
  // rather than on whitespace is what keeps "...,WORD_INVIS shortcircuit=no"
@@ -642,7 +626,7 @@ function serverSpamVerdict(input) {
642
626
  // The party that sent this on the author's behalf, when it signed: the
643
627
  // Sender: header names it and a verified signature backs the claim. That
644
628
  // is the one the reader can sensibly vouch for as an intermediary.
645
- const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
629
+ const senderDomain = (bare(header(input.headerLines, HEADER.sender)).split("@")[1] || "").toLowerCase();
646
630
  const relay = senderDomain ? a.signers.find(s => sameOrg(s, senderDomain)) : "";
647
631
  const via = relay && !a.proved ? { via: relay } : {};
648
632
  // Say WHICH party and HOW, in the finding itself (Bob 2026-09-08: "the
@@ -770,7 +754,7 @@ function selfSpoof(input) {
770
754
  const own = new Set(input.ownAddresses.map(bare).filter(Boolean));
771
755
  if (!own.has(from))
772
756
  return null;
773
- const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
757
+ const auth = headerAll(input.headerLines, HEADER.authenticationResults).join(" ; ");
774
758
  if (!auth)
775
759
  return null;
776
760
  // The From domain passed DMARC, aligned, at the receiving server: the
@@ -804,7 +788,7 @@ function selfSpoof(input) {
804
788
  if (!failed.length) {
805
789
  if (/\b(spf|dkim|dmarc)=pass\b/i.test(auth))
806
790
  return null;
807
- const envelopeAddr = bare(header(input.headerLines, "return-path"));
791
+ const envelopeAddr = bare(header(input.headerLines, HEADER.returnPath));
808
792
  const note = envelopeAddr && envelopeAddr !== from ? `; envelope sender ${envelopeAddr}` : "";
809
793
  return {
810
794
  id: "self-spoof",
@@ -815,7 +799,7 @@ function selfSpoof(input) {
815
799
  }
816
800
  // Name the envelope sender when it disagrees — that is the address that
817
801
  // actually sent this, and seeing it beside the claim is the whole story.
818
- const envelope = bare(header(input.headerLines, "return-path"));
802
+ const envelope = bare(header(input.headerLines, HEADER.returnPath));
819
803
  const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
820
804
  return {
821
805
  id: "self-spoof",
@@ -1095,7 +1079,7 @@ function listRewrittenAuthor(input, fromDomain) {
1095
1079
  const encoded = local.match(/^([^=]+)=([a-z0-9.-]+\.[a-z]{2,})$/i);
1096
1080
  if (encoded)
1097
1081
  return `${encoded[1]}@${encoded[2]}`.toLowerCase();
1098
- for (const name of ["x-original-from", "resent-from"]) {
1082
+ for (const name of [HEADER.xOriginalFrom, HEADER.resentFrom]) {
1099
1083
  const addr = bare(header(input.headerLines, name));
1100
1084
  if (addr.includes("@"))
1101
1085
  return addr;