@bobfrankston/mailx-types 0.1.106 → 0.1.110
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/index.d.ts +62 -0
- package/index.js +161 -1
- package/list-identity.d.ts +44 -0
- package/list-identity.js +89 -0
- package/package.json +1 -1
- package/trust.d.ts +19 -0
- package/trust.js +143 -15
package/index.d.ts
CHANGED
|
@@ -418,6 +418,66 @@ export declare function sanitizeHtml(html: string): {
|
|
|
418
418
|
* boundaries so a multi-byte sequence is never cut in half.
|
|
419
419
|
*/
|
|
420
420
|
export declare function encodeHeaderWord(text: string): string;
|
|
421
|
+
/** Format one recipient for a message header: `Name <addr>`, with the name
|
|
422
|
+
* quoted (and `\` / `"` escaped) when it contains RFC 5322 specials. Use this
|
|
423
|
+
* for every From/To/Cc/Bcc header the app writes — never interpolate by hand. */
|
|
424
|
+
export declare function formatAddressForHeader(name: string | undefined, address: string): string;
|
|
425
|
+
/** Format a recipient list for a header (see formatAddressForHeader). */
|
|
426
|
+
export declare function formatAddressListForHeader(list: {
|
|
427
|
+
name?: string;
|
|
428
|
+
address?: string;
|
|
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
|
+
/** What an SMTP send needs from a finished RFC 822 message. */
|
|
445
|
+
export interface OutgoingEnvelope {
|
|
446
|
+
/** RCPT TO: every addr-spec in To, Cc and Bcc. */
|
|
447
|
+
recipients: string[];
|
|
448
|
+
/** MAIL FROM: the From addr-spec, or the caller's fallback. */
|
|
449
|
+
sender: string;
|
|
450
|
+
/** Subject header value, or "". */
|
|
451
|
+
subject: string;
|
|
452
|
+
/** Message-ID header value with its angle brackets, or "". */
|
|
453
|
+
messageId: string;
|
|
454
|
+
/** The message as transmitted: Bcc removed from the header section. */
|
|
455
|
+
rawToSend: string;
|
|
456
|
+
}
|
|
457
|
+
/** Build the SMTP envelope and the transmitted copy of an outgoing message.
|
|
458
|
+
* ONE implementation for every send path (desktop mailx-imap sendRaw, Android
|
|
459
|
+
* android-bootstrap and sync-manager), which each used to carry their own copy
|
|
460
|
+
* of this code (2026-10-25 — Claude Code (Opus 5.5), at Bob's direction:
|
|
461
|
+
* "economy of mechanism — why does that pattern appear in multiple places").
|
|
462
|
+
* The copies are how one too-narrow address regex reached all three.
|
|
463
|
+
*
|
|
464
|
+
* Reads the HEADER SECTION only, unfolded: matching the whole raw file once
|
|
465
|
+
* scraped Cc lines out of a quoted reply chain into RCPT TO (2026-07-21).
|
|
466
|
+
* Bcc is stripped from the header section only, continuation lines included,
|
|
467
|
+
* so a quoted "Bcc:" in the body is never eaten. */
|
|
468
|
+
export declare function outgoingEnvelope(raw: string, fallbackSender: string): OutgoingEnvelope;
|
|
469
|
+
/** Entries of an address header that a reader would see as an address with
|
|
470
|
+
* no `@` — e.g. the "Frankston" in `Frankston, Bob <x@y>`. Empty when sound. */
|
|
471
|
+
export declare function findBareAddresses(value: string): string[];
|
|
472
|
+
/** Send-time check (Bob 2026-10-05: "perhaps put in a debugging check"):
|
|
473
|
+
* refuse to send when a header we built would carry an address without an
|
|
474
|
+
* `@`, which the outgoing server would silently complete with its own
|
|
475
|
+
* domain. Throws with the offending entries named; the caller's send error
|
|
476
|
+
* path shows it to the user. */
|
|
477
|
+
export declare function assertAddressHeaders(headers: Record<string, string | undefined>): void;
|
|
478
|
+
export declare function tempAssertNoBogusIeccRecipients(lists: {
|
|
479
|
+
address?: string;
|
|
480
|
+
}[][]): void;
|
|
421
481
|
/**
|
|
422
482
|
* Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
|
|
423
483
|
* DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
|
|
@@ -569,4 +629,6 @@ export declare function parseInvite(ics: string): ParsedInvite | null;
|
|
|
569
629
|
* case specially").
|
|
570
630
|
*/
|
|
571
631
|
export declare function inviteHasGoogleRsvp(bodyHtml: string): boolean;
|
|
632
|
+
export { readListIdentity } from "./list-identity.js";
|
|
633
|
+
export type { ListIdentity, ListHeaderLine } from "./list-identity.js";
|
|
572
634
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -194,6 +194,162 @@ function bytesToBase64(bytes) {
|
|
|
194
194
|
// btoa exists in browsers and in Node ≥16.
|
|
195
195
|
return btoa(bin);
|
|
196
196
|
}
|
|
197
|
+
// 2026-10-05 — Claude Code (Opus 5.5), at Bob's direction.
|
|
198
|
+
// The send paths wrote `${name} <${address}>` with the name UNQUOTED. A contact
|
|
199
|
+
// named "Frankston, Bob" went out as `To: Frankston, Bob <from-eleanor@...>`,
|
|
200
|
+
// which every reader parses as TWO recipients: a bare "Frankston" and
|
|
201
|
+
// "Bob <...>". The outgoing server (iecc's qmail) completes a bare name with
|
|
202
|
+
// its own domain, so the header said Frankston@iecc.com, and every reply-all
|
|
203
|
+
// bounced. Seen on Eleanor's sends since 2026-07-27 ("Frankston, Bob",
|
|
204
|
+
// "Yehezkel, Revital", "Elkin Frankston, Seth"). SMTP RCPT used the parsed
|
|
205
|
+
// list, so the original send itself was delivered correctly; only the header
|
|
206
|
+
// was wrong.
|
|
207
|
+
/** RFC 5322 `specials`: a display name containing any of these must be a
|
|
208
|
+
* quoted-string, or the comma / @ / angle bracket is read as syntax. */
|
|
209
|
+
const DISPLAY_NAME_SPECIALS = /[()<>[\]:;@\\,."]/;
|
|
210
|
+
/** Format one recipient for a message header: `Name <addr>`, with the name
|
|
211
|
+
* quoted (and `\` / `"` escaped) when it contains RFC 5322 specials. Use this
|
|
212
|
+
* for every From/To/Cc/Bcc header the app writes — never interpolate by hand. */
|
|
213
|
+
export function formatAddressForHeader(name, address) {
|
|
214
|
+
const n = (name || "").trim();
|
|
215
|
+
if (!n)
|
|
216
|
+
return address;
|
|
217
|
+
const display = DISPLAY_NAME_SPECIALS.test(n) ? `"${n.replace(/[\\"]/g, "\\$&")}"` : n;
|
|
218
|
+
return `${display} <${address}>`;
|
|
219
|
+
}
|
|
220
|
+
/** Format a recipient list for a header (see formatAddressForHeader). */
|
|
221
|
+
export function formatAddressListForHeader(list) {
|
|
222
|
+
return (list || []).map(a => formatAddressForHeader(a.name, a.address || "")).join(", ");
|
|
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
|
+
/** Build the SMTP envelope and the transmitted copy of an outgoing message.
|
|
280
|
+
* ONE implementation for every send path (desktop mailx-imap sendRaw, Android
|
|
281
|
+
* android-bootstrap and sync-manager), which each used to carry their own copy
|
|
282
|
+
* of this code (2026-10-25 — Claude Code (Opus 5.5), at Bob's direction:
|
|
283
|
+
* "economy of mechanism — why does that pattern appear in multiple places").
|
|
284
|
+
* The copies are how one too-narrow address regex reached all three.
|
|
285
|
+
*
|
|
286
|
+
* Reads the HEADER SECTION only, unfolded: matching the whole raw file once
|
|
287
|
+
* scraped Cc lines out of a quoted reply chain into RCPT TO (2026-07-21).
|
|
288
|
+
* Bcc is stripped from the header section only, continuation lines included,
|
|
289
|
+
* so a quoted "Bcc:" in the body is never eaten. */
|
|
290
|
+
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 };
|
|
307
|
+
}
|
|
308
|
+
/** Entries of an address header that a reader would see as an address with
|
|
309
|
+
* no `@` — e.g. the "Frankston" in `Frankston, Bob <x@y>`. Empty when sound. */
|
|
310
|
+
export function findBareAddresses(value) {
|
|
311
|
+
if (!value)
|
|
312
|
+
return [];
|
|
313
|
+
return splitAddressHeader(value).filter(part => {
|
|
314
|
+
const angle = /<([^>]*)>\s*$/.exec(part);
|
|
315
|
+
const addrSpec = angle ? angle[1] : part;
|
|
316
|
+
return !addrSpec.includes("@");
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Send-time check (Bob 2026-10-05: "perhaps put in a debugging check"):
|
|
320
|
+
* refuse to send when a header we built would carry an address without an
|
|
321
|
+
* `@`, which the outgoing server would silently complete with its own
|
|
322
|
+
* domain. Throws with the offending entries named; the caller's send error
|
|
323
|
+
* path shows it to the user. */
|
|
324
|
+
export function assertAddressHeaders(headers) {
|
|
325
|
+
for (const [label, value] of Object.entries(headers)) {
|
|
326
|
+
const bare = findBareAddresses(value || "");
|
|
327
|
+
if (bare.length) {
|
|
328
|
+
throw new Error(`${label} header would contain ${bare.map(b => `"${b}"`).join(", ")} with no @ — the mail server would add its own domain to it. Not sent. Header: ${value}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// ─── TEMPORARY — REMOVE AFTER 2026-10-19 ───────────────────────────────────
|
|
333
|
+
// 2026-10-05 — Claude Code (Opus 5.5), at Bob's direction ("add a temporary
|
|
334
|
+
// bug check"). Leftovers of the unquoted-name bug above: old message headers
|
|
335
|
+
// and learned contacts still hold bogus addresses like Frankston@iecc.com,
|
|
336
|
+
// which reply-all copies — and they have an @, so assertAddressHeaders lets
|
|
337
|
+
// them through. Real iecc recipients per Bob: johnl@ (2026-10-05), and
|
|
338
|
+
// hostmaster@, accounts@, publishers@ "may be valid" — allowed.
|
|
339
|
+
// iecc is specific to Bob's setup, which is why this is dated and temporary,
|
|
340
|
+
// not a feature. Remove this function and its two callers (mailx-service
|
|
341
|
+
// send, mailx-store-web send) once the old threads have aged out; tracked in
|
|
342
|
+
// TODO.md.
|
|
343
|
+
const TEMP_IECC_ALLOWED = new Set(["johnl@iecc.com", "hostmaster@iecc.com", "accounts@iecc.com", "publishers@iecc.com"]);
|
|
344
|
+
export function tempAssertNoBogusIeccRecipients(lists) {
|
|
345
|
+
const bad = lists.flat()
|
|
346
|
+
.map(a => (a?.address || "").trim())
|
|
347
|
+
.filter(addr => /@iecc\.com$/i.test(addr) && !TEMP_IECC_ALLOWED.has(addr.toLowerCase()));
|
|
348
|
+
if (bad.length) {
|
|
349
|
+
throw new Error(`Recipient ${bad.join(", ")} looks like a name that the iecc server turned into an address (known real iecc addresses: ${[...TEMP_IECC_ALLOWED].join(", ")}). Remove it and send again. [temporary check, 2026-10-05]`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ─── END TEMPORARY ─────────────────────────────────────────────────────────
|
|
197
353
|
/**
|
|
198
354
|
* Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
|
|
199
355
|
* DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
|
|
@@ -234,7 +390,9 @@ export function encodeAddressHeader(value) {
|
|
|
234
390
|
const m = /^(.*?)\s*(<[^>]*>)\s*$/.exec(p);
|
|
235
391
|
if (!m)
|
|
236
392
|
return p; // bare addr-spec, nothing to encode
|
|
237
|
-
|
|
393
|
+
// 2026-10-05 Claude Code: undo formatAddressForHeader's \" / \\ escapes
|
|
394
|
+
// before RFC 2047-encoding (an encoded-word carries the raw text).
|
|
395
|
+
const display = m[1].replace(/^"(.*)"$/s, "$1").replace(/\\(.)/g, "$1");
|
|
238
396
|
if (!display)
|
|
239
397
|
return m[2];
|
|
240
398
|
// eslint-disable-next-line no-control-regex
|
|
@@ -749,4 +907,6 @@ export function inviteHasGoogleRsvp(bodyHtml) {
|
|
|
749
907
|
return false;
|
|
750
908
|
return /calendar\.google\.com\/calendar\/event\?action=RESPOND/i.test(bodyHtml);
|
|
751
909
|
}
|
|
910
|
+
// Delivery address + mailing-list identity for reply's From (desktop + Android).
|
|
911
|
+
export { readListIdentity } from "./list-identity.js";
|
|
752
912
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of the reader's addresses a message was delivered to, and which
|
|
3
|
+
* mailing list it came through — the facts reply uses to pick a From.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-25 — Claude Code (Opus 5.5), at Bob's direction. Posting to the
|
|
6
|
+
* Multicians list (groups.io) needs From: Multicians@bobf.frankston.com, the
|
|
7
|
+
* subscribed address, and nothing in the message's headers names it:
|
|
8
|
+
* - groups.io writes its OWN `Delivered-To: mailing list multicians@groups.io`
|
|
9
|
+
* (the qmail/ezmlm loop-detection convention), which the old code took to
|
|
10
|
+
* be the reader's address — so a reply's From became the list itself;
|
|
11
|
+
* - the reader's own Delivered-To was lost before the message reached
|
|
12
|
+
* IMAP. IECC did record it (`bobf-Multicians@bobf.frankston.com`), but
|
|
13
|
+
* an older deployed mlproc (processletter.ts parseEnvelope) rewrote that
|
|
14
|
+
* line with groups.io's marker — see MailApps/mlproc/.llm/
|
|
15
|
+
* delivered-to-loss.md. (Corrected 2026-10-25; this said IECC recorded none.)
|
|
16
|
+
* - groups.io names the subscriber only in its footer, after the
|
|
17
|
+
* Unsubscribe link: `.../leave/... [Multicians@bobf.frankston.com]`.
|
|
18
|
+
*
|
|
19
|
+
* One module for desktop (mailx-store) and Android (mailx-store-web), so the
|
|
20
|
+
* two cannot drift apart again. Pure: header lines and body text in, strings
|
|
21
|
+
* out — no I/O, browser-safe.
|
|
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
|
+
}
|
|
28
|
+
export interface ListIdentity {
|
|
29
|
+
/** Address the RECEIVING server recorded delivering this copy to
|
|
30
|
+
* (Delivered-To / X-Original-To / Envelope-To), or "". A mailing list's
|
|
31
|
+
* own `Delivered-To: mailing list …` is never taken. */
|
|
32
|
+
deliveredTo: string;
|
|
33
|
+
/** List-Id identifier (`multicians.groups.io`), lowercase, or "". */
|
|
34
|
+
listId: string;
|
|
35
|
+
/** Addresses a post to this list would be sent To, most specific first:
|
|
36
|
+
* List-Post, Reply-To, Sender. Bare, lowercase. Empty when not list mail. */
|
|
37
|
+
listAddresses: string[];
|
|
38
|
+
/** The reader's subscribed address as the list itself states it (groups.io
|
|
39
|
+
* footer), or "". */
|
|
40
|
+
listSubscriber: string;
|
|
41
|
+
}
|
|
42
|
+
/** Read the delivery and list facts out of a parsed message. */
|
|
43
|
+
export declare function readListIdentity(headerLines: readonly ListHeaderLine[], bodyText: string, bodyHtml: string): ListIdentity;
|
|
44
|
+
//# sourceMappingURL=list-identity.d.ts.map
|
package/list-identity.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of the reader's addresses a message was delivered to, and which
|
|
3
|
+
* mailing list it came through — the facts reply uses to pick a From.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-25 — Claude Code (Opus 5.5), at Bob's direction. Posting to the
|
|
6
|
+
* Multicians list (groups.io) needs From: Multicians@bobf.frankston.com, the
|
|
7
|
+
* subscribed address, and nothing in the message's headers names it:
|
|
8
|
+
* - groups.io writes its OWN `Delivered-To: mailing list multicians@groups.io`
|
|
9
|
+
* (the qmail/ezmlm loop-detection convention), which the old code took to
|
|
10
|
+
* be the reader's address — so a reply's From became the list itself;
|
|
11
|
+
* - the reader's own Delivered-To was lost before the message reached
|
|
12
|
+
* IMAP. IECC did record it (`bobf-Multicians@bobf.frankston.com`), but
|
|
13
|
+
* an older deployed mlproc (processletter.ts parseEnvelope) rewrote that
|
|
14
|
+
* line with groups.io's marker — see MailApps/mlproc/.llm/
|
|
15
|
+
* delivered-to-loss.md. (Corrected 2026-10-25; this said IECC recorded none.)
|
|
16
|
+
* - groups.io names the subscriber only in its footer, after the
|
|
17
|
+
* Unsubscribe link: `.../leave/... [Multicians@bobf.frankston.com]`.
|
|
18
|
+
*
|
|
19
|
+
* One module for desktop (mailx-store) and Android (mailx-store-web), so the
|
|
20
|
+
* two cannot drift apart again. Pure: header lines and body text in, strings
|
|
21
|
+
* out — no I/O, browser-safe.
|
|
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
|
+
}
|
|
36
|
+
function bareAddress(value) {
|
|
37
|
+
return ((value || "").match(/[^\s<>,;:"]+@[^\s<>,;:"]+/)?.[0] || "").toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
/** qmail/ezmlm, and groups.io after them, stamp `Delivered-To: mailing list
|
|
40
|
+
* <addr>` on the copy they fan out, to catch mail loops. It names the LIST,
|
|
41
|
+
* not the subscriber, and it sits below the receiving server's own line
|
|
42
|
+
* when there is one. */
|
|
43
|
+
const LIST_DELIVERED_TO = /^mailing list\s/i;
|
|
44
|
+
/** The footer groups.io appends to every copy:
|
|
45
|
+
* text: `Unsubscribe: https://groups.io/g/<group>/leave/<ids> [addr]`
|
|
46
|
+
* html: `<a href="https://groups.io/g/<group>/leave/<ids>">Unsubscribe</a> [addr]`
|
|
47
|
+
* Each piece matches one way only (the URL stops at quote/space, then the
|
|
48
|
+
* optional anchor tail, then blanks, then the bracket), so a long body with
|
|
49
|
+
* no footer fails in a single pass. */
|
|
50
|
+
const GROUPS_IO_SUBSCRIBER = /groups\.io\/g\/[^\s"'<>\/]+\/leave\/[^\s"'<>]*(?:["']>Unsubscribe<\/a>)?\s*\[([^\s\[\]<>@]+@[^\s\[\]<>@]+)\]/i;
|
|
51
|
+
/** Read the delivery and list facts out of a parsed message. */
|
|
52
|
+
export function readListIdentity(headerLines, bodyText, bodyHtml) {
|
|
53
|
+
// Receiving-server headers: each agent PREPENDS its own, so the first
|
|
54
|
+
// that is not a list's loop marker is the final delivery to this mailbox.
|
|
55
|
+
let deliveredTo = "";
|
|
56
|
+
for (const key of ["delivered-to", "x-original-to", "envelope-to"]) {
|
|
57
|
+
for (const value of headerValues(headerLines, key)) {
|
|
58
|
+
if (LIST_DELIVERED_TO.test(value))
|
|
59
|
+
continue;
|
|
60
|
+
if (bareAddress(value)) {
|
|
61
|
+
deliveredTo = value;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (deliveredTo)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const rawListId = headerValues(headerLines, "list-id")[0] || "";
|
|
69
|
+
const listId = ((rawListId.match(/<([^>]+)>/)?.[1]) || rawListId).trim().toLowerCase();
|
|
70
|
+
if (!listId)
|
|
71
|
+
return { deliveredTo, listId: "", listAddresses: [], listSubscriber: "" };
|
|
72
|
+
const listPost = (headerValues(headerLines, "list-post")[0] || "").match(/<mailto:([^>?]+)/i)?.[1] || "";
|
|
73
|
+
const listAddresses = [];
|
|
74
|
+
for (const candidate of [listPost, headerValues(headerLines, "reply-to")[0], headerValues(headerLines, "sender")[0]]) {
|
|
75
|
+
const addr = bareAddress(candidate || "");
|
|
76
|
+
if (addr && !listAddresses.includes(addr))
|
|
77
|
+
listAddresses.push(addr);
|
|
78
|
+
}
|
|
79
|
+
// Only groups.io states the subscriber, and only in its footer; the List-Id
|
|
80
|
+
// gate keeps the pattern off every other message's body.
|
|
81
|
+
let listSubscriber = "";
|
|
82
|
+
if (listId === "groups.io" || listId.endsWith(".groups.io")) {
|
|
83
|
+
const m = (bodyText || "").match(GROUPS_IO_SUBSCRIBER) || (bodyHtml || "").match(GROUPS_IO_SUBSCRIBER);
|
|
84
|
+
if (m)
|
|
85
|
+
listSubscriber = m[1];
|
|
86
|
+
}
|
|
87
|
+
return { deliveredTo, listId, listAddresses, listSubscriber };
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=list-identity.js.map
|
package/package.json
CHANGED
package/trust.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare const TRUST_LIST_KEYS: {
|
|
|
40
40
|
readonly intermediary: "trustedIntermediaries";
|
|
41
41
|
readonly list: "trustedLists";
|
|
42
42
|
readonly via: "trustedSendersVia";
|
|
43
|
+
readonly linkhost: "trustedLinkHosts";
|
|
43
44
|
};
|
|
44
45
|
export type TrustListType = keyof typeof TRUST_LIST_KEYS;
|
|
45
46
|
/** One piece of evidence that a message is not what it claims to be. */
|
|
@@ -73,6 +74,11 @@ export interface TrustFinding {
|
|
|
73
74
|
* on a redirector warning when the sender is proved but not yet on the
|
|
74
75
|
* reader's list, so the viewer can offer "Stop warning about". (2026-09-11) */
|
|
75
76
|
sender?: string;
|
|
77
|
+
/** Link hosts a phishing/malware URL listing named, set only when they
|
|
78
|
+
* are the WHOLE of the forgery evidence on a proved sender, so the viewer
|
|
79
|
+
* can offer "Trust links to ‹host›" (allowlist.jsonc `trustedLinkHosts`).
|
|
80
|
+
* (2026-09-30) */
|
|
81
|
+
linkHosts?: string[];
|
|
76
82
|
}
|
|
77
83
|
export interface TrustInput {
|
|
78
84
|
/** Every header line, in order, as mailparser's `headerLines`. Repeated
|
|
@@ -135,6 +141,19 @@ export interface TrustInput {
|
|
|
135
141
|
* forger with their own SES account is not groundnews.email's, and one
|
|
136
142
|
* without a relay signature matches nothing. See provedSenderVia. */
|
|
137
143
|
trustedSendersVia?: string[];
|
|
144
|
+
/** Link hosts the reader vouches for, from allowlist.jsonc
|
|
145
|
+
* `trustedLinkHosts` ("us.list-manage.com"). 2026-09-30, Claude Code
|
|
146
|
+
* (Opus 5.5), at Bob's direction: Fig City News, a newsletter proved by
|
|
147
|
+
* DMARC, went red because its links run through Mailchimp's click tracker
|
|
148
|
+
* us.list-manage.com, which Spamhaus lists as abused for phishing
|
|
149
|
+
* (URIBL_DBL_ABUSE_PHISH, 7.0) — and a phishing listing is forgery-class,
|
|
150
|
+
* so no sender trust could clear it. A phishing listing stays forgery by
|
|
151
|
+
* default: a proved sender can still send a bad link. An entry here
|
|
152
|
+
* demotes such a listing to association ONLY when (a) the From line is
|
|
153
|
+
* proved and nothing failed authentication, and (b) EVERY host the
|
|
154
|
+
* server's report names for that rule is listed. A listing that names no
|
|
155
|
+
* host, or any unlisted host, is untouched. */
|
|
156
|
+
trustedLinkHosts?: string[];
|
|
138
157
|
}
|
|
139
158
|
/**
|
|
140
159
|
* Everything findable about this message, worst first.
|
package/trust.js
CHANGED
|
@@ -40,6 +40,8 @@ export const TRUST_LIST_KEYS = {
|
|
|
40
40
|
intermediary: "trustedIntermediaries",
|
|
41
41
|
list: "trustedLists",
|
|
42
42
|
via: "trustedSendersVia",
|
|
43
|
+
// 2026-09-30 — see TrustInput.trustedLinkHosts.
|
|
44
|
+
linkhost: "trustedLinkHosts",
|
|
43
45
|
};
|
|
44
46
|
/** First value of a header, case-insensitive. */
|
|
45
47
|
function header(lines, name) {
|
|
@@ -378,8 +380,10 @@ function unfold(value) {
|
|
|
378
380
|
* * blocklist
|
|
379
381
|
* * [URI: ns12.xincache.com/112.80.181.111]
|
|
380
382
|
*
|
|
381
|
-
* The bracketed evidence lines
|
|
382
|
-
* the finding is about this message
|
|
383
|
+
* The bracketed evidence lines stay out of the description — they name the
|
|
384
|
+
* third party, and the finding is about this message — but `[URI: host]`
|
|
385
|
+
* lines are kept per rule in `uris`: they are the only record of WHICH link
|
|
386
|
+
* host a URL listing is about (2026-09-30, for trustedLinkHosts). */
|
|
383
387
|
function parseSpamReport(report) {
|
|
384
388
|
const out = new Map();
|
|
385
389
|
let current = null;
|
|
@@ -389,12 +393,20 @@ function parseSpamReport(report) {
|
|
|
389
393
|
continue;
|
|
390
394
|
const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
|
|
391
395
|
if (start) {
|
|
392
|
-
current = { score: Number(start[1]), description: start[3].trim() };
|
|
396
|
+
current = { score: Number(start[1]), description: start[3].trim(), uris: [] };
|
|
393
397
|
out.set(start[2], current);
|
|
394
398
|
continue;
|
|
395
399
|
}
|
|
396
|
-
if (current
|
|
397
|
-
|
|
400
|
+
if (!current)
|
|
401
|
+
continue;
|
|
402
|
+
if (line.startsWith("[")) {
|
|
403
|
+
// "[URI: ns12.xincache.com/112.80.181.111]" — keep the host only.
|
|
404
|
+
const uri = line.match(/^\[URI:\s*([^\]\s/]+)/i)?.[1];
|
|
405
|
+
if (uri)
|
|
406
|
+
current.uris.push(uri.toLowerCase());
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
current.description += " " + line;
|
|
398
410
|
}
|
|
399
411
|
for (const rule of out.values())
|
|
400
412
|
rule.description = rule.description
|
|
@@ -403,6 +415,19 @@ function parseSpamReport(report) {
|
|
|
403
415
|
.trim();
|
|
404
416
|
return out;
|
|
405
417
|
}
|
|
418
|
+
/** Rules that are a third party's listing of a host the message LINKS to.
|
|
419
|
+
* Wider than ASSOCIATION_RULES: it includes the phishing/malware variants
|
|
420
|
+
* (URIBL_PHISH, URIBL_DBL_ABUSE_PHISH), which classifyRule files as forgery.
|
|
421
|
+
* Used only to decide whether trustedLinkHosts may speak to a rule. */
|
|
422
|
+
const URL_LISTING_RULES = [
|
|
423
|
+
/^URIBL_/, /^SH_DBL/, /^SEM_URIBL/, /^KAM_(BODY_|FROM_)?\w*URIBL/,
|
|
424
|
+
];
|
|
425
|
+
const isUrlListing = (name) => URL_LISTING_RULES.some(re => re.test(name));
|
|
426
|
+
/** Is `host` one of the reader's trusted link hosts, or a subdomain of one? */
|
|
427
|
+
function linkHostTrusted(host, trusted) {
|
|
428
|
+
const h = (host || "").toLowerCase();
|
|
429
|
+
return trusted.some(t => h === t || h.endsWith("." + t));
|
|
430
|
+
}
|
|
406
431
|
function analyzeServerSpam(input) {
|
|
407
432
|
const status = unfold(header(input.headerLines, "x-spam-status"));
|
|
408
433
|
const flag = header(input.headerLines, "x-spam-flag");
|
|
@@ -433,15 +458,39 @@ function analyzeServerSpam(input) {
|
|
|
433
458
|
// from delivering a rule called WORD_INVISshortcircuit.
|
|
434
459
|
const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "")
|
|
435
460
|
.replace(/\s+/g, "").split(",").map(n => n.toUpperCase()).filter(Boolean);
|
|
461
|
+
// Proof of the From line, from the same three sources provedBy uses below
|
|
462
|
+
// — needed BEFORE classification so a trusted link host can only ever
|
|
463
|
+
// speak for a proved sender. (2026-09-30)
|
|
464
|
+
const fromProved = dmarc.pass || names.includes("DKIM_VALID_AU") || names.includes("ALL_TRUSTED");
|
|
465
|
+
const trustedLinkHosts = (input.trustedLinkHosts || []).map(h => (h || "").trim().toLowerCase()).filter(Boolean);
|
|
466
|
+
const linkHostCandidates = new Set();
|
|
467
|
+
let forgeryOnlyUrlListings = true;
|
|
436
468
|
const rules = names.map(name => {
|
|
437
469
|
const found = report.get(name);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
470
|
+
let cls = classifyRule(name, dmarc.failed, list.proved);
|
|
471
|
+
const score = found ? found.score : NaN;
|
|
472
|
+
if (cls === "forgery" && !(score <= 0)) {
|
|
473
|
+
const uris = found?.uris || [];
|
|
474
|
+
const clearable = fromProved && !authFailures.length && isUrlListing(name) && uris.length > 0;
|
|
475
|
+
if (clearable && uris.every(u => linkHostTrusted(u, trustedLinkHosts))) {
|
|
476
|
+
// Every host this listing names is one the reader vouched for,
|
|
477
|
+
// on mail the sender proved: evidence about the link, not the From.
|
|
478
|
+
cls = "association";
|
|
479
|
+
}
|
|
480
|
+
else if (clearable) {
|
|
481
|
+
for (const u of uris)
|
|
482
|
+
if (!linkHostTrusted(u, trustedLinkHosts))
|
|
483
|
+
linkHostCandidates.add(u);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
forgeryOnlyUrlListings = false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { name, score, description: found ? found.description : "", cls };
|
|
444
490
|
});
|
|
491
|
+
// Offered only when trusting these hosts would remove every forgery rule —
|
|
492
|
+
// a button that leaves the banner red would be a lie about what it does.
|
|
493
|
+
const linkHosts = forgeryOnlyUrlListings && !authFailures.length ? [...linkHostCandidates] : [];
|
|
445
494
|
// A rule that scored zero or negative did not put this message over any
|
|
446
495
|
// line, so it cannot be the reason the message is here. Unscored rules
|
|
447
496
|
// (no X-Spam-Report, which is most unflagged mail) still count — absence
|
|
@@ -522,6 +571,7 @@ function analyzeServerSpam(input) {
|
|
|
522
571
|
signers: dkimSigners(input),
|
|
523
572
|
list,
|
|
524
573
|
senderVia: provedSenderVia(input),
|
|
574
|
+
linkHosts,
|
|
525
575
|
};
|
|
526
576
|
}
|
|
527
577
|
/**
|
|
@@ -611,15 +661,24 @@ function serverSpamVerdict(input) {
|
|
|
611
661
|
: "flagged by SpamAssassin";
|
|
612
662
|
const why = a.reasons.length ? ` — ${a.reasons.join("; ")}` : "";
|
|
613
663
|
const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
|
|
614
|
-
if (a.kind === "forgery")
|
|
664
|
+
if (a.kind === "forgery") {
|
|
665
|
+
// The one danger finding that carries an action (2026-09-30): the
|
|
666
|
+
// forgery evidence is entirely phishing listings of link hosts, on a
|
|
667
|
+
// proved sender. Say which host and what trusting it does.
|
|
668
|
+
const hosts = a.linkHosts;
|
|
669
|
+
const linkNote = hosts.length
|
|
670
|
+
? `. The only rule here that tests identity is a listing of the link host ${hosts.join(", ")}; ${a.provedDomain} proved it sent this message. If that host is the sender's mail service (a newsletter's click tracker), "Trust links to ${hosts[0]}" stops treating the listing as forgery on proved mail.`
|
|
671
|
+
: "";
|
|
615
672
|
return {
|
|
616
673
|
id: "server-spam-verdict",
|
|
617
674
|
severity: "danger",
|
|
618
675
|
text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
|
|
619
676
|
detail: a.authFailures.length
|
|
620
677
|
? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}`
|
|
621
|
-
: `${numbers}${why}`,
|
|
678
|
+
: `${numbers}${why}${linkNote}`,
|
|
679
|
+
...(hosts.length ? { linkHosts: hosts } : {}),
|
|
622
680
|
};
|
|
681
|
+
}
|
|
623
682
|
if (a.proved && a.kind !== "unclassified")
|
|
624
683
|
return {
|
|
625
684
|
id: "server-spam-verdict",
|
|
@@ -875,6 +934,15 @@ function redirectorLink(input) {
|
|
|
875
934
|
// trusted domain into From.
|
|
876
935
|
if (senderDomain && input.senderTrusted)
|
|
877
936
|
return null;
|
|
937
|
+
// 6. (2026-10-12, Claude Code (Opus 5.5), at Bob's direction: "This is
|
|
938
|
+
// from me to me".) The proved sender is the READER — a copy of their
|
|
939
|
+
// own reply cc'd to themselves, From bob2021b@bob.ma, DKIM_VALID_AU for
|
|
940
|
+
// bob.ma. "Only the sender could vouch for the link" — and the sender
|
|
941
|
+
// is the person reading, who chose the link. Same proof requirement as
|
|
942
|
+
// rule 5: an unproved From of the reader's own address is what a
|
|
943
|
+
// self-spoof looks like, and stays checked.
|
|
944
|
+
if (senderDomain && input.ownAddresses.map(bare).includes(bare(input.fromAddress)))
|
|
945
|
+
return null;
|
|
878
946
|
// 3. (2026-09-08) The redirector belongs to a reader-listed intermediary
|
|
879
947
|
// that provably handled the message. Google Calendar wraps every link
|
|
880
948
|
// in an invitation as google.com/url?q=…, signs the mail d=google.com,
|
|
@@ -882,6 +950,18 @@ function redirectorLink(input) {
|
|
|
882
950
|
// is "I trust google.com to do that". Same second-party logic: the
|
|
883
951
|
// host spending its reputation is the host that signed.
|
|
884
952
|
const intermediary = provedIntermediary(input);
|
|
953
|
+
// 7. (2026-10-23, Claude Code (Opus 5.5), at Bob's direction: "I want to
|
|
954
|
+
// allow mail from Aaron through Google but not all mail through
|
|
955
|
+
// Google. I thought I had specified that.") He had: allowlist.jsonc
|
|
956
|
+
// `trustedSendersVia: ["aaronsylvan.com via google.com"]`. But Aaron's
|
|
957
|
+
// Google Calendar invite is signed only d=google.com, so the From is
|
|
958
|
+
// never proved on its own, rule 5 cannot fire, and the only offer left
|
|
959
|
+
// was rule 3's "trust google.com as an intermediary" for ALL mail
|
|
960
|
+
// Google signs. The via pair is the narrower answer and its proof is
|
|
961
|
+
// the same one provedSenderVia demands (the relay's verified
|
|
962
|
+
// signature): links through THAT relay's own hosts are cleared for
|
|
963
|
+
// THAT sender only. Links through any other redirector stay checked.
|
|
964
|
+
const senderVia = parseSenderVia(provedSenderVia(input));
|
|
885
965
|
const signers = dkimSigners(input);
|
|
886
966
|
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
887
967
|
let url;
|
|
@@ -897,6 +977,8 @@ function redirectorLink(input) {
|
|
|
897
977
|
continue; // the sender's own host
|
|
898
978
|
if (intermediary && sameOrg(url.hostname, intermediary))
|
|
899
979
|
continue; // a trusted relay's own host
|
|
980
|
+
if (senderVia && sameOrg(url.hostname, senderVia.signer))
|
|
981
|
+
continue; // this sender's trusted relay (rule 7)
|
|
900
982
|
for (const [, value] of url.searchParams) {
|
|
901
983
|
let target;
|
|
902
984
|
try {
|
|
@@ -983,11 +1065,57 @@ const RELAY_IDENTITY_HEADERS = [
|
|
|
983
1065
|
"x-auth-id",
|
|
984
1066
|
"x-authenticated-user",
|
|
985
1067
|
];
|
|
1068
|
+
/**
|
|
1069
|
+
* The author a mailing list rewrote out of From, or "" when the list did not
|
|
1070
|
+
* rewrite it or left no trace of who it was.
|
|
1071
|
+
*
|
|
1072
|
+
* 2026-10-15 — Claude Code (Opus 5.5), at Bob's direction. A groups.io post by
|
|
1073
|
+
* cd@libertyhaven.com arrived as `From: cd=libertyhaven.com@groups.io` (the
|
|
1074
|
+
* list rewrites From to its own domain so the author's DMARC policy cannot
|
|
1075
|
+
* bounce it), carrying `X-Authenticated-Sender: libertyhaven@libertyhaven.com`
|
|
1076
|
+
* from the AUTHOR'S own host, one hop before the list. relayAuthMismatch
|
|
1077
|
+
* compared that stamp with groups.io and painted a valid post red. A stamp
|
|
1078
|
+
* describes the From that existed at the hop that wrote it — upstream of the
|
|
1079
|
+
* list, that is the author's From, not the list's.
|
|
1080
|
+
*
|
|
1081
|
+
* Order matters. The `local=domain@list` form is built by the list from the
|
|
1082
|
+
* From it actually received, so it is preferred over headers that merely
|
|
1083
|
+
* travelled with the message (X-Original-From, Resent-From) and that an
|
|
1084
|
+
* upstream sender could have written for itself.
|
|
1085
|
+
*/
|
|
1086
|
+
function listRewrittenAuthor(input, fromDomain) {
|
|
1087
|
+
const list = listProof(input);
|
|
1088
|
+
if (!list.proved || !sameOrg(fromDomain, list.host))
|
|
1089
|
+
return "";
|
|
1090
|
+
const local = bare(input.fromAddress).split("@")[0];
|
|
1091
|
+
const encoded = local.match(/^([^=]+)=([a-z0-9.-]+\.[a-z]{2,})$/i);
|
|
1092
|
+
if (encoded)
|
|
1093
|
+
return `${encoded[1]}@${encoded[2]}`.toLowerCase();
|
|
1094
|
+
for (const name of ["x-original-from", "resent-from"]) {
|
|
1095
|
+
const addr = bare(header(input.headerLines, name));
|
|
1096
|
+
if (addr.includes("@"))
|
|
1097
|
+
return addr;
|
|
1098
|
+
}
|
|
1099
|
+
return "";
|
|
1100
|
+
}
|
|
986
1101
|
function relayAuthMismatch(input) {
|
|
987
|
-
|
|
988
|
-
|
|
1102
|
+
let from = bare(input.fromAddress);
|
|
1103
|
+
let fromDomain = from.split("@")[1] || "";
|
|
989
1104
|
if (!fromDomain)
|
|
990
1105
|
return null;
|
|
1106
|
+
// A list that provably delivered this and rewrote From to itself: the
|
|
1107
|
+
// relay stamps are upstream of the list, so they are compared with the
|
|
1108
|
+
// author the list rewrote out. If the list left no trace of the author
|
|
1109
|
+
// there is nothing the stamp could be compared with, and no finding.
|
|
1110
|
+
// (2026-10-15 — see listRewrittenAuthor.)
|
|
1111
|
+
const list = listProof(input);
|
|
1112
|
+
if (list.proved && sameOrg(fromDomain, list.host)) {
|
|
1113
|
+
const author = listRewrittenAuthor(input, fromDomain);
|
|
1114
|
+
if (!author)
|
|
1115
|
+
return null;
|
|
1116
|
+
from = author;
|
|
1117
|
+
fromDomain = author.split("@")[1];
|
|
1118
|
+
}
|
|
991
1119
|
for (const name of RELAY_IDENTITY_HEADERS) {
|
|
992
1120
|
const stamped = bare(header(input.headerLines, name));
|
|
993
1121
|
const stampedDomain = stamped.split("@")[1] || "";
|