mailauth 7.1.1 → 7.2.0

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/docs/bimi.md CHANGED
@@ -12,7 +12,7 @@ Before looking up a BIMI record, mailauth checks the requirements of section 7.1
12
12
  - DMARC passed, with an effective policy other than `none` and without `t=y`.
13
13
  - Neither the DMARC record of the author domain nor the one of its Organizational Domain has `p=none` or `sp=none`, or `p=quarantine` with a `pct` other than 100. A record found at the author domain itself does not hide a lax record of the Organizational Domain. `dmarc()` passes both records to `bimi()`, for a DMARC result built elsewhere `bimi()` looks up the Organizational Domain's record itself.
14
14
 
15
- The record is looked up at `<selector>._bimi.<author domain>` and then at `<selector>._bimi.<organizational domain>`, where the selector comes from the `BIMI-Selector` header and defaults to `default`. A custom selector does not fall back to `default`. TXT records that do not start with `v=BIMI1` are ignored, several BIMI records are a failure.
15
+ The record is looked up at `<selector>._bimi.<author domain>` and then at `<selector>._bimi.<organizational domain>`, where the selector comes from the `BIMI-Selector` header and defaults to `default`. A `BIMI-Selector` header without `v=BIMI1` or without a valid `s=` selector is ignored, and `default` is used (sections 5.1 and 7.2). A custom selector does not fall back to `default`. TXT records that do not start with `v=BIMI1` are ignored, several BIMI records are a failure.
16
16
 
17
17
  ```javascript
18
18
  const { authenticate } = require('mailauth');
@@ -26,14 +26,29 @@ const { bimi } = await authenticate(message, {
26
26
 
27
27
  ## Result Object Fields
28
28
 
29
- | Field | Type | Presence | Description |
30
- | ------------ | -------- | ----------------- | ----------------------------------------------------- |
31
- | `status` | `object` | Always | Resolution status object (see below) |
32
- | `location` | `string` | Found | HTTPS URL for the logo SVG file (from `l=` tag) |
33
- | `authority` | `string` | Found | HTTPS URL for the VMC/CMC certificate (from `a=` tag) |
34
- | `preference` | `string` | When `p=` present | Logo preference value from DNS record |
35
- | `rr` | `string` | Found | Raw BIMI DNS TXT record |
36
- | `info` | `string` | Always | Formatted Authentication-Results header value |
29
+ | Field | Type | Presence | Description |
30
+ | ------------ | -------- | -------------------- | ------------------------------------------------------------------------------------------------- |
31
+ | `status` | `object` | Always | Resolution status object (see below) |
32
+ | `location` | `string` | Found | HTTPS URL for the logo SVG file (from `l=` tag) |
33
+ | `authority` | `string` | Found | HTTPS URL for the VMC/CMC certificate (from `a=` tag) |
34
+ | `preference` | `string` | When `avp=` is valid | Avatar preference from the `avp=` tag, `personal` or `brand`. Other values are ignored |
35
+ | `rr` | `string` | Found | Raw BIMI DNS TXT record |
36
+ | `warnings` | `array` | When there are any | Assertion Record deviations accepted because `strict` is not set, see [Strict Mode](#strict-mode) |
37
+ | `info` | `string` | Always | Formatted Authentication-Results header value |
38
+
39
+ ## Strict Mode
40
+
41
+ The Assertion Record uses the DKIM tag-list syntax, and the draft says that receivers must not fix syntax or capitalization errors (section 4.3). By default mailauth accepts the deviations below in the record and in the `BIMI-Selector` header, and lists them in `warnings`. With `strict: true` they are handled exactly:
42
+
43
+ | Input | Default | `strict: true` |
44
+ | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ |
45
+ | `v=` value in another case, such as `v=bimi1` | used, warning `record-version-case` | record ignored (`none` if it is the only one) |
46
+ | Upper case tag names, such as `L=` | used as the lower case tag, warning `record-tag-case` | unknown tag, ignored |
47
+ | Duplicate tags or other tag-list syntax errors | last duplicate wins, warning `record-syntax` | `fail` (`invalid syntax in dns record`) |
48
+ | Unencoded comma in `l=` or `a=` | used, warning `uri-comma` | `fail` (`invalid location value...`, `invalid authority value...`) |
49
+ | `l=` URI ending in another image format suffix, such as `.png` | used, warning `location-format` | `fail` (`unsupported image format in location value`) |
50
+ | `BIMI-Selector` header with `v=` in another case, `v=` not first, or a tag-list syntax error | used, warning `selector-version-case` or `selector-syntax` | header ignored, `default` is used |
51
+ | `BIMI-Selector` selector with underscores | used, warning `selector-syntax` | header ignored, `default` is used |
37
52
 
38
53
  ## status Object
39
54
 
@@ -53,10 +68,10 @@ const { bimi } = await authenticate(message, {
53
68
 
54
69
  ### status.policy Object
55
70
 
56
- | Field | Type | Description |
57
- | --------------- | -------- | -------------------------------------------------- |
58
- | `authority` | `string` | VMC validation status (`"none"` before validation) |
59
- | `authority-uri` | `string` | URL of the authority evidence document |
71
+ | Field | Type | Description |
72
+ | --------------- | -------- | ---------------------------------------------------------------------------------------- |
73
+ | `authority` | `string` | VMC validation status, `"none"` from `bimi()`, `"pass"` or `"fail"` from `validateVMC()` |
74
+ | `authority-uri` | `string` | URL of the authority evidence document |
60
75
 
61
76
  ## Result Values
62
77
 
@@ -89,15 +104,16 @@ The `status.comment` field explains why BIMI was skipped:
89
104
 
90
105
  ## Fail Reasons
91
106
 
92
- | Comment | Description |
93
- | ------------------------------------------- | ------------------------------------------------------------------------ |
94
- | `"multiple BIMI-Selector headers"` | Message has more than one BIMI-Selector header |
95
- | `"missing bimi version in selector header"` | BIMI-Selector header missing `v=BIMI1` |
96
- | `"missing bimi version in dns record"` | DNS record missing `v=BIMI1` |
97
- | `"missing location value in dns record"` | Record has no `l=` value (and is not a declination), even if it has `a=` |
98
- | `"invalid location value in dns record"` | `l=` value is not a valid HTTPS URL |
99
- | `"invalid authority value in dns record"` | `a=` value is not a valid HTTPS URL |
100
- | `"multiple BIMI records for {name}"` | More than one `v=BIMI1` record at the name |
107
+ | Comment | Description |
108
+ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
109
+ | `"multiple BIMI-Selector headers"` | Message has more than one BIMI-Selector header |
110
+ | `"missing bimi version in dns record"` | DNS record missing `v=BIMI1` |
111
+ | `"missing location value in dns record"` | Record has no `l=` value (and is not a declination), even if it has `a=` |
112
+ | `"invalid location value in dns record"` | `l=` value is not a valid HTTPS URL with a domain name (IP addresses, `localhost` and single-label hosts are not accepted) |
113
+ | `"invalid authority value in dns record"` | `a=` value is not a valid HTTPS URL with a domain name |
114
+ | `"invalid syntax in dns record"` | Tag-list syntax error, such as a duplicate tag (strict mode) |
115
+ | `"unsupported image format in location value"` | `l=` ends with an image format suffix other than SVG or SVGZ (strict mode) |
116
+ | `"multiple BIMI records for {name}"` | More than one `v=BIMI1` record at the name |
101
117
 
102
118
  ## Temperror Reasons
103
119
 
@@ -117,24 +133,44 @@ const bimiResult = await bimi(data);
117
133
  const vmcResult = await validateVMC(bimiResult, options);
118
134
  ```
