mailauth 6.0.0 → 7.0.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.
Files changed (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +111 -39
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +80 -33
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +495 -43
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +140 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +75 -36
  37. package/lib/mta-sts.d.ts +77 -11
  38. package/lib/mta-sts.js +497 -182
  39. package/lib/parse-dkim-headers.js +376 -191
  40. package/lib/parse-received.js +104 -32
  41. package/lib/spf/index.d.ts +37 -1
  42. package/lib/spf/index.js +260 -28
  43. package/lib/spf/macro.js +50 -13
  44. package/lib/spf/spf-verify.js +440 -204
  45. package/lib/spf/syntax.js +366 -0
  46. package/lib/tools.js +522 -116
  47. package/package.json +1 -1
package/docs/dmarc.md CHANGED
@@ -6,7 +6,7 @@ This document describes the result object returned by DMARC verification.
6
6
 
7
7
  DMARC (Domain-based Message Authentication, Reporting, and Conformance) is verified during the authentication process. The DMARC result depends on both SPF and DKIM verification results.
8
8
 
9
- Policy discovery and Organizational Domains follow the DNS Tree Walk of RFC 9989 section 4.10, not the Public Suffix List. The policy is taken from the record of the author domain, or else from the record of its Organizational Domain, or else from a record published with `psd=y` above it. Relaxed alignment compares the Organizational Domains of the author domain and of each authenticated identifier, which can take further walks. A walk makes at most eight queries, lookups are shared within a message, only identifiers at or below the author's Organizational Domain are walked, and at most ten identifiers per message are walked.
9
+ Policy discovery and Organizational Domains follow the DNS Tree Walk of RFC 9989 section 4.10, not the Public Suffix List. The policy is taken from the record of the author domain, or else from the record of its Organizational Domain, or else from a record published with `psd=y` above it. Relaxed alignment compares the Organizational Domains of the author domain and of each authenticated identifier, which can take further walks. A walk makes at most eight queries, lookups are shared within a message, only identifiers at or below the author's Organizational Domain are walked, and at most ten identifiers per message are walked. An identifier past that limit is treated like one whose walk failed: it gives `temperror` unless another identifier aligns, never a definite `fail`.
10
10
 
11
11
  ```javascript
12
12
  const { authenticate } = require('mailauth');
@@ -18,37 +18,70 @@ 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.
22
+
23
+ ```javascript
24
+ const { dmarc } = require('mailauth/lib/dmarc');
25
+
26
+ const result = await dmarc({
27
+ headerFrom: 'user@example.com', // or a list of From addresses
28
+ spfDomains: ['example.com'], // the MAIL FROM domain, if SPF passed
29
+ dkimDomains: [{ domain: 'example.com' }], // signing domains of passing DKIM signatures
30
+ strict: false, // true for the RFC tag-list rules and no header.d, see below
31
+ resolver // optional, defaults to dns.promises.resolve
32
+ });
33
+ ```
34
+
35
+ ### Strict mode
36
+
37
+ With `strict: true`, which `authenticate(message, { strict: true })` passes on:
38
+
39
+ - 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`.
40
+ - `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
+
21
42
  ## Result Object Fields
22
43
 
23
- | Field | Type | Presence | Description |
24
- | ----------- | -------- | ------------ | ---------------------------------------------------------- |
25
- | `status` | `object` | Always | Verification status object (see below) |
26
- | `domain` | `string` | Always | Organizational Domain of the author domain (Tree Walk) |
27
- | `policy` | `string` | Record found | Effective policy (`"reject"`, `"quarantine"`, or `"none"`) |
28
- | `p` | `string` | Record found | Policy from `p=` tag |
29
- | `sp` | `string` | Record found | Subdomain policy from `sp=` tag (defaults to `p` value) |
30
- | `pct` | `number` | Record found | Percentage of messages to apply policy (0-100) |
31
- | `rr` | `string` | Record found | Raw DMARC DNS TXT record |
32
- | `alignment` | `object` | Record found | SPF and DKIM alignment details (see below) |
33
- | `error` | `string` | On temperror | Error message |
34
-
35
- On a `temperror` that happens after a record was found, because a Tree Walk needed for relaxed alignment failed and no other identifier aligned, `policy`, `p`, `sp`, `rr` and `alignment` are still set.
36
- | `info` | `string` | Always | Formatted Authentication-Results header value |
44
+ | Field | Type | Presence | Description |
45
+ | -------------- | ---------- | ------------ | -------------------------------------------------------------------------------- |
46
+ | `status` | `object` | Always | Verification status object (see below) |
47
+ | `domain` | `string` | Always | Organizational Domain of the author domain (Tree Walk) |
48
+ | `policy` | `string` | Record found | Effective policy (`"reject"`, `"quarantine"`, or `"none"`), see below |
49
+ | `p` | `string` | Record found | Policy from `p=` tag |
50
+ | `sp` | `string` | Record found | Subdomain policy from `sp=` tag (defaults to `p` value) |
51
+ | `np` | `string` | Record found | Non-existent subdomain policy from `np=` tag, if published |
52
+ | `testMode` | `boolean` | Record found | `true` when the record has `t=y` |
53
+ | `rr` | `string` | Record found | Raw DMARC DNS TXT record |
54
+ | `policyDomain` | `string` | Record found | The DMARC Policy Domain (RFC 9989 3.2.5), where the applied record was published |
55
+ | `alignment` | `object` | Record found | SPF and DKIM alignment details (see below) |
56
+ | `error` | `string` | On temperror | Error message |
57
+ | `warnings` | `string[]` | Lax parsing | What the default mode accepted that `strict` would not (see Strict mode) |
58
+ | `info` | `string` | Always | Formatted Authentication-Results header value |
59
+
60
+ On a `temperror` that happens after a record was found, `policy`, `p`, `sp`, `rr` and `alignment` are still set. That is the case when a Tree Walk needed for relaxed alignment failed or was over the limit and no other identifier aligned, or when the existence query that chooses between `sp` and `np` failed and nothing aligned.
61
+
62
+ `policyDomain` is the domain an RFC 9990 aggregate report lists as the domain of the published policy. By default it is also in `status.header.d`. `domain` is the Organizational Domain, which is a different domain when the policy came from the author domain's own record below it, or from a PSD above it.
63
+
64
+ `policy` is `p` for the author domain's own record. For a record inherited from the Organizational Domain or a PSD it is `np` when the author domain does not exist (NXDOMAIN, RFC 9989 A.4), otherwise `sp`, both falling back to `p`. The existence query is only made when the record has `np`. When it fails and `np` differs from the other policy, a message that passes still passes, `policy` is the weaker of the two and `policy.dmarc` is left out, and a message that does not pass gets `temperror`. With `t=y` the policy is one level below that, so `reject` becomes `quarantine` and `quarantine` becomes `none`, while `p`, `sp` and `np` stay as published.
65
+
66
+ A record without a valid `p`, or with an invalid `sp` or `np`, is applied as `p=none` when its `rua` lists a syntactically valid URI (RFC 3986, so `mailto:` counts and a bare address does not), and otherwise gets no DMARC processing (result `none`). `pct` is historic in RFC 9989 and is ignored.
37
67
 
38
68
  ## status Object
39
69
 
40
- | Field | Type | Description |
41
- | --------- | -------- | ----------------------------- |
42
- | `result` | `string` | DMARC result code (see below) |
43
- | `header` | `object` | Header information |
44
- | `comment` | `string` | Policy and ARC information |
70
+ | Field | Type | Description |
71
+ | --------- | -------- | ------------------------------------------------------- |
72
+ | `result` | `string` | DMARC result code (see below) |
73
+ | `header` | `object` | Header information |
74
+ | `policy` | `object` | `{ dmarc }`, the evaluated policy, on `pass` and `fail` |
75
+ | `comment` | `string` | Policy and ARC information |
76
+
77
+ `policy.dmarc` (RFC 9989 9.1) is the `policy` value, so after `sp`, `np` and `t=y` were applied. It is only set when the result is `pass` or `fail` and the policy is known.
45
78
 
46
79
  ### status.header Object
47
80
 
48
- | Field | Type | Description |
49
- | ------ | -------- | --------------------------------------------------------------------------------------- |
50
- | `from` | `string` | Domain of the From header address |
51
- | `d` | `string` | Domain where the applied DMARC record was found (author, Organizational Domain, or PSD) |
81
+ | Field | Type | Description |
82
+ | ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
83
+ | `from` | `string` | Domain of the From header address |
84
+ | `d` | `string` | Domain where the applied DMARC record was found (author, Organizational Domain, or PSD). Not set in strict mode |
52
85
 
53
86
  ## alignment Object
54
87
 
@@ -76,12 +109,12 @@ On a `temperror` that happens after a record was found, because a Tree Walk need
76
109
 
77
110
  ## Result Values
78
111
 
79
- | Result | Description |
80
- | ----------- | -------------------------------------------------------------- |
81
- | `pass` | Message passed DMARC (SPF or DKIM aligned and passed) |
82
- | `fail` | Message failed DMARC (neither SPF nor DKIM aligned) |
83
- | `none` | No DMARC record found |
84
- | `temperror` | Temporary error during a DNS lookup that the result depends on |
112
+ | Result | Description |
113
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
114
+ | `pass` | Message passed DMARC (SPF or DKIM aligned and passed) |
115
+ | `fail` | Message failed DMARC (neither SPF nor DKIM aligned) |
116
+ | `none` | No DMARC record found |
117
+ | `temperror` | Temporary error during a DNS lookup that the result depends on, or an identifier that could not be checked because of the Tree Walk limit |
85
118
 
86
119
  ## Policy Values
87
120
 
@@ -113,12 +146,17 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
113
146
  "header": {
114
147
  "from": "example.com",
115
148
  "d": "example.com"
149
+ },
150
+ "policy": {
151
+ "dmarc": "reject"
116
152
  }
117
153
  },
118
154
  "domain": "example.com",
155
+ "policyDomain": "example.com",
119
156
  "policy": "reject",
120
157
  "p": "reject",
121
158
  "sp": "reject",
159
+ "testMode": false,
122
160
  "rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
123
161
  "alignment": {
124
162
  "spf": {
@@ -129,7 +167,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
129
167
  "strict": false
130
168
  }
131
169
  },
132
- "info": "dmarc=pass (p=REJECT) header.from=example.com header.d=example.com"
170
+ "info": "dmarc=pass (p=REJECT) policy.dmarc=reject header.from=example.com header.d=example.com"
133
171
  }
134
172
  ```
135
173
 
@@ -143,12 +181,17 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
143
181
  "header": {
144
182
  "from": "example.com",
145
183
  "d": "example.com"
184
+ },
185
+ "policy": {
186
+ "dmarc": "reject"
146
187
  }
147
188
  },
