@bobfrankston/mailx-types 0.1.111 → 0.1.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/address.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Email address parsing — the ONE place mailx reads addresses out of header
3
+ * values and decides whether a string is an address.
4
+ *
5
+ * 2026-10-26 — Claude Code (Opus 5.5), at Bob's direction ("fold the other
6
+ * address regexes into the shared functions"). Before this, about a dozen
7
+ * call sites each carried their own address regex: the send paths, trust,
8
+ * the store, list-identity, compose, the editor's link detection, contacts,
9
+ * groups and the CLI. They disagreed on which characters an address may
10
+ * hold, and the narrowest one (`[\w.+-]+@`) cut
11
+ * `bernardgreenberg=msn.com@groups.io` to `msn.com@groups.io` in RCPT TO.
12
+ * Parsing is structural here — split the header on top-level commas, take
13
+ * the `<…>` part or the bare entry — so no function guesses at the
14
+ * characters of a local part.
15
+ *
16
+ * Pure, no I/O, browser-safe.
17
+ */
18
+ /** One parsed entry of an address header. */
19
+ export interface ParsedAddress {
20
+ /** Display name, unquoted and unescaped; "" when there is none. */
21
+ name: string;
22
+ /** The addr-spec, as written (case kept). */
23
+ address: string;
24
+ }
25
+ /** Split an address header on top-level commas (outside quotes and angle
26
+ * brackets; `\` escapes inside quotes honored). Moved here from index.ts. */
27
+ export declare function splitAddressHeader(value: string): string[];
28
+ /** Every entry of an address header that carries an addr-spec, with its
29
+ * display name. Entries with no address (a bare name, an empty group like
30
+ * `Undisclosed recipients:;`) are left out. */
31
+ export declare function parseAddressList(value: string): ParsedAddress[];
32
+ /** The first entry of an address header value, or null. */
33
+ export declare function parseAddress(value: string): ParsedAddress | null;
34
+ /** The addr-spec of every entry — what SMTP RCPT TO / MAIL FROM must carry.
35
+ *
36
+ * 2026-10-25: replaced the `/[\w.+-]+@[\w.-]+/g` scan that all three send
37
+ * paths carried, which omitted characters RFC 5322 allows in a local part
38
+ * (`= ! # $ % & ' * / ? ^ { | } ~`) and so turned
39
+ * `bernardgreenberg=msn.com@groups.io` into `msn.com@groups.io`. */
40
+ export declare function envelopeAddresses(value: string): string[];
41
+ /** The first addr-spec in a header value (`"Name" <a@b>`, `a@b (comment)`,
42
+ * or a bare `a@b`), or "". Case kept; callers that compare lowercase it. */
43
+ export declare function firstAddress(value: string): string;
44
+ /** Is `s` — a bare addr-spec, no display name — plausibly deliverable?
45
+ * Something before the last `@`, a dotted domain after it, and no blanks or
46
+ * angle brackets anywhere. The rule all five copies of
47
+ * `/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/` encoded, written without a pattern
48
+ * whose domain part could be split two ways. Any character RFC 5322 allows
49
+ * in a local part passes. */
50
+ export declare function isValidAddress(s: string): boolean;
51
+ export declare const ADDRESS_IN_TEXT: string;
52
+ /** Every address-shaped run in free text. */
53
+ export declare function findAddressesInText(text: string): string[];
54
+ //# sourceMappingURL=address.d.ts.map
package/address.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Email address parsing — the ONE place mailx reads addresses out of header
3
+ * values and decides whether a string is an address.
4
+ *
5
+ * 2026-10-26 — Claude Code (Opus 5.5), at Bob's direction ("fold the other
6
+ * address regexes into the shared functions"). Before this, about a dozen
7
+ * call sites each carried their own address regex: the send paths, trust,
8
+ * the store, list-identity, compose, the editor's link detection, contacts,
9
+ * groups and the CLI. They disagreed on which characters an address may
10
+ * hold, and the narrowest one (`[\w.+-]+@`) cut
11
+ * `bernardgreenberg=msn.com@groups.io` to `msn.com@groups.io` in RCPT TO.
12
+ * Parsing is structural here — split the header on top-level commas, take
13
+ * the `<…>` part or the bare entry — so no function guesses at the
14
+ * characters of a local part.
15
+ *
16
+ * Pure, no I/O, browser-safe.
17
+ */
18
+ /** Split an address header on top-level commas (outside quotes and angle
19
+ * brackets; `\` escapes inside quotes honored). Moved here from index.ts. */
20
+ export function splitAddressHeader(value) {
21
+ const parts = [];
22
+ let cur = "";
23
+ let inAngle = false;
24
+ let inQuote = false;
25
+ for (let i = 0; i < value.length; i++) {
26
+ const ch = value[i];
27
+ if (inQuote && ch === "\\" && i + 1 < value.length) {
28
+ cur += ch + value[++i];
29
+ continue;
30
+ }
31
+ if (ch === '"' && !inAngle)
32
+ inQuote = !inQuote;
33
+ else if (ch === "<" && !inQuote)
34
+ inAngle = true;
35
+ else if (ch === ">" && !inQuote)
36
+ inAngle = false;
37
+ if (ch === "," && !inAngle && !inQuote) {
38
+ parts.push(cur);
39
+ cur = "";
40
+ continue;
41
+ }
42
+ cur += ch;
43
+ }
44
+ if (cur.trim())
45
+ parts.push(cur);
46
+ return parts.map(p => p.trim()).filter(Boolean);
47
+ }
48
+ /** Text on both sides of the last `@`: the least an addr-spec must have. */
49
+ function hasAddrShape(spec) {
50
+ const at = spec.lastIndexOf("@");
51
+ return at > 0 && at < spec.length - 1;
52
+ }
53
+ /** Every entry of an address header that carries an addr-spec, with its
54
+ * display name. Entries with no address (a bare name, an empty group like
55
+ * `Undisclosed recipients:;`) are left out. */
56
+ export function parseAddressList(value) {
57
+ if (!value)
58
+ return [];
59
+ const out = [];
60
+ for (const part of splitAddressHeader(value)) {
61
+ const angle = /<([^<>]*)>\s*$/.exec(part);
62
+ let name = "";
63
+ let spec;
64
+ if (angle) {
65
+ spec = angle[1].trim();
66
+ name = part.slice(0, angle.index).replace(/\([^()]*\)/g, "").trim();
67
+ if (name.length >= 2 && name.startsWith('"') && name.endsWith('"'))
68
+ name = name.slice(1, -1).replace(/\\(.)/g, "$1");
69
+ }
70
+ else {
71
+ // No angle brackets: an addr-spec has no unquoted blanks, so when
72
+ // the entry has words around it ("mailing list x@y", a sloppy
73
+ // "Bob bob@x.com") the address is the word that has the shape.
74
+ const words = part.replace(/\([^()]*\)/g, " ").trim().split(/\s+/);
75
+ spec = words.find(hasAddrShape) || "";
76
+ }
77
+ if (hasAddrShape(spec))
78
+ out.push({ name, address: spec });
79
+ }
80
+ return out;
81
+ }
82
+ /** The first entry of an address header value, or null. */
83
+ export function parseAddress(value) {
84
+ return parseAddressList(value)[0] || null;
85
+ }
86
+ /** The addr-spec of every entry — what SMTP RCPT TO / MAIL FROM must carry.
87
+ *
88
+ * 2026-10-25: replaced the `/[\w.+-]+@[\w.-]+/g` scan that all three send
89
+ * paths carried, which omitted characters RFC 5322 allows in a local part
90
+ * (`= ! # $ % & ' * / ? ^ { | } ~`) and so turned
91
+ * `bernardgreenberg=msn.com@groups.io` into `msn.com@groups.io`. */
92
+ export function envelopeAddresses(value) {
93
+ return parseAddressList(value).map(a => a.address);
94
+ }
95
+ /** The first addr-spec in a header value (`"Name" <a@b>`, `a@b (comment)`,
96
+ * or a bare `a@b`), or "". Case kept; callers that compare lowercase it. */
97
+ export function firstAddress(value) {
98
+ return parseAddressList(value)[0]?.address || "";
99
+ }
100
+ /** Is `s` — a bare addr-spec, no display name — plausibly deliverable?
101
+ * Something before the last `@`, a dotted domain after it, and no blanks or
102
+ * angle brackets anywhere. The rule all five copies of
103
+ * `/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/` encoded, written without a pattern
104
+ * whose domain part could be split two ways. Any character RFC 5322 allows
105
+ * in a local part passes. */
106
+ export function isValidAddress(s) {
107
+ const t = (s || "").trim();
108
+ if (!t || /[\s<>]/.test(t))
109
+ return false;
110
+ const at = t.lastIndexOf("@");
111
+ if (at <= 0)
112
+ return false;
113
+ const local = t.slice(0, at);
114
+ const domain = t.slice(at + 1);
115
+ if (local.includes("@") || domain.includes("@"))
116
+ return false;
117
+ const dot = domain.indexOf(".");
118
+ return dot > 0 && dot < domain.length - 1;
119
+ }
120
+ /** Regex SOURCE for an address appearing inside free text (prose, a display
121
+ * name) — where there is no header structure to split on, so a pattern is
122
+ * the only tool. Local part: the RFC 5322 atext characters plus dots; domain:
123
+ * dotted labels. Each piece matches one way only (`@` and `.` are not in the
124
+ * classes they separate). Exported as a source string so a caller can embed
125
+ * it in a larger alternation (the spell checker's skip spans).
126
+ * (2026-10-26, Claude Code (Opus 5.5): replaces the spell checker's
127
+ * `[\w.+\-]+@…` and displayNameClaimsOtherAddress's `[A-Za-z0-9._%+-]+@…`.) */
128
+ // The lookbehind lets a match START only where a run of local-part characters
129
+ // starts. Without it a long run with no `@` (a pasted base64 blob) was retried
130
+ // from every position inside it, re-reading the rest each time: 80,000
131
+ // characters took 5.3 s (measured 2026-10-26). With it each run is read once.
132
+ const LOCAL_CHARS = String.raw `\w.!#$%&'*+\/=?^\`{|}~\-`;
133
+ export const ADDRESS_IN_TEXT = String.raw `(?<![${LOCAL_CHARS}])[${LOCAL_CHARS}]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+`;
134
+ /** Every address-shaped run in free text. */
135
+ export function findAddressesInText(text) {
136
+ return (text || "").match(new RegExp(ADDRESS_IN_TEXT, "g")) || [];
137
+ }
138
+ //# sourceMappingURL=address.js.map
package/groups.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * names of other groups (recursive aliasing). Cycles are detected; depth
9
9
  * is capped at 10 nested levels.