119
135
 
136
+ ### Options
137
+
138
+ | Option | Type | Default | Description |
139
+ | ----------------- | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------- |
140
+ | `strict` | `boolean` | `false` | Validate the logo files exactly against the SVG Tiny PS profile, see [Logo SVG Validation](#logo-svg-validation) |
141
+ | `now` | `Date` | now | Time used for the certificate validity checks (passed to `@postalsys/vmc`) |
142
+ | `maxLogoSize` | `number` | `65536` | Maximum size of the logo file in bytes, also the limit for an uncompressed SVGZ file |
143
+ | `maxEvidenceSize` | `number` | `262144` | Maximum size of the evidence document in bytes |
144
+ | `timeout` | `number` | `30000` | Time limit in milliseconds for each download, including redirects and the response body |
145
+ | `dispatcher` | `Dispatcher` | | undici dispatcher for the downloads, for example to use a proxy |
146
+
147
+ Downloads only use HTTPS URLs whose host is a domain name. Redirects are followed up to 3 times when the target is such a URL as well, and the body is read up to the size limit only (section 7.6 of the draft allows a retrieval limit). The limits also apply to `locationPath` and `authorityPath` buffers.
148
+
120
149
  ### VMC Result Object
121
150
 
122
- | Field | Type | Description |
123
- | ----------- | -------- | ------------------------------------------------------- |
124
- | `location` | `object` | Logo file fetch result |
125
- | `authority` | `object` | VMC/CMC fetch and validation result |
126
- | `headers` | `object` | Ready-to-use email headers (only on validation success) |
151
+ | Field | Type | Description |
152
+ | ----------- | -------- | ----------------------------------------------------------------- |
153
+ | `location` | `object` | Logo file fetch result |
154
+ | `authority` | `object` | VMC/CMC fetch and validation result |
155
+ | `headers` | `object` | Ready-to-use email headers (only on validation success) |
156
+ | `status` | `object` | Updated BIMI status, when `bimiData.status.result` was `pass` |
157
+ | `info` | `string` | Updated Authentication-Results entry, along with `status` |
158
+ | `warnings` | `array` | Accepted SVG Tiny PS profile deviations of the logo files, if any |
159
+
160
+ The `info` of `bimi()` covers record discovery only, it reports `bimi=pass policy.authority=none` before anything is downloaded. When `validateVMC()` is used, use its `info` for the Authentication-Results header instead (section 7.7 of the draft): the result is `fail` when the indicator can not be retrieved (`failed to retrieve indicator`), fails SVG validation (`invalid SVG indicator`), the evidence document does not validate (`evidence document validation failed`) or its logo does not match the indicator (`indicator does not match evidence document`). When the record has `a=`, `policy.authority` is `pass` if the evidence document validated and its logo matches, and `fail` otherwise. `authenticate()` does not download anything, so its `bimi.info` is the discovery result.
127
161
 
128
162
  ### location Object
129
163
 
130
- | Field | Type | Description |
131
- | ----------- | --------- | ------------------------------------ |
132
- | `url` | `string` | Logo URL |
133
- | `success` | `boolean` | Whether fetch succeeded |
134
- | `logoFile` | `string` | Base64-encoded logo SVG (on success) |
135
- | `error` | `object` | Error details (on failure) |
136
- | `hashAlgo` | `string` | Hash algorithm used for verification |
137
- | `hashValue` | `string` | Calculated hash of the logo file |
164
+ | Field | Type | Description |
165
+ | ----------- | --------- | -------------------------------------------------------------- |
166
+ | `url` | `string` | Logo URL |
167
+ | `success` | `boolean` | Whether the logo was fetched and passed SVG validation |
168
+ | `logoFile` | `string` | Base64-encoded logo SVG, uncompressed if it was SVGZ (success) |
169
+ | `error` | `object` | Error details (on failure) |
170
+ | `hashAlgo` | `string` | Hash algorithm used for verification |
171
+ | `hashValue` | `string` | Calculated hash of the logo file |
172
+
173
+ The logo downloaded from `l=` is always checked with the SVG validator (section 7.6 of the draft), also when the record has no `a=` evidence document. An SVGZ file is uncompressed first. A logo that fails validation gives `success: false` with the error code `SVG_VALIDATION_FAILED`, and no headers are generated.
138
174
 
139
175
  ### authority Object
140
176
 
@@ -149,13 +185,13 @@ const vmcResult = await validateVMC(bimiResult, options);
149
185
 
150
186
  ### headers Object
151
187
 
152
- Present only when VMC validation succeeds. Contains ready-to-use email headers.
188
+ Present only when the logo passes SVG validation and, if the record has an `a=` tag, the evidence document validates and its logo hash matches. Contains ready-to-use email headers.
153
189
 
154
- | Field | Type | Presence | Description |
155
- | ------------ | -------- | ---------------- | -------------------------------------------------- |
156
- | `indicator` | `string` | Always | BIMI-Indicator header with base64-encoded SVG logo |
157
- | `location` | `string` | Always | BIMI-Location header with logo URL |
158
- | `preference` | `string` | `p=` tag present | BIMI-Logo-Preference header |
190
+ | Field | Type | Presence | Description |
191
+ | ------------ | -------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- |
192
+ | `indicator` | `string` | Always | BIMI-Indicator header with base64-encoded SVG logo |
193
+ | `location` | `string` | Always | BIMI-Location header with the logo URL, and the `a=` evidence document URL when it was verified |
194
+ | `preference` | `string` | `preference` is `personal` or `brand` | BIMI-Logo-Preference header, for example `BIMI-Logo-Preference: avp=brand` |
159
195
 
160
196
  These headers should be added to messages after successful BIMI validation. The MTA should:
161
197
 
@@ -164,14 +200,65 @@ These headers should be added to messages after successful BIMI validation. The
164
200
 
165
201
  ### VMC Error Codes
166
202
 
167
- | Code | Description |
168
- | ----------------------- | -------------------------------------- |
169
- | `HTTP_REQUEST_FAILED` | HTTP request failed |
170
- | `MISSING_VMC_LOGO` | VMC does not contain a logo file |
171
- | `INVALID_MEDIATYPE` | Logo media type is not `image/svg+xml` |
172
- | `INVALID_LOGO_HASH` | Logo hash does not match certificate |
173
- | `SVG_VALIDATION_FAILED` | SVG file failed validation |
174
- | `VMC_DOMAIN_MISMATCH` | Domain not found in certificate SAN |
203
+ | Code | Description |
204
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
205
+ | `HTTP_REQUEST_FAILED` | HTTP request failed |
206
+ | `HTTP_REQUEST_TIMEOUT` | The download took longer than `timeout` |
207
+ | `HTTP_REDIRECT_NOT_ALLOWED` | A redirect to a URL that is not HTTPS with a domain name, or more than 3 redirects. `redirect` has the target |
208
+ | `INVALID_URL` | The URL is not HTTPS, or its host is an IP address, `localhost` or a single label |
209
+ | `FILE_TOO_LARGE` | The file, or the uncompressed SVGZ logo, is larger than the size limit |
210
+ | `MISSING_VMC_LOGO` | VMC does not contain a logo file |
211
+ | `INVALID_MEDIATYPE` | Logo media type is not `image/svg+xml` |
212
+ | `INVALID_LOGO_HASH` | Logo hash does not match certificate |
213
+ | `SVG_VALIDATION_FAILED` | SVG file failed validation, `details` has the validator's error code (`INVALID_SVGZ_FILE` for an SVGZ file that can not be uncompressed) |
214
+ | `VMC_DOMAIN_MISMATCH` | Domain not found in certificate SAN |
215
+
216
+ ## Logo SVG Validation
217
+
218
+ `validateBimiSvg(logo, options)` (also `validateSvg` in `mailauth/lib/bimi/validate-svg`) checks a logo against the SVG Tiny Portable/Secure profile of [draft-svg-tiny-ps-abrotman](https://datatracker.ietf.org/doc/html/draft-svg-tiny-ps-abrotman). It returns `true` or throws an error with one of the codes below. `validateVMC()` runs it on the logo from `l=` and on the logo embedded in the evidence document, with the `strict` option of `validateVMC()`, and lists the warnings in `warnings` of its result.
219
+
220
+ ```javascript
221
+ const { validateBimiSvg } = require('mailauth');
222
+
223
+ const warnings = [];
224
+ validateBimiSvg(logo, { strict: false, warnings }); // throws if the logo is not valid
225
+ ```
226
+
227
+ The root element must be `svg` in the `http://www.w3.org/2000/svg` namespace. Some rules of the profile do not make a logo unsafe. By default a logo that breaks them is accepted and a marker is added to the `warnings` array, with `strict: true` it is rejected:
228
+
229
+ | Warning | Rule |
230
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
231
+ | `svg-version` | The root element has `version="1.2"` (`INVALID_SVG_VERSION`) |
232
+ | `svg-title-count` | Only one `title` child of the root element (`LOGO_MULTIPLE_TITLES`) |
233
+ | `svg-title-position` | `title` is the first child of the root element, and not used elsewhere (`LOGO_INVALID_ELEMENT`) |
234
+ | `svg-attribute-value` | `zoomAndPan`, `externalResourcesRequired`, `focusable`, `snapshotTime`, `playbackOrder`, `timelineBegin` and `editable` have their only allowed value (`LOGO_INVALID_ATTRIBUTE`) |
235
+ | `svg-empty-desc` | A `desc` element is not empty (`LOGO_INVALID_CONTENT`) |
236
+ | `svg-element` | Only the elements of the validation schema, not the other static SVG elements listed below (`LOGO_INVALID_ELEMENT`) |
237
+ | `svg-foreign-element` | No elements in other namespaces outside `metadata` (`LOGO_INVALID_ELEMENT`) |
238
+ | `svg-metadata-content` | `metadata` has text content only (`LOGO_INVALID_ELEMENT`) |
239
+
240
+ The rules below apply in both modes. Namespaces are resolved, and elements are checked against an allowlist: the element set of the validation schema in section 7 of the profile, plus static SVG 1.1 rendering elements found in published logos (`style`, `clipPath`, `mask`, `pattern`, `symbol`, `marker`, `tspan`, `textPath`, filter primitives other than `feImage`, and a few font elements). Script, interactivity, linking, multimedia, `image`, `switch`, `foreignObject` and animation elements are rejected, as are XHTML and MathML elements and SVG elements inside `metadata`. Elements in other namespaces, such as RDF metadata, are allowed by default.
241
+
242
+ These are rejected in every element and namespace:
243
+
244
+ - event handler attributes (any attribute whose local name starts with `on`)
245
+ - `href` in any namespace (XLink, no namespace, or another prefix) unless it is a same-document `#fragment` reference
246
+ - `url()` references that are not `#fragment`, `@import`, CSS image functions and CSS escapes, in `style` elements, `style` attributes and presentation attributes
247
+ - a non-empty `xml:base`
248
+ - processing instructions such as `xml-stylesheet`, DTDs with an internal subset, and entity references other than the predefined ones and character references
249
+ - markup that an HTML parser would read differently: `<` inside CDATA sections, comments that contain `<`, `>` or `--`, and a `font` element with `color`, `face` or `size` attributes
250
+
251
+ | Code | Description |
252
+ | ------------------------- | ------------------------------------------------------------------------ |
253
+ | `INVALID_XML_FILE` | Not well-formed XML, or XML features that are not allowed (DTD, entity) |
254
+ | `INVALID_SVG_FILE` | The root element is not `svg` in the SVG namespace |
255
+ | `INVALID_BASE_PROFILE` | `baseProfile` is not `tiny-ps` |
256
+ | `LOGO_MISSING_TITLE` | No `title` child of the root element, or it is empty |
257
+ | `LOGO_INVALID_ROOT_ATTRS` | The root element has `x` or `y` attributes |
258
+ | `LOGO_INVALID_ELEMENT` | An element that is not allowed, `details.element` names it |
259
+ | `LOGO_INVALID_ATTRIBUTE` | An event handler attribute or `xml:base`, `details.attribute` names it |
260
+ | `LOGO_INCLUDES_REFERENCE` | An external reference, `details.link` has the value |
261
+ | `LOGO_INVALID_CONTENT` | A processing instruction, unexpected markup in text or comments, nesting |
175
262
 
176
263
  ## Example Output
177
264
 
@@ -192,8 +279,8 @@ These headers should be added to messages after successful BIMI validation. The
192
279
  },
193
280
  "location": "https://example.com/bimi/logo.svg",
194
281
  "authority": "https://example.com/bimi/vmc.pem",
195
- "preference": "self",
196
- "rr": "v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/vmc.pem; p=self",
282
+ "preference": "brand",
283
+ "rr": "v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/vmc.pem; avp=brand",
197
284
  "info": "bimi=pass header.selector=default header.d=example.com policy.authority=none policy.authority-uri=https://example.com/bimi/vmc.pem"
198
285
  }