148
189
  "domain": "example.com",
190
+ "policyDomain": "example.com",
149
191
  "policy": "reject",
150
192
  "p": "reject",
151
193
  "sp": "reject",
194
+ "testMode": false,
152
195
  "rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
153
196
  "alignment": {
154
197
  "spf": {
@@ -158,7 +201,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
158
201
  "strict": false
159
202
  }
160
203
  },
161
- "info": "dmarc=fail (p=REJECT) header.from=example.com header.d=example.com"
204
+ "info": "dmarc=fail (p=REJECT) policy.dmarc=reject header.from=example.com header.d=example.com"
162
205
  }
163
206
  ```
164
207
 
@@ -187,9 +230,13 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
187
230
  "header": {
188
231
  "from": "sub.example.com",
189
232
  "d": "example.com"
233
+ },
234
+ "policy": {
235
+ "dmarc": "quarantine"
190
236
  }
191
237
  },
192
238
  "domain": "example.com",
239
+ "policyDomain": "example.com",
193
240
  "policy": "quarantine",
194
241
  "p": "none",
195
242
  "sp": "quarantine",
@@ -203,7 +250,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
203
250
  "strict": false
204
251
  }
205
252
  },
206
- "info": "dmarc=pass (p=NONE sp=QUARANTINE) header.from=sub.example.com header.d=example.com"
253
+ "info": "dmarc=pass (p=NONE sp=QUARANTINE) policy.dmarc=quarantine header.from=sub.example.com header.d=example.com"
207
254
  }
208
255
  ```
