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
@@ -24,38 +24,111 @@ 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
+ const domainExists = async (domain, resolver) => {
58
+ if (!isValidName(domain)) {
59
+ return false;
60
+ }
61
+ try {
62
+ await (resolver || dns.resolve)(domain, 'A');
63
+ return true;
64
+ } catch (err) {
65
+ if (err.code === 'ENODATA') {
66
+ return true;
67
+ }
68
+ if (err.code === 'ENOTFOUND' || err.code === 'EBADNAME') {
69
+ return false;
70
+ }
71
+ throw err;
72
+ }
73
+ };
74
+
75
+ // Tag names registered for DMARC (RFC 9989 9.3), including the historic ones
76
+ const KNOWN_TAGS = new Set(['v', 'p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rua', 'ruf', 'rf', 'ri', 'pct', 't', 'psd']);
77
+
78
+ // Parses a DMARC Policy Record. DMARC uses the DKIM tag-list syntax (RFC 9989 4.7), where tag names
79
+ // are case sensitive and a duplicated tag makes the whole tag-list invalid (RFC 6376 3.2).
80
+ // By default tag names are case-folded and the last of duplicated tags wins, which is reported in
81
+ // "warnings". With "strict" the RFC rules apply and a record with a duplicated tag is invalid (null).
82
+ const parseDmarcRecord = (txt, opts) => {
83
+ const { strict, warnings } = opts || {};
84
+
85
+ let seen = new Set();
86
+ let duplicate = false;
87
+
88
+ let entries = txt
89
+ .split(';')
90
+ .map(e => e.trim())
91
+ .filter(e => e)
92
+ .map((e, i) => {
93
+ let splitPos = e.indexOf('=');
94
+ if (splitPos < 0) {
95
+ // not a tag, a syntax error that is ignored
96
+ return [e.toLowerCase().trim(), false];
97
+ } else if (splitPos === 0) {
98
+ return [false, e];
99
+ }
100
+ // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
101
+ let name = e.substr(0, splitPos).trim();
102
+ let key = name.toLowerCase();
103
+ if (strict && i > 0) {
104
+ // the version tag is matched case-insensitively by its own ABNF rule, other names are not
105
+ key = name;
106
+ } else if (key !== name && KNOWN_TAGS.has(key) && i > 0) {
107
+ warnings?.add('tag-case');
108
+ }
109
+
110
+ if (seen.has(key)) {
111
+ duplicate = true;
112
+ }
113
+ seen.add(key);
114
+
115
+ let val = e.substr(splitPos + 1).trim();
116
+ if (['pct', 'ri'].includes(key)) {
117
+ val = parseInt(val, 10) || 0;
118
+ } else if (CASE_INSENSITIVE_TAGS.has(key)) {
119
+ val = val.toLowerCase();
120
+ }
121
+ return [key, val];
122
+ });
123
+
124
+ if (duplicate) {
125
+ if (strict) {
126
+ return null;
127
+ }
128
+ warnings?.add('duplicate-tag');
129
+ }
130
+
131
+ let parsed = Object.fromEntries(entries);
59
132
 
60
133
  parsed.rr = txt;
61
134
 
@@ -64,15 +137,16 @@ const parseDmarcRecord = txt => {
64
137
 
65
138
  // Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
66
139
  // 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)) {
140
+ const fetchRecord = async (name, resolver, opts) => {
141
+ if (!isValidName(`_dmarc.${name}`)) {
69
142
  return false;
70
143
  }
71
144
 
72
145
  let records;
73
146
  try {
74
147
  let txt = await resolver(`_dmarc.${name}`, 'TXT');
75
- records = (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
148
+ // a custom resolver might return plain strings instead of lists of character-strings
149
+ records = (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trim()).filter(row => DMARC_VERSION_RE.test(row));
76
150
  } catch (err) {
77
151
  if (NO_RECORD_CODES.has(err.code)) {
78
152
  return false;
@@ -80,16 +154,19 @@ const fetchRecord = async (name, resolver) => {
80
154
  throw err;
81
155
  }
82
156
 
83
- return records.length === 1 ? parseDmarcRecord(records[0]) : false;
157
+ // a record that is not a valid tag-list is discarded like one that is not a DMARC record
158
+ return (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
84
159
  };
85
160
 
86
- // Tree Walks for one message share most of their names, so each name is queried only once
87
- const createRecordLookup = resolver => {
161
+ // Tree Walks for one message share most of their names, so each name is queried only once.
162
+ // opts.strict parses records by the RFC 6376 tag-list rules, opts.warnings is a Set that collects
163
+ // what the default parsing accepted and strict parsing would not.
164
+ const createRecordLookup = (resolver, opts) => {
88
165
  resolver = resolver || dns.resolve;
89
166
  let cache = new Map();
90
167
  return name => {
91
168
  if (!cache.has(name)) {
92
- cache.set(name, fetchRecord(name, resolver));
169
+ cache.set(name, fetchRecord(name, resolver, opts));
93
170
  }
94
171
  return cache.get(name);
95
172
  };
@@ -154,20 +231,33 @@ const getOrgDomain = async (domain, lookup) => {
154
231
  return selectOrgDomain(domain, found);
155
232
  };
156
233
 
234
+ // The record that holds the policy of an Organizational Domain: its own record, or else the record
235
+ // of the PSD above it. Returns false when there is neither.
236
+ const findOrgRecord = (orgDomain, found) => {
237
+ let last = found[found.length - 1];
238
+ let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
239
+ return entry || false;
240
+ };
241
+
157
242
  // 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.
243
+ // { record, recordDomain, orgDomain, orgRecord, error }, where "record" is false when no policy
244
+ // applies and "orgRecord" is the record that holds the Organizational Domain's policy (see findOrgRecord).
245
+ // When the Tree Walk fails after a record was found at the author domain, "orgDomain" and
246
+ // "orgRecord" are null and "error" says why. Throws on DNS errors that leave the policy unknown.
161
247
  const discoverPolicy = async (domain, lookup) => {
162
248
  if (isAddress(domain)) {
163
- return { record: false, recordDomain: null, orgDomain: domain };
249
+ return { record: false, recordDomain: null, orgDomain: domain, orgRecord: false };
164
250
  }
165
251
 
166
252
  let { found, error } = await treeWalk(domain, lookup);
167
253
 
168
254
  if (found[0]?.domain === domain) {
169
255
  // 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 };
256
+ if (error) {
257
+ return { record: found[0].record, recordDomain: domain, orgDomain: null, orgRecord: null, error };
258
+ }
259
+ let orgDomain = selectOrgDomain(domain, found);
260
+ return { record: found[0].record, recordDomain: domain, orgDomain, orgRecord: findOrgRecord(orgDomain, found).record || false };
171
261
  }
172
262
 
173
263
  if (error) {
@@ -178,17 +268,17 @@ const discoverPolicy = async (domain, lookup) => {
178
268
 
179
269
  // otherwise the record of the Organizational Domain, and failing that the record of the PSD.
180
270
  // 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);
271
+ let entry = findOrgRecord(orgDomain, found);
183
272
 
184
- return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain };
273
+ return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain, orgRecord: entry ? entry.record : false };
185
274
  };
186
275
 
187
276
  // Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
188
- const getDmarcRecord = async (domain, resolver) => {
277
+ // opts.strict parses records by the RFC 6376 tag-list rules (see parseDmarcRecord).
278
+ const getDmarcRecord = async (domain, resolver, opts) => {
189
279
  domain = formatDomain(domain || '');
190
280
 
191
- let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver));
281
+ let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver, { strict: !!opts?.strict }));
192
282
  if (!record) {
193
283
  return false;
194
284
  }
@@ -205,3 +295,6 @@ module.exports = getDmarcRecord;
205
295
  module.exports.createRecordLookup = createRecordLookup;
206
296
  module.exports.discoverPolicy = discoverPolicy;
207
297
  module.exports.getOrgDomain = getOrgDomain;
298
+ module.exports.getPolicyTags = getPolicyTags;
299
+ module.exports.domainExists = domainExists;
300
+ 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