199
286
  ```
@@ -283,8 +370,20 @@ These headers should be added to messages after successful BIMI validation. The
283
370
  },
284
371
  "headers": {
285
372
  "indicator": "BIMI-Indicator: PHN2ZyB4bWxucz0i...",
286
- "location": "BIMI-Location: v=BIMI1; l=https://example.com/bimi/logo.svg",
287
- "preference": "BIMI-Logo-Preference: self"
288
- }
373
+ "location": "BIMI-Location: v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/vmc.pem",
374
+ "preference": "BIMI-Logo-Preference: avp=brand"
375
+ },
376
+ "status": {
377
+ "result": "pass",
378
+ "header": {
379
+ "selector": "default",
380
+ "d": "example.com"
381
+ },
382
+ "policy": {
383
+ "authority": "pass",
384
+ "authority-uri": "https://example.com/bimi/vmc.pem"
385
+ }
386
+ },
387
+ "info": "bimi=pass policy.authority=pass policy.authority-uri=\"https://example.com/bimi/vmc.pem\" header.selector=default header.d=example.com"
289
388
  }
290
389
  ```
package/docs/dkim.md CHANGED
@@ -67,7 +67,7 @@ Each entry in the `results` array has the following structure:
67
67
  | ------------ | --------------- | --------------- | ----------------------------------------------------------------------------------------------------- |