10
10
  */
11
+ import { firstAddress } from "./address.js";
11
12
  /** Split a comma-or-semicolon recipient string into tokens. Respects
12
13
  * angle-bracket address blocks ("Name, Suffix <a@b>") so a comma inside
13
14
  * the display name doesn't split the entry. */
@@ -40,15 +41,15 @@ export function splitRecipients(raw) {
40
41
  * a display name)? Anything containing "@" with non-whitespace on both
41
42
  * sides counts. */
42
43
  export function isAddressToken(token) {
43
- return /^[^@\s][^@]*@[^@\s]+(\s|$)|<[^>]+@[^>]+>/.test(token) || /^[^\s<>@]+@[^\s<>@]+$/.test(token);
44
+ // 2026-10-26 — Claude Code (Opus 5.5): shared parser (address.ts) instead of
45
+ // its own patterns; "Name <a@b>" and a bare "a@b" both parse.
46
+ return !!firstAddress(token);
44
47
  }
45
48
  /** Extract the bare email address from a token. Returns the original token
46
49
  * lowercased if it has no angle-bracket form. */
47
50
  export function extractAddress(token) {
48
- const m = token.match(/<([^>]+)>/);
49
- if (m)
50
- return m[1].trim().toLowerCase();
51
- return token.trim().toLowerCase();
51
+ // 2026-10-26 — Claude Code (Opus 5.5): shared parser (address.ts).
52
+ return (firstAddress(token) || token).trim().toLowerCase();
52
53
  }
