@bobfrankston/mailx-types 0.1.108 → 0.1.111

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 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
- const display = m[1].replace(/^"(.*)"$/s, "$1");
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,53 @@
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
+ /** The author's real address when the list rewrote From into an
42
+ * undeliverable stand-in, else "". groups.io turns
43
+ * `bernardgreenberg@msn.com` into `"Bernard Greenberg via groups.io"
44
+ * <bernardgreenberg=msn.com@groups.io>` (so his own domain's DMARC policy
45
+ * doesn't reject the list's copy) and refuses mail sent to that:
46
+ * "510 5.1.1 … is not a real mailbox. To reply to this person, send your
47
+ * message to bernardgreenberg@msn.com" (Bob's bounce, 2026-10-26). The
48
+ * real address is in Resent-From. (2026-10-26, Claude Code (Opus 5.5).) */
49
+ listAuthor: string;
50
+ }
51
+ /** Read the delivery and list facts out of a parsed message. */
52
+ export declare function readListIdentity(headerLines: readonly ListHeaderLine[], bodyText: string, bodyHtml: string): ListIdentity;
53
+ //# sourceMappingURL=list-identity.d.ts.map
@@ -0,0 +1,97 @@
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: "", listAuthor: "" };
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
+ let listAuthor = "";
83
+ if (listId === "groups.io" || listId.endsWith(".groups.io")) {
84
+ const m = (bodyText || "").match(GROUPS_IO_SUBSCRIBER) || (bodyHtml || "").match(GROUPS_IO_SUBSCRIBER);
85
+ if (m)
86
+ listSubscriber = m[1];
87
+ // A rewritten From is `local=domain@groups.io`. Resent-From carries
88
+ // the original; failing that, the rewrite itself encodes it (the
89
+ // LAST `=` separates local part from domain).
90
+ const from = bareAddress(headerValues(headerLines, "from")[0] || "");
91
+ const rewritten = from.match(/^(.+)=([^=@]+)@groups\.io$/);
92
+ if (rewritten)
93
+ listAuthor = bareAddress(headerValues(headerLines, "resent-from")[0] || "") || `${rewritten[1]}@${rewritten[2]}`;
94
+ }
95
+ return { deliveredTo, listId, listAddresses, listSubscriber, listAuthor };
96
+ }
97
+ //# sourceMappingURL=list-identity.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.108",
3
+ "version": "0.1.111",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.js CHANGED
@@ -934,6 +934,15 @@ function redirectorLink(input) {
934
934
  // trusted domain into From.
935
935
  if (senderDomain && input.senderTrusted)
936
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;
937
946
  // 3. (2026-09-08) The redirector belongs to a reader-listed intermediary
938
947
  // that provably handled the message. Google Calendar wraps every link
939
948
  // in an invitation as google.com/url?q=…, signs the mail d=google.com,
@@ -941,6 +950,18 @@ function redirectorLink(input) {
941
950
  // is "I trust google.com to do that". Same second-party logic: the
942
951
  // host spending its reputation is the host that signed.
943
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));
944
965
  const signers = dkimSigners(input);
945
966
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
946
967
  let url;
@@ -956,6 +977,8 @@ function redirectorLink(input) {
956
977
  continue; // the sender's own host
957
978
  if (intermediary && sameOrg(url.hostname, intermediary))
958
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)
959
982
  for (const [, value] of url.searchParams) {
960
983
  let target;
961
984
  try {
@@ -1042,11 +1065,57 @@ const RELAY_IDENTITY_HEADERS = [
1042
1065
  "x-auth-id",
1043
1066
  "x-authenticated-user",
1044
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
+ }
1045
1101
  function relayAuthMismatch(input) {
1046
- const from = bare(input.fromAddress);
1047
- const fromDomain = from.split("@")[1] || "";
1102
+ let from = bare(input.fromAddress);
1103
+ let fromDomain = from.split("@")[1] || "";
1048
1104
  if (!fromDomain)
1049
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
+ }
1050
1119
  for (const name of RELAY_IDENTITY_HEADERS) {
1051
1120
  const stamped = bare(header(input.headerLines, name));
1052
1121
  const stampedDomain = stamped.split("@")[1] || "";