68
68
  | `result` | `string` | Always | Verification result code (see below) |
69
69
  | `comment` | `string` | On error/info | Human-readable explanation |
70
- | `aligned` | `string\|false` | DKIM signatures | DMARC-aligned domain, or false |
70
+ | `aligned` | `string\|false` | DKIM signatures | DMARC-aligned domain, or false (also when From does not yield a single Author Domain) |
71
71
  | `header` | `object` | Always | Signature header info |
72
72
  | `policy` | `object` | Policy result | Policy violation details |
73
73
  | `underSized` | `number` | Body limited | Number of unsigned bytes |
@@ -99,46 +99,47 @@ Each entry in the `results` array has the following structure:
99
99
 
100
100
  Common values for `status.comment`:
101
101
 
102
- | Comment | Description |
103
- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
104
- | `"body hash did not verify"` | Calculated body hash does not match `bh=` tag |
105
- | `"bad signature"` | Cryptographic signature verification failed |
106
- | `"invalid expiration"` | Expiration timestamp is before signing timestamp |
107
- | `"expired"` | Signature has expired (past `x=` timestamp) |
108
- | `"no key"` | No DKIM key found in DNS |
109
- | `"unknown key version"` | Unsupported key version in DNS record |
110
- | `"unknown key type"` | Unsupported key type in DNS record |
111
- | `"invalid public key"` | Public key in DNS record is malformed, or revoked (empty `p=`) |
112
- | `"inappropriate hash algorithm"` | The key record `h=` tag does not list the hash algorithm of the signature (both modes) |
113
- | `"key not for email"` | The key record `s=` tag does not list `email` or `*` (both modes) |
114
- | `"From field not signed"` | The `h=` tag does not include From (both modes, RFC 6376 section 6.1.1) |
115
- | `"weak algorithm"` | Strict mode or `rejectRsaSha1`: rsa-sha1 signature (RFC 8301) |
116
- | `"signature syntax error"` | Strict mode: the signature is not valid RFC 6376 syntax |
117
- | `"signature missing required tag"` | Strict mode: `v=`, `a=`, `b=`, `bh=`, `d=`, `h=` or `s=` is missing |
118
- | `"incompatible version"` | Strict mode: `v=` is not 1 |
119
- | `"domain mismatch"` | Strict mode: the `i=` domain is not `d=` or its subdomain, or is a subdomain while the key has `t=s` |
120
- | `"unsupported query method"` | Strict mode: `q=` does not list `dns/txt` |
121
- | `"inappropriate key algorithm"` | Strict mode: the key type does not match the signature algorithm |
122
- | `"unknown algorithm"`, `"unknown canonicalization"` | Strict mode: the signature can not be processed |
123
- | `"DNS failure: {code}"` | DNS lookup failed with error code |
124
- | `"message not signed"` | No DKIM-Signature headers found |
102
+ | Comment | Description |
103
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
104
+ | `"body hash did not verify"` | Calculated body hash does not match `bh=` tag |
105
+ | `"bad signature"` | Cryptographic signature verification failed |
106
+ | `"invalid expiration"` | Expiration timestamp is before signing timestamp |
107
+ | `"expired"` | Signature has expired (past `x=` timestamp) |
108
+ | `"no key"` | No DKIM key found in DNS, or the key record name can not exist in the DNS (an empty label, a label over 63 octets or a name over 253 octets), which is not queried |
109
+ | `"unknown key version"` | Unsupported key version in DNS record |
110
+ | `"unknown key type"` | Unsupported key type in DNS record |
111
+ | `"invalid public key"` | Public key in DNS record is malformed, or revoked (empty `p=`) |
112
+ | `"inappropriate hash algorithm"` | The key record `h=` tag does not list the hash algorithm of the signature (both modes) |
113
+ | `"key not for email"` | The key record `s=` tag does not list `email` or `*` (both modes) |
114
+ | `"From field not signed"` | The `h=` tag does not include From (both modes, RFC 6376 section 6.1.1) |
115
+ | `"weak algorithm"` | Strict mode or `rejectRsaSha1`: rsa-sha1 signature (RFC 8301) |
116
+ | `"signature syntax error"` | Strict mode: the signature is not valid RFC 6376 syntax. Both modes: `d=` has a character that can not be in a host name, such as `/`, `?`, `#`, `@` or `:` |
117
+ | `"signature missing required tag"` | Strict mode: `v=`, `a=`, `b=`, `bh=`, `d=`, `h=` or `s=` is missing |
118
+ | `"incompatible version"` | Strict mode: `v=` is not 1 |
119
+ | `"domain mismatch"` | Strict mode: the `i=` domain is not `d=` or its subdomain, or is a subdomain while the key has `t=s` |
120
+ | `"unsupported query method"` | Strict mode: `q=` does not list `dns/txt` |
121
+ | `"inappropriate key algorithm"` | The key type does not match the signature algorithm (both modes) |
122
+ | `"unknown algorithm"`, `"unknown canonicalization"` | Strict mode: the signature can not be processed |
123
+ | `"DNS failure: {code}"` | DNS lookup failed with error code |
124
+ | `"message not signed"` | No DKIM-Signature headers found |
125
125
 