209
256
 
package/docs/mta-sts.md CHANGED
@@ -10,41 +10,53 @@ MTA-STS allows domain owners to declare that their mail servers support TLS and
10
10
  const { getPolicy, validateMx } = require('mailauth/lib/mta-sts');
11
11
  ```
12
12
 
13
+ ## Options
14
+
15
+ `getPolicy`, `fetchPolicy` and `resolvePolicy` accept an options object as their last argument. `parsePolicy` and `validateMx` accept `{ strict }`.
16
+
17
+ | Option | Type | Default | Description |
18
+ | --------------- | ---------- | ---------------------- | ------------------------------------------------------------------------------------------ |
19
+ | `resolver` | `function` | `dns.promises.resolve` | Async DNS resolver with the `dns.promises.resolve` signature |
20
+ | `strict` | `boolean` | `false` | Apply the RFC 8461 rules exactly, see [Strict Mode](#strict-mode) |
21
+ | `timeout` | `number` | `60000` | Overall time limit for the HTTPS policy request in milliseconds (the idle timeout is 15 s) |
22
+ | `maxPolicySize` | `number` | `65536` | Maximum policy file size in bytes, larger responses fail with the `policy_too_large` error |
23
+
13
24
  ## getPolicy Result
14
25
 
15
- The `getPolicy` function fetches and returns the MTA-STS policy for a domain.
26
+ The `getPolicy` function fetches and returns the MTA-STS policy for a domain. The domain may also be given as an email address.
16
27
 
17
28
  ```javascript
