diff --git a/.gitignore b/.gitignore index 82bc380..175b909 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ node_modules package-lock.json .claude +.qlty/* +!.qlty/qlty.toml diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 0000000..8b0c1f4 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,19 @@ +exclude_patterns = [ + ".release/**", + "test/**", + "eslint.config.mjs", + "*.config.mjs", + "scripts/**", + "dist/**", + "coverage/**", + "node_modules/**", +] + +[smells.file_complexity] +threshold = 300 + +[smells.return_statements] +enabled = false + +[smells.function_complexity] +threshold = 30 diff --git a/.release b/.release index af474bd..9069128 160000 --- a/.release +++ b/.release @@ -1 +1 @@ -Subproject commit af474bd435cf7075a74643dbe6278ac68917f7c4 +Subproject commit 9069128a8cbbab42c2b50989c630700397c4aba1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8194076..e52d740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [3.1.6] - 2026-06-05 + +- feat: added `Address#toJSON` #9 +- feat: `postel: true` relaxes the 64-octet local-part limit to 998 + octets, accepting bloated VERP/SRS forwarding addresses #9 + ### [3.1.5] - 2026-05-26 - fix: `Group.format()` now emits the RFC 5322 trailing `;` @@ -75,3 +81,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [3.1.3]: https://github.com/haraka/email-address/releases/tag/v3.1.3 [3.1.4]: https://github.com/haraka/email-address/releases/tag/v3.1.4 [3.1.5]: https://github.com/haraka/email-address/releases/tag/v3.1.5 +[3.1.6]: https://github.com/haraka/email-address/releases/tag/v3.1.6 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 0e1bb8b..bca521c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ This handcrafted artisanal software is brought to you by: -|
msimerson (8) | +|
msimerson (9) | | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is generated by [.release](https://github.com/msimerson/.release). diff --git a/README.md b/README.md index 4a13458..bf9ef4a 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,9 @@ The package's `exports` map resolves to the right file automatically: ### Envelope options -| Option | Type | Default | Effect | -| -------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `postel` | `boolean` | `false` | Be liberal in what you accept. When `true`: (1) malformed `[IPv6:…]` bodies that fail the strict §4.1.3 grammar fall back to the General-address-literal path and are accepted as-is; (2) the 256-octet RFC 5321 §4.5.3.1.3 path limit is raised to the 998-octet SMTP text-line maximum (§4.5.3.1.6). | +| Option | Type | Default | Effect | +| -------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `postel` | `boolean` | `false` | Be liberal in what you accept. When `true`: (1) malformed `[IPv6:…]` bodies that fail the strict §4.1.3 grammar fall back to the General-address-literal path and are accepted as-is; (2) the 256-octet RFC 5321 §4.5.3.1.3 path limit and the 64-octet §4.5.3.1.1 local-part limits are raised to the 998-octet SMTP text-line maximum (§4.5.3.1.6) | ### Header options @@ -214,7 +214,7 @@ This parser targets strict conformance to the SMTP envelope grammar by default. | [RFC 5321 §4.1.3][rfc5321] | IPv4 address literal | **Conformant.** Strict octet validation (0-255, no leading zeros). | | [RFC 5321 §4.1.3][rfc5321] | IPv6 address literal | **Conformant (strict).** The `IPv6:` tag is validated against the IPv6-full / IPv6-comp / IPv6v4-full / IPv6v4-comp productions. The `postel: true` option opts back into the lax fallback. | | [RFC 5321 §4.1.2][rfc5321] | Source routes / ADL | **Variance (discarded).** Source routes parse correctly but are silently dropped; only the final mailbox is retained. RFC 5321 deprecates source routes; preserve externally if you need them. | -| [RFC 5321 §4.5.3.1.1][rfc5321] | 64-octet local-part | **Conformant.** Bytes counted as UTF-8 octets. | +| [RFC 5321 §4.5.3.1.1][rfc5321] | 64-octet local-part | **Conformant.** Bytes counted as UTF-8 octets. `postel: true` raises the cap to 998 octets (the §4.5.3.1.6 SMTP text-line maximum). | | [RFC 5321 §4.5.3.1.2][rfc5321] | 255-octet domain | **Conformant.** Checked before any IDN encoding. | | [RFC 5321 §4.5.3.1.3][rfc5321] | 256-octet Path | **Conformant.** Enforced on the input string. `postel: true` raises the cap to 998 octets (the §4.5.3.1.6 SMTP text-line maximum). | | [RFC 1035 §2.3.4][rfc1035] / [RFC 5321 §4.5.3.1.1][rfc5321] | 63-octet label | **Conformant.** Each sub-domain label is rejected if its UTF-8 length exceeds 63 octets. | diff --git a/dist/cjs/lib/address.cjs b/dist/cjs/lib/address.cjs index 3da6614..0240c1a 100644 --- a/dist/cjs/lib/address.cjs +++ b/dist/cjs/lib/address.cjs @@ -151,9 +151,14 @@ class Address { let domainpart = result.domain this.original_host = domainpart - // RFC-5321 §4.5.3.1.1: 64 octet local-part - if (Buffer.byteLength(result.local_part, 'utf8') > 64) { - throw new Error('RFC-5321 local-part exceeds 64 octets') + // RFC-5321 §4.5.3.1.1: 64 octet local-part. Under `postel: true` the + // cap is raised to the §4.5.3.1.6 SMTP text-line maximum so bloated + // VERP/SRS forwarding local-parts seen in the wild are accepted, and + // so the envelope parser matches the header parser, which never + // enforced this limit. + const maxLocalPart = this.opts?.postel ? 998 : 64 + if (Buffer.byteLength(result.local_part, 'utf8') > maxLocalPart) { + throw new Error(`RFC-5321 local-part exceeds ${maxLocalPart} octets`) } // RFC-5321 §4.5.3.1.2: 255 octet domain (defense-in-depth; the // 256-octet path cap above makes this branch unreachable today). @@ -206,6 +211,26 @@ class Address { toString() { return this.format() } + + // Serialized addresses are persisted (e.g. Haraka's outbound queue) and + // rehydrated via `new Address(json)`, so the wire shape stays lean + // and stable. Envelope addresses carry no phrase/comment/group/opts and + // since only populated fields are emitted, this toJSON preserves the + // addr-rfc2821 wire shape. + toJSON() { + const { phrase, comment, group, original, original_host, host, user, is_utf8, _kind } = this + return { + ...(phrase && { phrase }), + ...(comment && { comment }), + ...(group && { group }), + original, + original_host, + host, + user, + ...(is_utf8 && { is_utf8 }), + ...(_kind && { _kind }), + } + } } function formatPhrase(phrase) { diff --git a/index.d.ts b/index.d.ts index 9049cd0..c91a432 100644 --- a/index.d.ts +++ b/index.d.ts @@ -78,6 +78,19 @@ export declare class Address { name(): string toString(): string + + /** Lean wire shape for persistence; omits empty header metadata and transient parser opts. */ + toJSON(): { + phrase?: string + comment?: string + group?: Group + original: string + original_host?: string + host: string + user: string + is_utf8?: boolean + _kind?: string + } } export declare class Group { diff --git a/lib/address.js b/lib/address.js index d91672b..3285d18 100644 --- a/lib/address.js +++ b/lib/address.js @@ -155,9 +155,14 @@ class Address { let domainpart = result.domain this.original_host = domainpart - // RFC-5321 §4.5.3.1.1: 64 octet local-part - if (Buffer.byteLength(result.local_part, 'utf8') > 64) { - throw new Error('RFC-5321 local-part exceeds 64 octets') + // RFC-5321 §4.5.3.1.1: 64 octet local-part. Under `postel: true` the + // cap is raised to the §4.5.3.1.6 SMTP text-line maximum so bloated + // VERP/SRS forwarding local-parts seen in the wild are accepted, and + // so the envelope parser matches the header parser, which never + // enforced this limit. + const maxLocalPart = this.opts?.postel ? 998 : 64 + if (Buffer.byteLength(result.local_part, 'utf8') > maxLocalPart) { + throw new Error(`RFC-5321 local-part exceeds ${maxLocalPart} octets`) } // RFC-5321 §4.5.3.1.2: 255 octet domain (defense-in-depth; the // 256-octet path cap above makes this branch unreachable today). @@ -210,6 +215,26 @@ class Address { toString() { return this.format() } + + // Serialized addresses are persisted (e.g. Haraka's outbound queue) and + // rehydrated via `new Address(json)`, so the wire shape stays lean + // and stable. Envelope addresses carry no phrase/comment/group/opts and + // since only populated fields are emitted, this toJSON preserves the + // addr-rfc2821 wire shape. + toJSON() { + const { phrase, comment, group, original, original_host, host, user, is_utf8, _kind } = this + return { + ...(phrase && { phrase }), + ...(comment && { comment }), + ...(group && { group }), + original, + original_host, + host, + user, + ...(is_utf8 && { is_utf8 }), + ...(_kind && { _kind }), + } + } } function formatPhrase(phrase) { diff --git a/package.json b/package.json index 116ece0..719d36f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@haraka/email-address", - "version": "3.1.5", + "version": "3.1.6", "description": "RFC-5321 envelope and RFC-5322 header email-address parser", "author": { "name": "The Haraka Team", diff --git a/test/envelope-parsing.js b/test/envelope-parsing.js index 3177303..054281e 100644 --- a/test/envelope-parsing.js +++ b/test/envelope-parsing.js @@ -255,6 +255,29 @@ describe('Address parsing — round-trips and RFC-5321 length limits', () => { expectOk(`<${local}@b>`, { user: local, host: 'b' }) }) + it('local-part over 64 octets is rejected by default', () => { + assert.throws(() => new Address(`<${'a'.repeat(65)}@b>`), /local-part exceeds 64 octets/) + }) + + it('postel: true accepts an over-64-octet local-part (VERP/SRS, issue #8)', () => { + const local = + 'duo-srs0=y11n=D6=em3828.signin.autodesk.com=bounces+41143108-218e-user1234=test.com' + const input = `<${local}@mx1.mailhop.org>` + + assert.throws(() => new Address(input), /local-part exceeds 64 octets/) + + const address = new Address(input, { postel: true }) + assert.equal(address.user, local) + assert.equal(address.host, 'mx1.mailhop.org') + }) + + it('postel: true still enforces the 998-octet path limit', () => { + assert.throws( + () => new Address(`<${'a'.repeat(995)}@b>`, { postel: true }), + /path exceeds 998 octets/, + ) + }) + it('sub-domain of 63 octets is accepted', () => { const subdomain = 'a'.repeat(63) expectOk(``, { diff --git a/test/serialization.js b/test/serialization.js new file mode 100644 index 0000000..bd4e094 --- /dev/null +++ b/test/serialization.js @@ -0,0 +1,47 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { Address, parseFrom } from '../index.js' + +describe('Address#toJSON', () => { + it('emits only the addr-rfc2821 wire shape for an envelope address', () => { + const json = JSON.parse(JSON.stringify(new Address(''))) + assert.deepEqual(Object.keys(json), ['original', 'original_host', 'host', 'user']) + }) + + it('omits empty header metadata and transient opts', () => { + const json = JSON.parse(JSON.stringify(new Address('matt', 'tnpi.net'))) + assert.equal('phrase' in json, false) + assert.equal('comment' in json, false) + assert.equal('group' in json, false) + assert.equal('opts' in json, false) + }) + + it('carries is_utf8 when set', () => { + const json = JSON.parse(JSON.stringify(new Address('user', 'δοκιμή.gr'))) + assert.equal(json.is_utf8, true) + }) + + it('preserves a header display name and re-renders it', () => { + const addr = parseFrom('John Doe ')[0] + const json = JSON.parse(JSON.stringify(addr)) + assert.equal(json.phrase, 'John Doe') + + const reparsed = new Address(json) + assert.equal(reparsed.phrase, 'John Doe') + assert.equal(reparsed.toString(), 'John Doe ') + }) + + it('preserves a header comment', () => { + const addr = parseFrom('john@example.com (Johnny)')[0] + const reparsed = new Address(JSON.parse(JSON.stringify(addr))) + assert.equal(reparsed.comment, 'Johnny') + assert.equal(reparsed.toString(), addr.toString()) + }) + + it('round-trips a null reverse-path', () => { + const reparsed = new Address(JSON.parse(JSON.stringify(new Address('<>')))) + assert.equal(reparsed.isNull(), true) + assert.equal(reparsed.format(), '<>') + }) +})