126
126
  ## Warnings
127
127
 
128
128
  In the default mode `status.warnings` lists what was accepted although strict mode would have rejected it. The array is only present when there is something to report, and it is never written into the Authentication-Results text.
129
129
 
130
- | Warning | Meaning |
131
- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
- | `rsa-sha1` | The signature uses rsa-sha1, which RFC 8301 section 3.1 does not allow. A domain can refuse it with `h=sha256` in its key record, which applies in both modes |
133
- | `tag-syntax` | Duplicate tags, upper case tag names, a malformed `t=`, `x=` or `l=` value, or a `d=` or `s=` that is not a domain name |
134
- | `missing-v`, `missing-h` (and the other `missing-*`) | A required tag is missing. Without `h=` the default header list is used, which includes From |
135
- | `invalid-v` | `v=` is not `1` |
136
- | `identity-domain` | The `i=` domain is not `d=` or its subdomain, or it is a subdomain while the key record has `t=s` |
137
- | `invalid-expiration` | `x=` is equal to `t=`. An `x=` before `t=` is `neutral (invalid expiration)` in both modes |
138
- | `query-method` | `q=` does not list `dns/txt` |
139
- | `key-syntax` | The key record is not valid tag-list syntax |
140
- | `key-v-syntax` | The key record `v=` is not the first tag, or not exactly `DKIM1` |
141
- | `key-type-inferred` | An Ed25519 key was recognized from its length, the key record has no `k=ed25519` |
130
+ | Warning | Meaning |
131
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | `rsa-sha1` | The signature uses rsa-sha1, which RFC 8301 section 3.1 does not allow. A domain can refuse it with `h=sha256` in its key record, which applies in both modes |
133
+ | `tag-syntax` | Duplicate tags, upper case tag names, a malformed `c=`, `t=`, `x=` or `l=` value, an `l=` larger than the canonicalized body, or a `d=` or `s=` that is not a domain name, or that makes the key record name longer than 253 octets. A `d=` with a character that can not be in a host name is `neutral (signature syntax error)` in both modes |
134
+ | `missing-v`, `missing-h` (and the other `missing-*`) | A required tag is missing. Without `h=` the default header list is used, which includes From |
135
+ | `invalid-v` | `v=` is not `1` |
136
+ | `identity-domain` | The `i=` domain is not `d=` or its subdomain, or it is a subdomain while the key record has `t=s` |
137
+ | `invalid-expiration` | `x=` is equal to `t=`. An `x=` before `t=` is `neutral (invalid expiration)` in both modes |
138
+ | `query-method` | `q=` does not list `dns/txt` |
139
+ | `key-syntax` | The key record is not valid tag-list syntax, or a `k=`, `s=` or `t=` value has whitespace inside it, which is removed |
140
+ | `key-v-syntax` | The key record `v=` is not the first tag, or not exactly `DKIM1` |
141
+ | `key-type-inferred` | An Ed25519 key was recognized from its length, the key record has no `k=ed25519` |
142
+ | `key-ed25519-spki` | An Ed25519 key record has the key as a SubjectPublicKeyInfo structure instead of the bare 32 octet key (RFC 8463 section 4.2). Strict mode reports `invalid public key` |
142
143
 