18
- const { policy, status } = await getPolicy('example.com', knownPolicy);
29
+ const { policy, status, warnings } = await getPolicy('example.com', knownPolicy, { strict: false });
19
30
  ```
20
31
 
21
32
  ### Result Object Fields
22
33
 
23
- | Field | Type | Description |
24
- | -------- | -------- | ------------------------------------- |
25
- | `policy` | `object` | The MTA-STS policy object (see below) |
26
- | `status` | `string` | Policy retrieval status (see below) |
34
+ | Field | Type | Description |
35
+ | ---------- | ---------- | ------------------------------------------------------------------------------------------------- |
36
+ | `policy` | `object` | The MTA-STS policy object to apply and to store in your cache (see below) |
37
+ | `status` | `string` | Policy retrieval status (see below) |
38
+ | `warnings` | `string[]` | Only present when lax mode accepted something strict mode would reject, see [Warnings](#warnings) |
27
39
 
28
40
  ### Status Values
29
41
 
30
- | Status | Description |
31
- | ----------- | ---------------------------------------------------------- |
32
- | `found` | New or updated policy was fetched successfully |
33
- | `not_found` | No MTA-STS policy exists for the domain |
34
- | `renewed` | Existing policy is still valid (ID unchanged, not expired) |
35
- | `errored` | Policy discovery failed due to an error |
42
+ | Status | Description |
43
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
44
+ | `found` | A new or updated policy was fetched successfully |
45
+ | `not_found` | No MTA-STS policy exists for the domain, and there is no valid cached policy |
46
+ | `renewed` | The cached policy has the same ID as the DNS record and has not expired. It is returned unchanged, without fetching the policy file |
47
+ | `errored` | No live policy could be discovered or fetched. `policy.error` holds the reason. `policy` is the valid cached policy if there is one, otherwise a policy with mode `none` (see below) |
36
48
 
37
49
  ### Policy Object Fields
38
50
 
39
- | Field | Type | Presence | Description |
40
- | --------- | --------------- | --------------- | ------------------------------------------------------ |
41
- | `id` | `string\|false` | Always | Policy ID from DNS TXT record, or `false` if not found |
42
- | `version` | `string` | Policy found | Always `"STSv1"` for valid policies |
43
- | `mode` | `string` | Always | Policy mode (see below) |
44
- | `mx` | `string[]` | Mode not "none" | Array of allowed MX hostnames (may include wildcards) |
45
- | `maxAge` | `number` | Policy found | Policy validity period in seconds |
46
- | `expires` | `string` | Policy found | ISO 8601 expiration timestamp |
47
- | `error` | `Error` | On error | Error object if discovery failed |
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 |
48
60
 
49
61
  ### Mode Values
50
62
 
@@ -54,6 +66,57 @@ const { policy, status } = await getPolicy('example.com', knownPolicy);
54
66
  | `enforce` | Strictly enforce TLS and MX restrictions |
55
67
  | `none` | No policy in effect |
56
68
 
69
+ ## Caching
70
+
71
+ Pass the `policy` value of the previous result as `knownPolicy`, and store the `policy` value of every result, whatever the status. `getPolicy` implements the RFC 8461 3.3 and 5.1 cache rules itself:
72
+
73
+ ```javascript
74
+ const knownPolicy = await cache.get(domain); // undefined if not cached
75
+ const { policy } = await getPolicy(domain, knownPolicy);
76
+ await cache.set(domain, policy); // keep the entry at least until policy.expires
77
+ ```
78
+
79
+ - **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
+ - **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
+ - **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.
82
+ - **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
+
84
+ ## Strict Mode
85
+
86
+ With `strict: true` the RFC 8461 rules are applied exactly. The default lax mode accepts the following, and reports each acceptance in the `warnings` array of the `getPolicy` result:
87
+
88
+ | Rule | Warning |
89
+ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
90
+ | The TXT record must match the RFC 8461 3.1 syntax: case-sensitive `v=STSv1` at the start, `id` of 1 to 32 letters and digits | `txt-syntax` |
91
+ | Only HTTP status 200 is accepted (lax accepts any 2xx) | `http-status` |
92
+ | The policy must be served with the `text/plain` media type | `content-type` |
93
+ | Policy field names and the `mode` value are case-sensitive, `max_age` is 1 to 10 digits, `mx` values are `["*."] Domain` | `policy-syntax` |
94
+ | The Policy Host certificate must match through a DNS-ID subjectAltName, with no CN fallback and no partial-label wildcards (`mta-*`) | `cert-identity` |
95
+ | An expired cached policy is not applied when the policy can not be fetched | `expired-cache` |
96
+
97
+ A strict TXT record syntax error makes the record unusable, so it is handled like a missing record. The other strict failures are fetch errors, so a valid cached policy is kept.
98
+
99
+ These rules apply in both modes:
100
+
101
+ - Only records that begin with `v=STSv1;` are counted when checking for multiple TXT records. Two or more remaining records fail with `multi_sts_records`.
102
+ - If a TXT or policy field other than `mx` is repeated, the first value is used.
103
+ - TXT extension fields may contain any value allowed by RFC 8461, including `:`, `(` and quotes.
104
+ - A policy without a `mode` field is invalid.
105
+ - Redirects are not followed, and only 2xx responses (200 in strict mode) are accepted.
106
+ - The policy file size is limited by `maxPolicySize` and the whole request by `timeout`.
107
+ - Internationalized domain names are converted to A-labels for DNS queries, TLS SNI, the Host header and MX matching.
108
+
109
+ ## Warnings
110
+
111
+ | Warning | Meaning |
112
+ | --------------- | ----------------------------------------------------------------------------- |
113
+ | `txt-syntax` | The TXT record does not match the RFC 8461 3.1 syntax |
114
+ | `http-status` | The policy was served with a 2xx status other than 200 |
115
+ | `content-type` | The policy was not served as `text/plain` |
116
+ | `policy-syntax` | The policy file does not match the RFC 8461 3.2 syntax |
117
+ | `cert-identity` | The Policy Host certificate matched only through the CN or a partial wildcard |
118
+ | `expired-cache` | An expired cached policy was returned because the policy could not be fetched |
119
+
57
120
  ## validateMx Result
58
121
 
59
122
  The `validateMx` function checks if an MX hostname is valid according to the MTA-STS policy.
@@ -62,14 +125,16 @@ The `validateMx` function checks if an MX hostname is valid according to the MTA
62
125
  const result = validateMx('alt1.mx.example.com', policy);
63
126
  ```
64
127
 
128
+ MX names are matched case-insensitively, a trailing dot is ignored, and U-label names are converted to A-labels. A wildcard pattern such as `*.example.com` matches exactly one complete left-most label: `mail.example.com` matches, but `example.com` and `foo.bar.example.com` do not. An `enforce` or `testing` policy without an `mx` list matches nothing. `validateMx` does not look at `policy.expires`; use `getPolicy` to refresh the policy.
129
+
65
130
  ### Result Object Fields
66
131
 
67
- | Field | Type | Presence | Description |
68
- | --------- | --------- | ----------- | ----------------------------------------------------- |
69
- | `valid` | `boolean` | Always | Whether the MX hostname is allowed |
70
- | `mode` | `string` | Always | Policy mode (`"testing"`, `"enforce"`, or `"none"`) |
71
- | `match` | `string` | Valid match | The pattern that matched (exact hostname or wildcard) |
72
- | `testing` | `boolean` | Always | Whether policy is in testing mode |
132
+ | Field | Type | Presence | Description |
133
+ | --------- | --------- | ----------- | ------------------------------------------------------------------------------------- |
134
+ | `valid` | `boolean` | Always | Whether the MX hostname is allowed |
135
+ | `mode` | `string` | Always | Policy mode (`"testing"`, `"enforce"`, or `"none"`) |
136
+ | `match` | `string` | Valid match | The matching hostname, or for a wildcard the suffix without `*` (e.g. `.example.com`) |
137
+ | `testing` | `boolean` | Always | Whether policy is in testing mode |
73
138
 
74
139
  ## Example Output
75
140
 
@@ -101,7 +166,9 @@ const result = validateMx('alt1.mx.example.com', policy);
101
166
  }