53
54
  /** Expand a recipient string by resolving group names against `groups`.
54
55
  * Group names take precedence: if a token matches a group name AND looks
package/index.d.ts CHANGED
@@ -427,20 +427,6 @@ export declare function formatAddressListForHeader(list: {
427
427
  name?: string;
428
428
  address?: string;
429
429
  }[] | undefined): string;
430
- /** The addr-spec of every entry in an address header — what SMTP RCPT TO /
431
- * MAIL FROM must carry. One per comma-separated entry (commas inside quotes
432
- * and angle brackets don't split): the `<…>` part when there is one, else the
433
- * entry itself minus any `(comment)`. Entries without an `@` are dropped.
434
- *
435
- * 2026-10-25 — Claude Code (Opus 5.5), at Bob's direction. Replaces the
436
- * `/[\w.+-]+@[\w.-]+/g` scan that all three send paths (desktop mailx-imap,
437
- * Android android-bootstrap and sync-manager) carried as copies. That class
438
- * omits characters RFC 5322 allows in a local part (`= ! # $ % & ' * / ? ^ { | } ~`),
439
- * so `bernardgreenberg=msn.com@groups.io` (groups.io's relay address for a
440
- * member) matched as `msn.com@groups.io`: the To header was right, the
441
- * envelope was wrong, and groups.io bounced "The group 'msn.com' does not
442
- * exist". Taking the addr-spec whole avoids guessing at its characters. */
443
- export declare function envelopeAddresses(value: string): string[];
444
430
  /** What an SMTP send needs from a finished RFC 822 message. */
