mailauth 6.0.0 → 7.1.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 +25 -0
  3. package/README.md +113 -39
  4. package/bin/mailauth.js +15 -6
  5. package/cli.md +9 -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 +508 -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 +3 -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 +577 -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 +154 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +77 -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
@@ -24,38 +24,125 @@ const WALK_MAX_LABELS = 7;
24
24
  // A domain literal or an IP address is not a DNS name and can not publish a DMARC record
25
25
  const isAddress = domain => domain.startsWith('[') || net.isIP(domain) !== 0;
26
26
 
27
- // Whether "_dmarc.<name>" can exist in the DNS: no empty labels, labels of at most 63 octets
28
- // and a name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a
29
- // resolver error that looks like a transient failure.
30
- const isQueryable = name => {
31
- let fqdn = `_dmarc.${name}`;
32
- return fqdn.length <= 253 && fqdn.split('.').every(label => label.length && label.length <= 63);
27
+ // Whether a name can exist in the DNS: no empty labels, labels of at most 63 octets and a
28
+ // name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a resolver
29
+ // error that looks like a transient failure.
30
+ const isValidName = name => name.length <= 253 && name.split('.').every(label => label.length && label.length <= 63);
31
+
32
+ const POLICY_VALUES = new Set(['none', 'quarantine', 'reject']);
33
+
34
+ // A reporting URI (RFC 9989 4.8 dmarc-uri): an RFC 3986 URI, so a scheme, a ":" and a possibly
35
+ // empty rest made of URI characters, where commas and "!" must be percent-encoded. "mailto:" is
36
+ // therefore valid and a bare address is not. An obsolete "!size" suffix does not change that.
37
+ const REPORTING_URI = /^[a-z][a-z0-9+.-]*:(?:[a-z0-9\-._~:/?#[\]@$&'()*+;=]|%[0-9a-f]{2})*$/i;
38
+ const OBS_REPORT_SIZE = /![0-9]+[kmgt]?$/i;
39
+
40
+ // "rua" lists at least one syntactically valid reporting URI (RFC 9989 4.10.1)
41
+ const hasReportingUri = record =>
42
+ typeof record.rua === 'string' && record.rua.split(',').some(uri => REPORTING_URI.test(uri.trim().replace(OBS_REPORT_SIZE, '')));
43
+
44
+ // Returns the policy tags a record is applied with (RFC 9989 4.10.1). A record without a
45
+ // valid "p", or with an invalid "sp" or "np", acts as p=none when it asks for aggregate
46
+ // reports, and gets no DMARC processing at all (null) otherwise.
47
+ const getPolicyTags = record => {
48
+ const isValid = value => value === undefined || POLICY_VALUES.has(value);
49
+ if (POLICY_VALUES.has(record.p) && isValid(record.sp) && isValid(record.np)) {
50
+ return { p: record.p, sp: record.sp, np: record.np };
51
+ }
52
+ return hasReportingUri(record) ? { p: 'none' } : null;
33
53
  };
34
54
 
35
- const parseDmarcRecord = txt => {
36
- let parsed = Object.fromEntries(
37
- txt
38
- .split(';')
39
- .map(e => e.trim())
40
- .filter(e => e)
41
- .map(e => {
42
- let splitPos = e.indexOf('=');
43
- if (splitPos < 0) {
44
- return [e.toLowerCase().trim(), false];
45
- } else if (splitPos === 0) {
46
- return [false, e];
47
- }
48
- // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
49
- let key = e.substr(0, splitPos).toLowerCase().trim();
50
- let val = e.substr(splitPos + 1).trim();
51
- if (['pct', 'ri'].includes(key)) {
52
- val = parseInt(val, 10) || 0;
53
- } else if (CASE_INSENSITIVE_TAGS.has(key)) {
54
- val = val.toLowerCase();
55
- }
56
- return [key, val];
57
- })
58
- );
55
+ // Domain existence test (RFC 9989 A.4): any RR makes a name exist, only NXDOMAIN means it
56
+ // does not. A query for A tells the two apart, NODATA is an existing name without A records.
57
+ // A query for A follows a CNAME and gets the target's NXDOMAIN when the target is gone (RFC 6604 3),
58
+ // so after an NXDOMAIN the name's own CNAME, which is not followed, is looked up as well.
59
+ const domainExists = async (domain, resolver) => {
60
+ if (!isValidName(domain)) {
61
+ return false;
62
+ }
63
+ resolver = resolver || dns.resolve;
64
+ try {
65
+ await resolver(domain, 'A');
66
+ return true;
67
+ } catch (err) {
68
+ if (err.code === 'ENODATA') {
69
+ return true;
70
+ }
71
+ if (err.code === 'EBADNAME') {
72
+ return false;
73
+ }
74
+ if (err.code !== 'ENOTFOUND') {
75
+ throw err;
76
+ }
77
+ }
78
+ try {
79
+ return ((await resolver(domain, 'CNAME')) || []).length > 0;
80
+ } catch (err) {
81
+ if (NO_RECORD_CODES.has(err.code)) {
82
+ // no CNAME, so the NXDOMAIN for A was the name's own
83
+ return false;
84
+ }
85
+ throw err;
86
+ }
87
+ };
88
+
89
+ // Tag names registered for DMARC (RFC 9989 9.3), including the historic ones
90
+ const KNOWN_TAGS = new Set(['v', 'p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rua', 'ruf', 'rf', 'ri', 'pct', 't', 'psd']);
91
+
92
+ // Parses a DMARC Policy Record. DMARC uses the DKIM tag-list syntax (RFC 9989 4.7), where tag names
93
+ // are case sensitive and a duplicated tag makes the whole tag-list invalid (RFC 6376 3.2).
94
+ // By default tag names are case-folded and the last of duplicated tags wins, which is reported in
95
+ // "warnings". With "strict" the RFC rules apply and a record with a duplicated tag is invalid (null).
96
+ const parseDmarcRecord = (txt, opts) => {
97
+ const { strict, warnings } = opts || {};
98
+
99
+ let seen = new Set();
100
+ let duplicate = false;
101
+
102
+ let entries = txt
103
+ .split(';')
104
+ .map(e => e.trim())
105
+ .filter(e => e)
106
+ .map((e, i) => {
107
+ let splitPos = e.indexOf('=');
108
+ if (splitPos < 0) {
109
+ // not a tag, a syntax error that is ignored
110
+ return [e.toLowerCase().trim(), false];
111
+ } else if (splitPos === 0) {
112
+ return [false, e];
113
+ }
114
+ // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
115
+ let name = e.substr(0, splitPos).trim();
116
+ let key = name.toLowerCase();
117
+ if (strict && i > 0) {
118
+ // the version tag is matched case-insensitively by its own ABNF rule, other names are not
119
+ key = name;
120
+ } else if (key !== name && KNOWN_TAGS.has(key) && i > 0) {
121
+ warnings?.add('tag-case');
122
+ }
123
+
124
+ if (seen.has(key)) {
125
+ duplicate = true;
126
+ }
127
+ seen.add(key);
128
+
129
+ let val = e.substr(splitPos + 1).trim();
130
+ if (['pct', 'ri'].includes(key)) {
131
+ val = parseInt(val, 10) || 0;
132
+ } else if (CASE_INSENSITIVE_TAGS.has(key)) {
133
+ val = val.toLowerCase();
134
+ }
135
+ return [key, val];
136
+ });
137
+
138
+ if (duplicate) {
139
+ if (strict) {
140
+ return null;
141
+ }
142
+ warnings?.add('duplicate-tag');
143
+ }
144
+
145
+ let parsed = Object.fromEntries(entries);
59
146
 
60
147
  parsed.rr = txt;
61
148
 
@@ -64,15 +151,16 @@ const parseDmarcRecord = txt => {
64
151
 
65
152
  // Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
66
153
  // records counts as no record, it is discarded (RFC 9989 4.10 steps 2 and 6).
67
- const fetchRecord = async (name, resolver) => {
68
- if (!isQueryable(name)) {
154
+ const fetchRecord = async (name, resolver, opts) => {
155
+ if (!isValidName(`_dmarc.${name}`)) {
69
156
  return false;
70
157
  }
71
158
 
72
159
  let records;
73
160
  try {
74
161
  let txt = await resolver(`_dmarc.${name}`, 'TXT');
75
- records = (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
162
+ // a custom resolver might return plain strings instead of lists of character-strings
163
+ records = (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trim()).filter(row => DMARC_VERSION_RE.test(row));
76
164
  } catch (err) {
77
165
  if (NO_RECORD_CODES.has(err.code)) {
78
166
  return false;
@@ -80,16 +168,19 @@ const fetchRecord = async (name, resolver) => {
80
168
  throw err;
81
169
  }
82
170
 
83
- return records.length === 1 ? parseDmarcRecord(records[0]) : false;
171
+ // a record that is not a valid tag-list is discarded like one that is not a DMARC record
172
+ return (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
84
173
  };
85
174
 
86
- // Tree Walks for one message share most of their names, so each name is queried only once
87
- const createRecordLookup = resolver => {
175
+ // Tree Walks for one message share most of their names, so each name is queried only once.
176
+ // opts.strict parses records by the RFC 6376 tag-list rules, opts.warnings is a Set that collects
177
+ // what the default parsing accepted and strict parsing would not.
178
+ const createRecordLookup = (resolver, opts) => {
88
179
  resolver = resolver || dns.resolve;
89
180
  let cache = new Map();
90
181
  return name => {
91
182
  if (!cache.has(name)) {
92
- cache.set(name, fetchRecord(name, resolver));
183
+ cache.set(name, fetchRecord(name, resolver, opts));
93
184
  }
94
185
  return cache.get(name);
95
186
  };
@@ -154,20 +245,33 @@ const getOrgDomain = async (domain, lookup) => {
154
245
  return selectOrgDomain(domain, found);
155
246
  };
156
247
 
248
+ // The record that holds the policy of an Organizational Domain: its own record, or else the record
249
+ // of the PSD above it. Returns false when there is neither.
250
+ const findOrgRecord = (orgDomain, found) => {
251
+ let last = found[found.length - 1];
252
+ let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
253
+ return entry || false;
254
+ };
255
+
157
256
  // DMARC Policy Discovery (RFC 9989 4.10.1) for a normalized author domain. Returns
158
- // { record, recordDomain, orgDomain, error }, where "record" is false when no policy applies.
159
- // When the Tree Walk fails after a record was found at the author domain, "orgDomain" is null
160
- // and "error" says why. Throws on DNS errors that leave the policy unknown.
257
+ // { record, recordDomain, orgDomain, orgRecord, error }, where "record" is false when no policy
258
+ // applies and "orgRecord" is the record that holds the Organizational Domain's policy (see findOrgRecord).
259
+ // When the Tree Walk fails after a record was found at the author domain, "orgDomain" and
260
+ // "orgRecord" are null and "error" says why. Throws on DNS errors that leave the policy unknown.
161
261
  const discoverPolicy = async (domain, lookup) => {
162
262
  if (isAddress(domain)) {
163
- return { record: false, recordDomain: null, orgDomain: domain };
263
+ return { record: false, recordDomain: null, orgDomain: domain, orgRecord: false };
164
264
  }
165
265
 
166
266
  let { found, error } = await treeWalk(domain, lookup);
167
267
 
168
268
  if (found[0]?.domain === domain) {
169
269
  // the author domain's own record applies regardless of what is above it
170
- return { record: found[0].record, recordDomain: domain, orgDomain: error ? null : selectOrgDomain(domain, found), error };
270
+ if (error) {
271
+ return { record: found[0].record, recordDomain: domain, orgDomain: null, orgRecord: null, error };
272
+ }
273
+ let orgDomain = selectOrgDomain(domain, found);
274
+ return { record: found[0].record, recordDomain: domain, orgDomain, orgRecord: findOrgRecord(orgDomain, found).record || false };
171
275
  }
172
276
 
173
277
  if (error) {
@@ -178,17 +282,17 @@ const discoverPolicy = async (domain, lookup) => {
178
282
 
179
283
  // otherwise the record of the Organizational Domain, and failing that the record of the PSD.
180
284
  // A walk only stops at a psd tag, so a psd=y record can only be the last one found.
181
- let last = found[found.length - 1];
182
- let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
285
+ let entry = findOrgRecord(orgDomain, found);
183
286
 
184
- return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain };
287
+ return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain, orgRecord: entry ? entry.record : false };
185
288
  };
186
289
 
187
290
  // Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
188
- const getDmarcRecord = async (domain, resolver) => {
291
+ // opts.strict parses records by the RFC 6376 tag-list rules (see parseDmarcRecord).
292
+ const getDmarcRecord = async (domain, resolver, opts) => {
189
293
  domain = formatDomain(domain || '');
190
294
 
191
- let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver));
295
+ let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver, { strict: !!opts?.strict }));
192
296
  if (!record) {
193
297
  return false;
194
298
  }
@@ -205,3 +309,6 @@ module.exports = getDmarcRecord;
205
309
  module.exports.createRecordLookup = createRecordLookup;
206
310
  module.exports.discoverPolicy = discoverPolicy;
207
311
  module.exports.getOrgDomain = getOrgDomain;
312
+ module.exports.getPolicyTags = getPolicyTags;
313
+ module.exports.domainExists = domainExists;
314
+ module.exports.parseDmarcRecord = parseDmarcRecord;
@@ -6,6 +6,33 @@ import { DMARCOptions, DMARCResult } from '../../index';
6
6
  * Verifies DMARC policy for a message
7
7
  *
8
8
  * @param opts - DMARC verification options
9
- * @returns DMARC verification result
9
+ * @returns DMARC verification result, or false when the From header has no domain or more than one domain
10
10
  */
11
- export function dmarc(opts: DMARCOptions): Promise<DMARCResult | false>;
11
+ export function dmarc(opts: DMARCOptions & { strict?: boolean }): Promise<DMARCResult | false>;
12
+
13
+ /**
14
+ * Verifies DMARC policy for a message, with details that dmarc() does not return
15
+ *
16
+ * @param opts - DMARC verification options
17
+ */
18
+ export function evaluateDmarc(opts: DMARCOptions & { strict?: boolean }): Promise<{
19
+ /**
20
+ * The dmarc() result
21
+ */
22
+ response: DMARCResult | false;
23
+
24
+ /**
25
+ * Signing domains (normalized) of the DKIM signatures that aligned
26
+ */
27
+ dkimAligned?: Set<string>;
28
+
29
+ /**
30
+ * Why DMARC validation was not possible (RFC 9989 5.3.1), set when response is false
31
+ */
32
+ reason?: 'no-author-domain' | 'multiple-author-domains' | 'invalid-author-domain' | 'multiple-from-fields';
33
+
34
+ /**
35
+ * The Author Domains found, set when response is false
36
+ */
37
+ authorDomains?: string[];
38
+ }>;
@@ -1,46 +1,61 @@
1
1
  'use strict';
2
2
 
3
- const punycode = require('punycode.js');
3
+ const dns = require('node:dns').promises;
4
4
  const { formatAuthHeaderRow, formatDomain } = require('../tools');
5
- const { createRecordLookup, discoverPolicy, getOrgDomain } = require('./get-dmarc-record');
5
+ const { createRecordLookup, discoverPolicy, getOrgDomain, getPolicyTags, domainExists } = require('./get-dmarc-record');
6
+ const { getAuthorDomain } = require('./author-domain');
6
7
 
7
8
  // Every Tree Walk is capped at eight queries, but the number of walks is not (RFC 9989 4.10.2),
8
- // so a message can only make this many identifiers walk for relaxed alignment. The rest do not align.
9
+ // so a message can only make this many identifiers walk for relaxed alignment. An identifier
10
+ // past the limit is not known to align or not, so it is treated like one whose walk failed.
9
11
  const MAX_ALIGNMENT_WALKS = 10;
10
12
 
11
- const evaluateDmarc = async opts => {
12
- let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts;
13
-
14
- if (Array.isArray(headerFrom)) {
15
- if (headerFrom.length === 1) {
16
- headerFrom = headerFrom[0];
17
- } else {
18
- // invalid number of FROM addresses found
19
- return { response: false };
20
- }
21
- }
13
+ // t=y asks for the policy one level below the published one (RFC 9989 4.7 "t")
14
+ const TEST_MODE_POLICY = { reject: 'quarantine', quarantine: 'none', none: 'none' };
22
15
 
23
- let atPos = headerFrom.indexOf('@');
24
- let domain = atPos >= 0 ? headerFrom.substr(atPos + 1) : headerFrom;
16
+ const POLICY_STRENGTH = { none: 0, quarantine: 1, reject: 2 };
25
17
 
26
- domain = domain.toLowerCase().trim();
18
+ // Two domains can only have the same Organizational Domain if they have a common parent. With
19
+ // no common label at all no Tree Walk result could make them align.
20
+ const shareParent = (a, b) => a.split('.').pop() === b.split('.').pop();
27
21
 
28
- try {
29
- domain = punycode.toASCII(domain);
30
- } catch (err) {
31
- // ignore punycode conversion errors
22
+ const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
23
+
24
+ const evaluateDmarc = async opts => {
25
+ let { headerFrom, fromFields, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
26
+
27
+ strict = !!strict;
28
+ resolver = resolver || dns.resolve;
29
+
30
+ // RFC 9989 5.3.1: several mailboxes are fine if they share one domain, but with zero or more
31
+ // than one Author Domain DMARC validation is not possible. The response stays false, as it
32
+ // always was for these messages, and "reason" says why.
33
+ const author = getAuthorDomain(headerFrom, fromFields);
34
+ if (!author.authorDomain) {
35
+ return { response: false, reason: author.reason, authorDomains: author.domains };
32
36
  }
33
37
 
34
- // the name to query and compare, "example.com." and "example.com" are the same domain
35
- const authorDomain = formatDomain(domain);
38
+ // "domain" is reported in header.from. "authorDomain" is the name to query and compare,
39
+ // where "example.com." and "example.com" are the same domain.
40
+ const { domain, authorDomain } = author;
41
+
42
+ // what the default mode accepted that strict mode would not
43
+ const warnings = new Set();
36
44
 
37
45
  let formatResponse = response => {
38
- response.info = formatAuthHeaderRow('dmarc', response.status);
46
+ if (warnings.size) {
47
+ response.warnings = Array.from(warnings);
48
+ }
49
+
50
+ response.info = formatAuthHeaderRow('dmarc', response.status, { strict });
39
51
 
40
52
  if (typeof response.status.comment === 'boolean') {
41
53
  delete response.status.comment;
42
54
  }
43
55
 
56
+ // for BIMI (draft-brand-indicators-for-message-identification 7.1), not part of the result
57
+ setHidden(response, 'authorMailboxes', author.mailboxes);
58
+
44
59
  return response;
45
60
  };
46
61
 
@@ -55,7 +70,8 @@ const evaluateDmarc = async opts => {
55
70
  }
56
71
  };
57
72
 
58
- const lookup = createRecordLookup(resolver);
73
+ // the strict parsing rejects what the default parsing would note, so it adds no warnings
74
+ const lookup = createRecordLookup(resolver, { strict, warnings });
59
75
 
60
76
  let policyDiscovery;
61
77
  try {
@@ -66,7 +82,7 @@ const evaluateDmarc = async opts => {
66
82
  return { response: formatResponse({ status, domain: authorDomain, error: err.message }) };
67
83
  }
68
84
 
69
- let { record: dmarcRecord, recordDomain, orgDomain } = policyDiscovery;
85
+ let { record: dmarcRecord, recordDomain, orgDomain, orgRecord } = policyDiscovery;
70
86
 
71
87
  if (!dmarcRecord) {
72
88
  // nothing to do here
@@ -75,18 +91,57 @@ const evaluateDmarc = async opts => {
75
91
  return { response: formatResponse({ status, domain: orgDomain }) };
76
92
  }
77
93
 
78
- // the domain the applied policy record was published at
79
- status.header.d = recordDomain;
94
+ const policyTags = getPolicyTags(dmarcRecord);
95
+ if (!policyTags) {
96
+ // no valid policy and nowhere to report to, the record gets no DMARC processing (RFC 9989 4.10.1)
97
+ status.result = 'none';
98
+ return { response: formatResponse({ status, domain: orgDomain || authorDomain }) };
99
+ }
100
+
101
+ if (!strict) {
102
+ // The domain the applied policy record was published at, the DMARC Policy Domain. This
103
+ // property is not registered for dmarc (RFC 9989 9.1), so strict mode leaves it out and
104
+ // the domain is only reported in "policyDomain".
105
+ status.header.d = recordDomain;
106
+ }
107
+
108
+ let policy = policyTags.p;
109
+ // set when the existence query that decides between "sp" and "np" failed
110
+ let existenceError = null;
111
+ if (recordDomain !== authorDomain) {
112
+ // A record inherited by a subdomain applies "np" if the subdomain does not exist and "sp"
113
+ // if it does, both falling back to "p" (RFC 9989 4.10.1). Existence only matters with "np".
114
+ policy = policyTags.sp || policyTags.p;
115
+ if (policyTags.np) {
116
+ try {
117
+ if (!(await domainExists(authorDomain, resolver))) {
118
+ policy = policyTags.np;
119
+ }
120
+ } catch (err) {
121
+ if (policyTags.np !== policy) {
122
+ // The policy is one of the two, but which one is not known. This only matters
123
+ // for a message that does not pass. Until then the weaker one is assumed, so
124
+ // that nothing (such as BIMI) treats the domain as more enforcing than it is.
125
+ existenceError = err;
126
+ if (POLICY_STRENGTH[policyTags.np] < POLICY_STRENGTH[policy]) {
127
+ policy = policyTags.np;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ const testMode = dmarcRecord.t === 'y';
135
+ if (testMode) {
136
+ policy = TEST_MODE_POLICY[policy];
137
+ }
80
138
 
81
139
  status.comment = []
82
- .concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
83
- .concat(dmarcRecord.sp ? `sp=${dmarcRecord.sp.toUpperCase()}` : [])
140
+ .concat(`p=${policyTags.p.toUpperCase()}`)
141
+ .concat(policyTags.sp ? `sp=${policyTags.sp.toUpperCase()}` : [])
84
142
  .concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : [])
85
143
  .join(' ');
86
144
 
87
- // use "sp" if the record was inherited by a subdomain and "sp" is set, otherwise use "p"
88
- const policy = recordDomain !== authorDomain && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
89
-
90
145
  const adkimStrict = dmarcRecord.adkim === 's';
91
146
  const aspfStrict = dmarcRecord.aspf === 's';
92
147
 
@@ -94,13 +149,17 @@ const evaluateDmarc = async opts => {
94
149
  // An identifier's Organizational Domain is always the identifier itself or one of its
95
150
  // parents, so only identifiers at or below the author's Organizational Domain need a walk.
96
151
  let orgDomains = new Map();
97
- // throws when a Tree Walk the answer depends on failed
152
+ // throws when a Tree Walk the answer depends on failed, or was not made because of the limit
98
153
  const alignsRelaxed = async entryDomain => {
99
154
  if (entryDomain === authorDomain) {
100
155
  return true;
101
156
  }
102
157
  if (!orgDomain) {
103
- // the author's own Tree Walk failed, so there is nothing to compare against
158
+ // The author's own Tree Walk failed, so there is nothing to compare against,
159
+ // unless the two domains have no parent in common and can never align
160
+ if (!shareParent(entryDomain, authorDomain)) {
161
+ return false;
162
+ }
104
163
  throw policyDiscovery.error;
105
164
  }
106
165
  if (entryDomain !== orgDomain && !entryDomain.endsWith(`.${orgDomain}`)) {
@@ -108,7 +167,9 @@ const evaluateDmarc = async opts => {
108
167
  }
109
168
  if (!orgDomains.has(entryDomain)) {
110
169
  if (orgDomains.size >= MAX_ALIGNMENT_WALKS) {
111
- return false;
170
+ let err = new Error(`Alignment of ${entryDomain} not checked, limit of ${MAX_ALIGNMENT_WALKS} Tree Walks per message reached`);
171
+ err.code = 'EWALKLIMIT';
172
+ throw err;
112
173
  }
113
174
  orgDomains.set(entryDomain, getOrgDomain(entryDomain, lookup));
114
175
  }
@@ -118,7 +179,7 @@ const evaluateDmarc = async opts => {
118
179
  // Returns every entry that aligns, most preferred first, and the first walk error seen.
119
180
  // Signatures with an l= tag come last, so that a signature covering the whole body is
120
181
  // reported when several align.
121
- const findAligned = async (entries, strict) => {
182
+ const findAligned = async (entries, strictAlignment) => {
122
183
  let list = []
123
184
  .concat(entries || [])
124
185
  .map(entry => (typeof entry === 'string' ? { domain: entry } : entry))
@@ -134,7 +195,7 @@ const evaluateDmarc = async opts => {
134
195
  continue;
135
196
  }
136
197
  try {
137
- if (strict ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
198
+ if (strictAlignment ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
138
199
  aligned.push(entry);
139
200
  }
140
201
  } catch (err) {
@@ -162,10 +223,14 @@ const evaluateDmarc = async opts => {
162
223
  let response = {
163
224
  status,
164
225
  domain: orgDomain || authorDomain,
226
+ // the DMARC Policy Domain (RFC 9989 3.2.5), where the applied record was published.
227
+ // This is the domain RFC 9990 aggregate reports list as the published policy's domain.
228
+ policyDomain: recordDomain,
165
229
  policy,
166
- p: dmarcRecord.p,
167
- sp: dmarcRecord.sp || dmarcRecord.p,
168
- pct: dmarcRecord.pct,
230
+ p: policyTags.p,
231
+ sp: policyTags.sp || policyTags.p,
232
+ np: policyTags.np,
233
+ testMode,
169
234
  rr: dmarcRecord.rr,
170
235
 
171
236
  alignment: {
@@ -177,15 +242,26 @@ const evaluateDmarc = async opts => {
177
242
  if (dkimAlignment || spfAlignment) {
178
243
  // pass
179
244
  status.result = 'pass';
180
- } else if (walkError) {
181
- // an identifier might have aligned if its Tree Walk had not failed
245
+ } else if (walkError || existenceError) {
246
+ // an identifier might have aligned if its Tree Walk had not failed, or the
247
+ // policy to apply is not known because the author domain's existence is not
182
248
  status.result = 'temperror';
183
- response.error = walkError.message;
249
+ response.error = (walkError || existenceError).message;
184
250
  } else {
185
251
  // fail
186
252
  status.result = 'fail';
187
253
  }
188
254
 
255
+ if (status.result !== 'temperror' && !existenceError) {
256
+ // RFC 9989 9.1: the evaluated policy after the policy options have been processed
257
+ status.policy = { dmarc: policy };
258
+ }
259
+
260
+ // for BIMI (draft-brand-indicators-for-message-identification 7.1), not part of the result:
261
+ // the applied record and the record holding the Organizational Domain's policy (null if unknown)
262
+ setHidden(response, 'record', dmarcRecord);
263
+ setHidden(response, 'orgRecord', orgRecord);
264
+
189
265
  return { response: formatResponse(response), dkimAligned: new Set(dkim.aligned.map(entry => formatDomain(entry.domain))) };
190
266
  };
191
267