143
144
  A negative `l=` value is read as no limit in the default mode, so the whole body is covered and nothing is left unsigned. Strict mode reports it as a syntax error.
144
145
 
package/docs/dmarc.md CHANGED
@@ -18,7 +18,7 @@ const { dmarc } = await authenticate(message, {
18
18
  });
19
19
  ```
20
20
 
21
- The author domain is the domain of the addr-spec in the From header (RFC 9989 5.3.1, RFC 5322 3.4.1). An "@" inside a quoted local-part (`"ceo@x"@bank.example`) or an obsolete source route (`<@relay.example:ceo@bank.example>`) does not change it. It is lower-cased, converted to an A-label, and a trailing dot is ignored. Several From addresses are evaluated when they all have the same domain. When there is no domain, or more than one, or a mailbox has no usable domain, or the message has more than one From header field, DMARC validation is not possible: `dmarc()` returns `false` and `authenticate()` returns `dmarc: false`, as for a message without a From address. The lower level `evaluateDmarc()` from `mailauth/lib/dmarc` also returns `reason` (`"no-author-domain"`, `"multiple-author-domains"`, `"invalid-author-domain"` or `"multiple-from-fields"`) and `authorDomains` in that case. RFC 9989 11.5 warns that such messages are often abusive, so a receiver should handle them by local policy instead of treating them as mail without DMARC.
21
+ The author domain is the domain of the addr-spec in the From header (RFC 9989 5.3.1, RFC 5322 3.4.1). An "@" inside a quoted local-part (`"ceo@x"@bank.example`) or an obsolete source route (`<@relay.example:ceo@bank.example>`) does not change it. The From header is parsed by the RFC 5322 grammar, including the obsolete syntax of section 4, so whitespace or a comment between the atoms of the domain is removed (`ceo@bank (x).example` is `bank.example`). A From header that does not follow the grammar, for example text after the angle-addr or two addresses without a comma between them (`<bob@evil.example> alice@bank.example`), has no reliable author domain and gives `invalid-author-domain`. The default mode accepts a few common deviations and lists `"from-syntax"` in `warnings`: an unquoted comma in a display name (`Doe, John <john@example.com>`), a group without the closing `;`, a domain with a trailing dot, a domain literal in a display name, and an address used as a display name (`john@example.com <john@example.com>`, where both addresses count as author addresses). Strict mode treats these as `invalid-author-domain`. It is lower-cased, converted to an A-label, and a trailing dot is ignored. A domain with whitespace, a control character or an invisible format character (such as U+200B or the soft hyphen, but not the U+200C and U+200D joiners) is not a valid domain name and gives `invalid-author-domain`. Several From addresses are evaluated when they all have the same domain. When there is no domain, or more than one, or a mailbox has no usable domain, or the message has more than one From header field, DMARC validation is not possible: `dmarc()` returns `false` and `authenticate()` returns `dmarc: false`, as for a message without a From address. The lower level `evaluateDmarc()` from `mailauth/lib/dmarc` also returns `reason` (`"no-author-domain"`, `"multiple-author-domains"`, `"invalid-author-domain"` or `"multiple-from-fields"`) and `authorDomains` in that case. RFC 9989 11.5 warns that such messages are often abusive, so a receiver should handle them by local policy instead of treating them as mail without DMARC.
22
22
 
23
23
  ```javascript