102
167
  ```
103
168
 
104
- ### Policy Renewed (Cached)
169
+ ### Policy Renewed
170
+
171
+ The cached policy is returned unchanged.
105
172
 
106
173
  ```json
107
174
  {
@@ -117,7 +184,9 @@ const result = validateMx('alt1.mx.example.com', policy);
117
184
  }
118
185
  ```
119
186
 
120
- ### Policy Error
187
+ ### Policy Error Without a Cached Policy
188
+
189
+ The policy ID is kept, and `expires` is one hour ahead, so the policy host is not retried before that.
121
190
 
122
191
  ```json
123
192
  {
@@ -134,6 +203,26 @@ const result = validateMx('alt1.mx.example.com', policy);
134
203
  }
135
204
  ```
136
205
 
206
+ ### Policy Error With a Valid Cached Policy
207
+
208
+ ```json
209
+ {
210
+ "policy": {
211
+ "id": "20240115T120000",
212
+ "version": "STSv1",
213
+ "mode": "enforce",
214
+ "mx": ["mx1.example.com", "mx2.example.com"],
215
+ "maxAge": 86400,
216
+ "expires": "2024-01-16T12:00:00.000Z",
217
+ "error": {
218
+ "message": "No usable MTA-STS TXT record found for example.com",
219
+ "code": "sts_record_not_found"
220
+ }
221
+ },
222
+ "status": "errored"
223
+ }
224
+ ```
225
+
137
226
  ### MX Validation - Valid Match
138
227
 
139
228
  ```json
