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(), '<>')
+ })
+})