24
24
  const { dmarc } = require('mailauth/lib/dmarc');
@@ -27,6 +27,7 @@ const result = await dmarc({
27
27
  headerFrom: 'user@example.com', // or a list of From addresses
28
28
  spfDomains: ['example.com'], // the MAIL FROM domain, if SPF passed
29
29
  dkimDomains: [{ domain: 'example.com' }], // signing domains of passing DKIM signatures
30
+ fromSyntax: 'valid', // optional, the From header syntax ('valid', 'lax' or 'invalid'), see above
30
31
  strict: false, // true for the RFC tag-list rules and no header.d, see below
31
32
  resolver // optional, defaults to dns.promises.resolve
32
33
  });
@@ -37,6 +38,8 @@ const result = await dmarc({
37
38
  With `strict: true`, which `authenticate(message, { strict: true })` passes on:
38
39
 
39
40
  - Records are parsed by the tag-list rules of RFC 6376 3.2 that DMARC uses (RFC 9989 4.7). Tag names are case sensitive, so `P=reject` is an unknown tag (only the version tag may be `V`, which the DMARC ABNF allows), and a record with a duplicated tag is invalid and ignored, as if it was not published. By default tag names are case-folded and the last of duplicated tags wins, and the result lists `"tag-case"` or `"duplicate-tag"` in `warnings`.
41
+ - A record must start with the `v` tag (RFC 9989 4.10), so one with whitespace before it is discarded. The default mode uses it and lists `"record-whitespace"` in `warnings`.
42
+ - A From header that only the lenient parsing accepts (see above) gives `invalid-author-domain` instead of the `"from-syntax"` warning.
40
43
  - `status.header.d` is not set and `header.d` is left out of the Authentication-Results entry, because it is not a registered property for `dmarc` (RFC 9989 9.1). `policyDomain` still has the domain.
41
44
 
42
45
  ## Result Object Fields
package/docs/mta-sts.md CHANGED
@@ -48,15 +48,17 @@ const { policy, status, warnings } = await getPolicy('example.com', knownPolicy,
48
48
 
49
49
  ### Policy Object Fields
50
50
 
51
- | Field | Type | Presence | Description |
52
- | --------- | --------------- | ---------------- | ------------------------------------------------------------------------- |
53
- | `id` | `string\|false` | Always | Policy ID from DNS TXT record, or `false` if not found |
54
- | `version` | `string` | Policy found | Always `"STSv1"` for valid policies |
55
- | `mode` | `string` | Always | Policy mode (see below) |
56
- | `mx` | `string[]` | Listed in policy | Array of allowed MX hostnames (may include wildcards) |
57
- | `maxAge` | `number` | Policy found | Policy validity period in seconds |
58
- | `expires` | `string` | Policy found | ISO 8601 expiration timestamp |
59
- | `error` | `Error` | Status errored | Error object explaining why no live policy could be discovered or fetched |
51
+ | Field | Type | Presence | Description |
52
+ | ------------ | --------------- | ---------------- | ------------------------------------------------------------------------- |
53
+ | `id` | `string\|false` | Always | Policy ID from DNS TXT record, or `false` if not found |
54
+ | `version` | `string` | Policy found | Always `"STSv1"` for valid policies |
55
+ | `mode` | `string` | Always | Policy mode (see below) |
56
+ | `mx` | `string[]` | Listed in policy | Array of allowed MX hostnames (may include wildcards) |
57
+ | `maxAge` | `number` | Policy found | Policy validity period in seconds |
58
+ | `expires` | `string` | Policy found | ISO 8601 expiration timestamp |
59
+ | `error` | `Error` | Status errored | Error object explaining why no live policy could be discovered or fetched |
60
+ | `retryId` | `string` | Fetch failed | New policy ID that could not be fetched while the cached policy is kept |
61
+ | `retryAfter` | `string` | Fetch failed | ISO 8601 timestamp before which `retryId` is not fetched again |
60
62
 
61
63
  ### Mode Values
62
64
 
@@ -79,6 +81,7 @@ await cache.set(domain, policy); // keep the entry at least until policy.expires
79
81
  - **Valid cached policy, same ID in DNS:** the cached policy is returned as `renewed` without an HTTPS request. Its `expires` value is not extended, so the policy file is fetched again once it expires.
80
82
  - **Valid cached policy, no live policy:** if the TXT lookup fails, the TXT record is missing, unusable or duplicated, the policy host has no address, or the new policy can not be fetched or is invalid, the cached policy is returned with `status: "errored"` and the reason in `policy.error`. A valid cached policy is never replaced by `mode: "none"` because of a discovery failure. An exception is a cached policy that already has mode `none`, which may be replaced by the retry placeholder described below.
81
83
  - **Expired cached policy:** the policy file is fetched again. If the fetch fails, the default lax mode keeps returning the expired `enforce` or `testing` policy (with the `expired-cache` warning), while strict mode treats the domain as having no policy.
84
+ - **Cached policy kept after a failed fetch:** when the policy for a new ID can not be fetched and the cached policy is returned instead, it gets `retryId` (the new ID) and `retryAfter` (one hour ahead). Storing it makes the next calls return it as `errored` without HTTPS requests while the DNS record has the same ID, so the policy host is not retried for an hour (RFC 8461 3.3). A different policy ID is fetched right away, and the fields are dropped once a policy is fetched.
82
85
  - **No usable cached policy and the fetch fails:** the result is a placeholder with the new policy ID, mode `none`, and `expires` set one hour ahead. Storing it makes the next calls return it as `renewed` without HTTPS requests, so the policy host is not retried for an hour. A changed policy ID is fetched right away.
83
86
 
84
87
  ## Strict Mode
package/docs/spf.md CHANGED
@@ -19,17 +19,17 @@ const result = await spf({
19
19
 
20
20
  ## Options
21
21
 
22
- | Option | Type | Default | Description |
23
- | ----------------- | ---------- | ----------------- | -------------------------------------------------------------------------------------------------------------- |
24
- | `sender` | `string` | `postmaster@helo` | MAIL FROM address. An empty value means a null reverse-path, so the HELO identity is checked |
25
- | `ip` | `string` | Required | SMTP client IP address. IPv4-mapped IPv6 addresses (`::ffff:192.0.2.1`, `::ffff:c000:201`) are checked as IPv4 |
26
- | `helo` | `string` | | EHLO/HELO hostname |
27
- | `mta` | `string` | `os.hostname()` | Hostname of the MTA performing the check |
28
- | `resolver` | `function` | `dns.resolve` | Custom DNS resolver |
29
- | `maxResolveCount` | `number` | `10` | Maximum DNS lookups allowed |
30
- | `maxVoidCount` | `number` | `2` | Maximum void (empty) DNS lookups allowed |
31
- | `maxElapsedTime` | `number` | No limit | Maximum time in milliseconds for the whole evaluation. If exceeded, the result is `temperror` (RFC 7208 4.6.4) |
32
- | `strict` | `boolean` | `false` | Follow RFC 7208 exactly instead of the lenient default, see [Strict Mode](#strict-mode) |
22
+ | Option | Type | Default | Description |
23
+ | ----------------- | ---------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
24
+ | `sender` | `string` | `postmaster@helo` | MAIL FROM address. An empty value means a null reverse-path, so the HELO identity is checked |
25
+ | `ip` | `string` | Required | SMTP client IP address. IPv4-mapped IPv6 addresses (`::ffff:192.0.2.1`, `::ffff:c000:201`) are checked as IPv4 |
26
+ | `helo` | `string` | | EHLO/HELO hostname |
27
+ | `mta` | `string` | `os.hostname()` | Hostname of the MTA performing the check |
28
+ | `resolver` | `function` | `dns.resolve` | Custom DNS resolver |
29
+ | `maxResolveCount` | `number` | `10` | Maximum DNS lookups allowed |
30
+ | `maxVoidCount` | `number` | `2` | Maximum void (empty) DNS lookups allowed. `0` is a valid limit, a value that is not a non-negative number uses the default |
31
+ | `maxElapsedTime` | `number` | No limit | Maximum time in milliseconds for the whole evaluation. If exceeded, the result is `temperror` (RFC 7208 4.6.4) |
32
+ | `strict` | `boolean` | `false` | Follow RFC 7208 exactly instead of the lenient default, see [Strict Mode](#strict-mode) |
33
33
 
34
34
  A missing or invalid `ip` gives a `temperror` result with the comment `missing or invalid client IP address`.
35
35
 
@@ -68,7 +68,7 @@ In strict mode `status.smtp` holds only the identity that was checked (RFC 8601
68
68
 
69
69
  ## explanation
70
70
 
71
- When the result is `fail` because of a mechanism match and the record has an `exp` modifier, the explanation string is fetched and expanded as described in RFC 7208 section 6.2. Any problem with it (DNS error, no or several TXT records, syntax error, non-ASCII text) means that there is no explanation. The explanation lookup does not count toward the DNS lookup limits. The explanation of an included record is never used, and after a redirect only the explanation of the redirect target is used.
71
+ When the result is `fail` because of a mechanism match and the record has an `exp` modifier, the explanation string is fetched and expanded as described in RFC 7208 section 6.2. Any problem with it (DNS error, no or several TXT records, syntax error, text that is not printable US-ASCII before or after macro expansion) means that there is no explanation. The explanation lookup does not count toward the DNS lookup limits. The explanation of an included record is never used, and after a redirect only the explanation of the redirect target is used.
72
72
 
73
73
  The explanation is text from the domain owner. It is not added to the generated headers. If you show it to an SMTP client, make clear that it comes from a third party, for example by prepending `"<domain> explains: "`.
74
74
 
@@ -144,7 +144,8 @@ These rules apply in both modes:
144
144
  - `%{p}` expands to a validated PTR name or `unknown`.
145
145
  - Trailing dots in domain-specs are accepted, names longer than 253 characters are truncated from the left, and uppercase macros are URL escaped.
146
146
  - An internationalized MAIL FROM domain is converted to A-labels.
147
- - Address lookups of MX hosts are not counted as void lookups.
147
+ - A single trailing dot is removed from the MAIL FROM domain and the HELO name (RFC 7208 section 4.3). A zero-length label anywhere else gives `none`.
148
+ - Address lookups of MX hosts are not counted as void lookups. The PTR lookup of a `%{p}` expansion is not counted as a void lookup either, the query of the term itself decides if the term was void.
148
149
 
149
150
  ## Example Output
150
151