@@ -191,25 +280,34 @@ const result = validateMx('alt1.mx.example.com', policy);
191
280
 
192
281
  Errors that may appear in `policy.error`:
193
282
 
194
- | Code | Description |
195
- | --------------------- | ---------------------------------------------------- |
196
- | `multi_sts_records` | Multiple TXT records found for `_mta-sts.{domain}` |
197
- | `invalid_sts_version` | Policy file has invalid or missing version field |
198
- | `invalid_sts_mode` | Policy file has invalid mode value |
199
- | `invalid_sts_max_age` | Policy file has invalid max_age value |
200
- | `invalid_sts_mx` | Policy file missing mx field in enforce/testing mode |
201
- | `HTTP_SOCKET_TIMEOUT` | HTTP request timed out |
202
- | `http_status_{code}` | HTTP request returned non-2xx status |
203
- | `ENOTFOUND` | DNS lookup failed (domain not found) |
204
- | `ENODATA` | DNS lookup returned no data |
283
+ | Code | Description |
284
+ | ------------------------------ | -------------------------------------------------------------------------------------------------------------- |
285
+ | `multi_sts_records` | More than one TXT record beginning with `v=STSv1;` found for `_mta-sts.{domain}` |
286
+ | `sts_record_not_found` | No MTA-STS TXT record (only reported when a valid cached policy is returned) |
287
+ | `invalid_sts_record` | The TXT record has no usable `id`, or a syntax error in strict mode (only reported with a valid cached policy) |
288
+ | `policy_host_not_found` | `mta-sts.{domain}` has no A or AAAA record (only reported with a valid cached policy) |
289
+ | `invalid_sts_version` | Policy file has invalid or missing version field |
290
+ | `invalid_sts_mode` | Policy file has invalid or missing mode field |
291
+ | `invalid_sts_max_age` | Policy file has invalid max_age value |
292
+ | `invalid_sts_mx` | Policy file missing mx field in enforce/testing mode, or invalid mx value (strict) |
293
+ | `invalid_content_type` | Policy was not served as `text/plain` (strict mode only) |
294
+ | `policy_too_large` | Policy file is larger than `maxPolicySize` |
295
+ | `HTTP_SOCKET_TIMEOUT` | The HTTPS connection was idle for too long |
296
+ | `HTTP_REQUEST_TIMEOUT` | The HTTPS request took longer than `timeout` |
297
+ | `http_incomplete_response` | The HTTPS response ended before the complete body was received |
298
+ | `http_status_{code}` | HTTP request returned a non-2xx status (any status other than 200 in strict mode) |
299
+ | `ERR_TLS_CERT_ALTNAME_INVALID` | The Policy Host certificate is not valid for `mta-sts.{domain}` |
300
+ | `ESERVFAIL`, `ETIMEOUT`, ... | DNS lookup failed |
205
301
 
206
302
  ## Usage Example
207
303
 
208
304
  ```javascript
209
305
  const { getPolicy, validateMx } = require('mailauth/lib/mta-sts');
210
306
 
211
- // Fetch policy
212
- const { policy, status } = await getPolicy('gmail.com');
307
+ // Fetch policy, using and updating the cached policy
308
+ const knownPolicy = await cache.get('gmail.com');
309
+ const { policy, status } = await getPolicy('gmail.com', knownPolicy);
310
+ await cache.set('gmail.com', policy);
213
311
 
214
312
  console.log(`Policy status: ${status}`);
215
313
  console.log(`Policy mode: ${policy.mode}`);
package/docs/spf.md CHANGED
@@ -17,6 +17,22 @@ const result = await spf({
17
17
  });
18
18
  ```
