Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ node_modules

package-lock.json
.claude
.qlty/*
!.qlty/qlty.toml
19 changes: 19 additions & 0 deletions .qlty/qlty.toml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .release
Submodule .release updated 1 files
+43 −8 start.sh
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `;`
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This handcrafted artisanal software is brought to you by:

| <img height="80" src="https://avatars.githubusercontent.com/u/261635?v=4"><br><a href="https://github.com/msimerson">msimerson</a> (<a href="https://github.com/haraka/email-address/commits?author=msimerson">8</a>) |
| <img height="80" src="https://avatars.githubusercontent.com/u/261635?v=4"><br><a href="https://github.com/msimerson">msimerson</a> (<a href="https://github.com/haraka/email-address/commits?author=msimerson">9</a>) |
| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |

<sub>this file is generated by [.release](https://github.com/msimerson/.release).
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down
31 changes: 28 additions & 3 deletions dist/cjs/lib/address.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 28 additions & 3 deletions lib/address.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
23 changes: 23 additions & 0 deletions test/envelope-parsing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<u@${subdomain}.example.com>`, {
Expand Down
47 changes: 47 additions & 0 deletions test/serialization.js
Original file line number Diff line number Diff line change
@@ -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('<matt@tnpi.net>')))
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 <john@example.com>')[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 <john@example.com>')
})

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