445
431
  export interface OutgoingEnvelope {
446
432
  /** RCPT TO: every addr-spec in To, Cc and Bcc. */
@@ -629,6 +615,8 @@ export declare function parseInvite(ics: string): ParsedInvite | null;
629
615
  * case specially").
630
616
  */
631
617
  export declare function inviteHasGoogleRsvp(bodyHtml: string): boolean;
618
+ export { splitAddressHeader, parseAddressList, parseAddress, envelopeAddresses, firstAddress, isValidAddress, ADDRESS_IN_TEXT, findAddressesInText } from "./address.js";
619
+ export type { ParsedAddress } from "./address.js";
632
620
  export { readListIdentity } from "./list-identity.js";
633
621
  export type { ListIdentity, ListHeaderLine } from "./list-identity.js";
634
622
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -221,61 +221,6 @@ export function formatAddressForHeader(name, address) {
221
221
  export function formatAddressListForHeader(list) {
222
222
  return (list || []).map(a => formatAddressForHeader(a.name, a.address || "")).join(", ");
223
223
  }
224
- /** Split an address header on top-level commas (outside quotes and angle
225
- * brackets; `\` escapes inside quotes honored). */
226
- function splitAddressHeader(value) {
227
- const parts = [];
228
- let cur = "";
229
- let inAngle = false;
230
- let inQuote = false;
231
- for (let i = 0; i < value.length; i++) {
232
- const ch = value[i];
233
- if (inQuote && ch === "\\" && i + 1 < value.length) {
234
- cur += ch + value[++i];
235
- continue;
236
- }
237
- if (ch === '"' && !inAngle)
238
- inQuote = !inQuote;
239
- else if (ch === "<" && !inQuote)
240
- inAngle = true;
241
- else if (ch === ">" && !inQuote)
242
- inAngle = false;
243
- if (ch === "," && !inAngle && !inQuote) {
244
- parts.push(cur);
245
- cur = "";
246
- continue;
247
- }
248
- cur += ch;
249
- }
250
- if (cur.trim())
251
- parts.push(cur);
252
- return parts.map(p => p.trim()).filter(Boolean);
253
- }
254
- /** The addr-spec of every entry in an address header — what SMTP RCPT TO /
255
- * MAIL FROM must carry. One per comma-separated entry (commas inside quotes
256
- * and angle brackets don't split): the `<…>` part when there is one, else the
257
- * entry itself minus any `(comment)`. Entries without an `@` are dropped.
258
- *
259
- * 2026-10-25 — Claude Code (Opus 5.5), at Bob's direction. Replaces the
260
- * `/[\w.+-]+@[\w.-]+/g` scan that all three send paths (desktop mailx-imap,
261
- * Android android-bootstrap and sync-manager) carried as copies. That class
262
- * omits characters RFC 5322 allows in a local part (`= ! # $ % & ' * / ? ^ { | } ~`),
263
- * so `bernardgreenberg=msn.com@groups.io` (groups.io's relay address for a
264
- * member) matched as `msn.com@groups.io`: the To header was right, the
265
- * envelope was wrong, and groups.io bounced "The group 'msn.com' does not
266
- * exist". Taking the addr-spec whole avoids guessing at its characters. */
267
- export function envelopeAddresses(value) {
268
- if (!value)
269
- return [];
270
- const out = [];
271
- for (const part of splitAddressHeader(value)) {
272
- const angle = /<([^<>]*)>\s*$/.exec(part);
273
- const spec = (angle ? angle[1] : part.replace(/\([^()]*\)/g, "")).trim();
274
- if (spec.includes("@"))
275
- out.push(spec);
276
- }
277
- return out;
278
- }
279
224
  /** Build the SMTP envelope and the transmitted copy of an outgoing message.
280
225
  * ONE implementation for every send path (desktop mailx-imap sendRaw, Android
281
226
  * android-bootstrap and sync-manager), which each used to carry their own copy
@@ -667,8 +612,9 @@ export function displayNameClaimsOtherAddress(name, address) {
667
612
  return null;
668
613
  const real = (address || "").trim().toLowerCase();
669
614
  // Deliberately loose: anything shaped like local@domain.tld inside the name.
670
- const m = n.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g);
671
- if (!m)
615
+ // 2026-10-26 — Claude Code (Opus 5.5): shared free-text pattern (address.ts).
616
+ const m = findAddressesInText(n);
617
+ if (!m.length)
672
618
  return null;
673
619
  const realDomain = real.split("@").pop() || "";
674
620
  for (const claimed of m) {
@@ -907,6 +853,9 @@ export function inviteHasGoogleRsvp(bodyHtml) {
907
853
  return false;
908
854
  return /calendar\.google\.com\/calendar\/event\?action=RESPOND/i.test(bodyHtml);
909
855
  }
856
+ // Address parsing: one splitter, one notion of "is this an address".
857
+ import { splitAddressHeader, envelopeAddresses, findAddressesInText } from "./address.js";
858
+ export { splitAddressHeader, parseAddressList, parseAddress, envelopeAddresses, firstAddress, isValidAddress, ADDRESS_IN_TEXT, findAddressesInText } from "./address.js";
910
859
  // Delivery address + mailing-list identity for reply's From (desktop + Android).
911
860
  export { readListIdentity } from "./list-identity.js";
912
861
  //# sourceMappingURL=index.js.map
package/list-identity.js CHANGED
@@ -20,6 +20,7 @@
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
+ import { firstAddress } from "./address.js";
23
24
  /** Value of every occurrence of a header, unfolded, in message order. */
24
25
  function headerValues(lines, name) {
25
26
  const want = name.toLowerCase();
@@ -33,8 +34,9 @@ function headerValues(lines, name) {
33
34
  }
34
35
  return out;
35
36
  }
37
+ /** First addr-spec in a header value, lowercased (shared parser: address.ts). */
36
38
  function bareAddress(value) {
37
- return ((value || "").match(/[^\s<>,;:"]+@[^\s<>,;:"]+/)?.[0] || "").toLowerCase();
39
+ return firstAddress(value || "").toLowerCase();
38
40
  }
39
41
  /** qmail/ezmlm, and groups.io after them, stamp `Delivered-To: mailing list
40
42
  * <addr>` on the copy they fan out, to catch mail loops. It names the LIST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.js CHANGED
@@ -28,6 +28,7 @@
28
28
  * be you. Forwarded mail fails SPF constantly, and flagging that would be
29
29
  * exactly the cry-wolf banner this is trying not to be.
30
30
  */
31
+ import { firstAddress } from "./address.js";
31
32
  /** What the reader can vouch for, and the allowlist.jsonc key each one is
32
33
  * written to. One table, shared by the desktop service and the Android
33
34
  * web-service, so "trust X" cannot land in different keys on the two
@@ -67,9 +68,12 @@ function headerAll(lines, name) {
67
68
  }
68
69
  return out;
69
70
  }
71
+ // 2026-10-26 — Claude Code (Opus 5.5): the addr-spec comes from the shared
72
+ // parser (address.ts) instead of a first-`@`-token scan. The scan took the
73
+ // first token holding an `@` ANYWHERE in the value, so `"ceo@bank.com"
74
+ // <x@evil.example>` read as ceo@bank.com — the display name, not the address.
70
75
  function bare(addr) {
71
- const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
72
- return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
76
+ return (firstAddress(addr || "") || addr || "").toLowerCase().replace(/[.,;]+$/, "");
73
77
  }
74
78
  /** Do two hostnames belong to the same organisation?
75
79
  *