@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 +87 -0
- package/headers.js +138 -0
- package/index.d.ts +3 -1
- package/index.js +16 -17
- package/list-identity.d.ts +13 -5
- package/list-identity.js +22 -21
- package/package.json +1 -1
- package/trust.js +22 -38
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
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
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
|
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
|
@@ -21,19 +21,8 @@
|
|
|
21
21
|
* out — no I/O, browser-safe.
|
|
22
22
|
*/
|
|
23
23
|
import { firstAddress } from "./address.js";
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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,
|
|
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,
|
|
76
|
+
const listPost = (headerValues(headerLines, HEADER.listPost)[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
|
|
75
77
|
const listAddresses = [];
|
|
76
|
-
for (const candidate of [listPost, headerValues(headerLines,
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
437
|
-
const flag = header(input.headerLines,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 [
|
|
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;
|