19
19
 
20
+ ## Options
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) |
33
+
34
+ A missing or invalid `ip` gives a `temperror` result with the comment `missing or invalid client IP address`.
35
+
20
36
  ## Result Object Fields
21
37
 
22
38
  | Field | Type | Presence | Description |
@@ -30,6 +46,8 @@ const result = await spf({
30
46
  | `info` | `string` | Always | Formatted Authentication-Results header value |
31
47
  | `rr` | `string` | Record found | Raw SPF DNS TXT record |
32
48
  | `lookups` | `object` | Always | DNS lookup statistics (see below) |
49
+ | `explanation` | `string` | Fail only | Explanation string from the `exp` modifier (see below) |
50
+ | `warnings` | `array` | Default mode | Leniencies that strict mode would have rejected (see below) |
33
51
 
34
52
  ## status Object
35
53
 
@@ -46,6 +64,26 @@ const result = await spf({
46
64
  | `mailfrom` | `string` | The MAIL FROM address |
47
65
  | `helo` | `string` | The HELO/EHLO hostname |
48
66
 
67
+ In strict mode `status.smtp` holds only the identity that was checked (RFC 8601 section 2.7.2): `mailfrom` for a MAIL FROM check, or `helo` for a null reverse-path. The `mailfrom` value is then only the domain, unless the evaluated policy uses the `%{l}` or `%{s}` macro.
68
+
69
+ ## explanation
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.
72
+
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
+
75
+ ## warnings
76
+
77
+ In the default mode some input is accepted that strict mode rejects. When that happens, the result gets a `warnings` array. It is never rendered into the headers. The values are:
78
+
79
+ | Warning | Meaning |
80
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | `syntax-error` | An evaluated record has a syntax error (for example after the matching term, a `c`, `r` or `t` macro outside of explanation text, or a duplicate `exp`), or leading space |
82
+ | `void-lookup-limit` | The void lookup limit would have been exceeded if single-stack hosts were counted as void lookups, as RFC 7208 does |
83
+ | `non-ascii-local-part` | A `%{l}` or `%{s}` term was evaluated with a non-ASCII local-part (RFC 8616 section 4 says such terms do not match) |
84
+
85
+ `warnings` is not set for a `permerror` result.
86
+
49
87
  ## lookups Object
50
88
 
51
89
  | Field | Type | Description |
@@ -81,6 +119,33 @@ The `status.comment` field follows this format based on the result:
81
119
  | `permerror` | `"{mta}: permanent error in processing during lookup of {sender}: {text}"` |
82
120
  | `temperror` | `"{mta}: error in processing during lookup of {sender}: {text}"` |
83
121
 
122
+ ## Received-SPF Header
123
+
124
+ The `header` field holds a `Received-SPF` header (RFC 7208 section 9.1) with the `client-ip`, `envelope-from` and `helo` keys. `envelope-from` is omitted for a null reverse-path, and `client-ip` if no valid IP was given. Values that are not a dot-atom, such as IPv6 addresses and email addresses, are quoted.
125
+
126
+ ## Strict Mode
127
+
128
+ By default the SPF check is lenient where the leniency does not change which domain is authenticated. With `strict: true` it follows RFC 7208 exactly:
129
+
130
+ - The whole record is validated before evaluation (RFC 7208 section 4.6). A syntax error anywhere in the record gives `permerror`, even after the matching term. By default records are evaluated lazily, so an error after the matching term is not noticed.
131
+ - The `c`, `r` and `t` macros are only allowed in explanation text, a macro number must be nonzero, and transformers must follow the ABNF (`%{d0}`, `%{d2x}` and `%{dr2}` are errors).
132
+ - `exp` may appear only once, and modifier names must follow the `name` rule.
133
+ - `ip4` and `ip6` networks and prefix lengths are checked against the ABNF (`ip4:2001:db8::1`, `ip6:192.0.2.1`, `ip6:2001:db8::/32//64` and `ip4:192.0.2.0/00` are errors).
134
+ - A record must start with exactly `v=spf1` followed by a space or the end of the record. Records with leading whitespace are ignored.
135
+ - A void lookup is counted for the query of the client's address type alone. By default an `a` or `mx` host that has only addresses of the other type is not counted as void.
136
+ - A term that uses `%{l}` or `%{s}` does not match if the local-part is not ASCII (RFC 8616 section 4).
137
+ - A target name that is not a valid domain name after macro expansion makes the `a`, `mx`, `exists` or `ptr` mechanism not match. By default it is a `permerror`. For `include` and `redirect` it is a `permerror` in both modes.
138
+ - Authentication-Results reports only the checked identity, see [status.smtp Object](#statussmtp-object).
139
+
140
+ These rules apply in both modes:
141
+
142
+ - A DNS error (other than NXDOMAIN or an empty answer) in any lookup, including the targets of `include` and `redirect`, gives `temperror`.
143
+ - Every `ptr` mechanism and every `%{p}` expansion counts toward the DNS lookup limit. A DNS error in the PTR lookup makes the `ptr` mechanism not match.
144
+ - `%{p}` expands to a validated PTR name or `unknown`.
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
+ - An internationalized MAIL FROM domain is converted to A-labels.
147
+ - Address lookups of MX hosts are not counted as void lookups.
148
+
84
149
  ## Example Output
85
150
 
86
151
  ### SPF Pass
@@ -99,7 +164,7 @@ The `status.comment` field follows this format based on the result:
99
164
  "helo": "mail.example.com"
100
165
  }
101
166
  },
102
- "header": "Received-SPF: pass (mx.receiver.com: domain of user@example.com designates 192.0.2.1 as permitted sender) client-ip=192.0.2.1;",
167
+ "header": "Received-SPF: pass (mx.receiver.com: domain of user@example.com designates 192.0.2.1 as permitted sender) client-ip=192.0.2.1;\r\n envelope-from=\"user@example.com\"; helo=mail.example.com;",
103
168
  "info": "spf=pass (mx.receiver.com: domain of user@example.com designates 192.0.2.1 as permitted sender) smtp.mailfrom=user@example.com smtp.helo=mail.example.com",
104
169
  "rr": "v=spf1 ip4:192.0.2.0/24 -all",
105
170
  "lookups": {
@@ -127,7 +192,7 @@ The `status.comment` field follows this format based on the result:
127
192
  "helo": "attacker.example.net"
128
193
  }
129
194
  },
130
- "header": "Received-SPF: fail (mx.receiver.com: domain of user@example.com does not designate 203.0.113.1 as permitted sender) client-ip=203.0.113.1;",
195
+ "header": "Received-SPF: fail (mx.receiver.com: domain of user@example.com does not designate 203.0.113.1 as permitted sender) client-ip=203.0.113.1;\r\n envelope-from=\"user@example.com\"; helo=attacker.example.net;",
131
196
  "info": "spf=fail (mx.receiver.com: domain of user@example.com does not designate 203.0.113.1 as permitted sender) smtp.mailfrom=user@example.com smtp.helo=attacker.example.net",
132
197
  "rr": "v=spf1 ip4:192.0.2.0/24 -all",
133
198
  "lookups": {
@@ -155,7 +220,7 @@ The `status.comment` field follows this format based on the result:
155
220
  "helo": "mail.no-spf.example.com"
156
221
  }
157
222
  },
158
- "header": "Received-SPF: none (mx.receiver.com: no-spf.example.com does not designate permitted sender hosts) client-ip=192.0.2.1;",
223
+ "header": "Received-SPF: none (mx.receiver.com: no-spf.example.com does not designate permitted sender hosts) client-ip=192.0.2.1;\r\n envelope-from=\"user@no-spf.example.com\"; helo=mail.no-spf.example.com;",
159
224
  "info": "spf=none (mx.receiver.com: no-spf.example.com does not designate permitted sender hosts) smtp.mailfrom=user@no-spf.example.com smtp.helo=mail.no-spf.example.com",
160
225
  "lookups": {
161
226
  "limit": 10,
@@ -180,7 +245,7 @@ The `status.comment` field follows this format based on the result:
180
245
  "mailfrom": "user@complex.example.com"
181
246
  }
182
247
  },
183
- "header": "Received-SPF: permerror (mx.receiver.com: permanent error in processing during lookup of user@complex.example.com: Too many DNS requests) client-ip=192.0.2.1;",
248
+ "header": "Received-SPF: permerror (mx.receiver.com: permanent error in processing during lookup of user@complex.example.com: Too many DNS requests) client-ip=192.0.2.1;\r\n envelope-from=\"user@complex.example.com\";",
184
249
  "info": "spf=permerror (mx.receiver.com: permanent error in processing during lookup of user@complex.example.com: Too many DNS requests) smtp.mailfrom=user@complex.example.com",
185
250
  "lookups": {
186
251
  "limit": 10,