mailauth 5.0.3 → 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 (48) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +117 -41
  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 +82 -31
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +499 -45
  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 +267 -53
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/index.js +2 -1
  36. package/lib/dmarc/verify.js +205 -53
  37. package/lib/mailauth.js +86 -40
  38. package/lib/mta-sts.d.ts +77 -11
  39. package/lib/mta-sts.js +497 -182
  40. package/lib/parse-dkim-headers.js +376 -191
  41. package/lib/parse-received.js +104 -32
  42. package/lib/spf/index.d.ts +37 -1
  43. package/lib/spf/index.js +260 -28
  44. package/lib/spf/macro.js +50 -13
  45. package/lib/spf/spf-verify.js +440 -204
  46. package/lib/spf/syntax.js +366 -0
  47. package/lib/tools.js +524 -118
  48. package/package.json +12 -12
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { formatSignatureHeaderLine, formatRelaxedLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
4
+ const { formatRelaxedLine, stripSignatureValue } = require('../../../lib/tools');
5
+ const { buildSignatureOpts } = require('./signature-opts');
5
6
 
6
7
  // generate headers for signing
7
8
  const relaxedHeaders = (type, signingHeaderLines, options) => {
8
- let { signatureHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature, instance, bodyHashedBytes, expires } =
9
- options || {};
9
+ let { signatureHeaderLine, strict } = options || {};
10
10
  let chunks = [];
11
11
 
12
12
  for (let signedHeaderLine of signingHeaderLines.headers) {
@@ -16,48 +16,11 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
16
16
  let opts = false;
17
17
 
18
18
  if (!signatureHeaderLine) {
19
- opts = {
20
- a: algorithm,
21
- c: canonicalization,
22
- s: selector,
23
- d: signingDomain,
24
- h: signingHeaderLines.keys,
25
- bh: bodyHash
26
- };
27
-
28
- if (typeof bodyHashedBytes === 'number') {
29
- opts.l = bodyHashedBytes;
30
- }
31
-
32
- if (instance) {
33
- // ARC only
34
- opts.i = instance;
35
- }
36
-
37
- // The same opts object formats the line that gets signed here and, once the
38
- // signature exists, the line that is emitted. Leaving t= out of it lets
39
- // formatSignatureHeaderLine read the clock again for the second one, and a second
40
- // boundary falling between the two reads signs one timestamp and publishes another
41
- opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
42
-
43
- if (expires) {
44
- opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
45
- }
46
-
47
- signatureHeaderLine = formatSignatureHeaderLine(
48
- type,
49
- Object.assign(
50
- {
51
- // make sure that b= always has a value, otherwise folding would be different
52
- b: signature || 'a'.repeat(73)
53
- },
54
- opts
55
- ),
56
- true
57
- );
19
+ ({ opts, signatureHeaderLine } = buildSignatureOpts(type, signingHeaderLines, options));
58
20
  }
59
21
 
60
- chunks.push(stripSignatureValue(formatRelaxedLine(signatureHeaderLine)));
22
+ // the strict mode reads the tag names of a signature header case-sensitively
23
+ chunks.push(stripSignatureValue(formatRelaxedLine(signatureHeaderLine), strict));
61
24
 
62
25
  return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
63
26
  };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ const { formatSignatureHeaderLine, getCurTime } = require('../../tools');
4
+
5
+ /**
6
+ * Builds the tags of a signature header that is being created, and the header line that the
7
+ * header canonicalization hashes for it. Shared by the relaxed and the simple canonicalization.
8
+ *
9
+ * @param {String} type 'DKIM', 'ARC' or 'AS'
10
+ * @param {Object} signingHeaderLines { keys } as returned by getSigningHeaderLines()
11
+ * @param {Object} options The options of the canonicalization
12
+ * @returns {Object} { opts, signatureHeaderLine }
13
+ */
14
+ const buildSignatureOpts = (type, signingHeaderLines, options) => {
15
+ let {
16
+ signingDomain,
17
+ selector,
18
+ algorithm,
19
+ canonicalization,
20
+ bodyHash,
21
+ signTime,
22
+ signature,
23
+ instance,
24
+ identity,
25
+ bodyHashedBytes,
26
+ expires,
27
+ timestamp,
28
+ expiration
29
+ } = options || {};
30
+
31
+ let opts = {
32
+ a: algorithm,
33
+ c: canonicalization,
34
+ s: selector,
35
+ d: signingDomain,
36
+ h: signingHeaderLines.keys,
37
+ bh: bodyHash
38
+ };
39
+
40
+ if (typeof bodyHashedBytes === 'number') {
41
+ opts.l = bodyHashedBytes;
42
+ }
43
+
44
+ if (instance && type === 'ARC') {
45
+ // ARC only, the i= of an ARC-Message-Signature is the instance number
46
+ opts.i = instance;
47
+ } else if (identity && type === 'DKIM') {
48
+ // DKIM only, the i= of a DKIM-Signature is the AUID
49
+ opts.i = identity;
50
+ }
51
+
52
+ // The same opts object formats the line that gets signed here and, once the
53
+ // signature exists, the line that is emitted. Leaving t= out of it lets
54
+ // formatSignatureHeaderLine read the clock again for the second one, and a second
55
+ // boundary falling between the two reads signs one timestamp and publishes another.
56
+ // `timestamp` and `expiration` are the values the signer already validated, false
57
+ // leaves the tag out (an explicit null keeps formatSignatureHeaderLine from adding
58
+ // a t= of its own). Callers that do not pass them get the values from signTime and
59
+ // expires
60
+ if (typeof timestamp === 'number' || timestamp === false) {
61
+ opts.t = timestamp === false ? null : timestamp;
62
+ } else {
63
+ opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
64
+ }
65
+
66
+ if (typeof expiration === 'number') {
67
+ opts.x = expiration;
68
+ } else if (expiration !== false && expires) {
69
+ opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
70
+ }
71
+
72
+ let signatureHeaderLine = formatSignatureHeaderLine(
73
+ type,
74
+ Object.assign(
75
+ {
76
+ // make sure that b= always has a value, otherwise folding would be different
77
+ b: signature || 'a'.repeat(73)
78
+ },
79
+ opts
80
+ ),
81
+ true
82
+ );
83
+
84
+ return { opts, signatureHeaderLine };
85
+ };
86
+
87
+ module.exports = { buildSignatureOpts };
@@ -1,14 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const { Buffer } = require('node:buffer');
4
- const { formatSignatureHeaderLine, stripSignatureValue, getCurTime } = require('../../../lib/tools');
4
+ const { stripSignatureValue } = require('../../../lib/tools');
5
+ const { buildSignatureOpts } = require('./signature-opts');
5
6
 
6
7
  const formatSimpleLine = (line, suffix) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary');
7
8
 
8
9
  // generate headers for signing
9
10
  const simpleHeaders = (type, signingHeaderLines, options) => {
10
- let { signatureHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature, instance, bodyHashedBytes, expires } =
11
- options || {};
11
+ let { signatureHeaderLine, strict } = options || {};
12
12
  let chunks = [];
13
13
 
14
14
  for (let signedHeaderLine of signingHeaderLines.headers) {
@@ -18,48 +18,11 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
18
18
  let opts = false;
19
19
 
20
20
  if (!signatureHeaderLine) {
21
- opts = {
22
- a: algorithm,
23
- c: canonicalization,
24
- s: selector,
25
- d: signingDomain,
26
- h: signingHeaderLines.keys,
27
- bh: bodyHash
28
- };
29
-
30
- if (typeof bodyHashedBytes === 'number') {
31
- opts.l = bodyHashedBytes;
32
- }
33
-
34
- if (instance) {
35
- // ARC only (should never happen thoug as simple algo is not allowed)
36
- opts.i = instance;
37
- }
38
-
39
- // The same opts object formats the line that gets signed here and, once the
40
- // signature exists, the line that is emitted. Leaving t= out of it lets
41
- // formatSignatureHeaderLine read the clock again for the second one, and a second
42
- // boundary falling between the two reads signs one timestamp and publishes another
43
- opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
44
-
45
- if (expires) {
46
- opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
47
- }
48
-
49
- signatureHeaderLine = formatSignatureHeaderLine(
50
- type,
51
- Object.assign(
52
- {
53
- // make sure that b= has a value, otherwise folding would be different
54
- b: signature || 'a'.repeat(73)
55
- },
56
- opts
57
- ),
58
- true
59
- );
21
+ ({ opts, signatureHeaderLine } = buildSignatureOpts(type, signingHeaderLines, options));
60
22
  }
61
23
 
62
- chunks.push(stripSignatureValue(formatSimpleLine(signatureHeaderLine)));
24
+ // the strict mode reads the tag names of a signature header case-sensitively
25
+ chunks.push(stripSignatureValue(formatSimpleLine(signatureHeaderLine), strict));
63
26
 
64
27
  return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
65
28
  };
@@ -20,6 +20,8 @@ class MessageParser extends Writable {
20
20
 
21
21
  this.state = 'header';
22
22
  this.stateBytes = [];
23
+ // bytes of the header section seen so far
24
+ this.headerBytes = 0;
23
25
 
24
26
  this.headers = false;
25
27
  this.headerChunks = [];
@@ -61,12 +63,22 @@ class MessageParser extends Writable {
61
63
  if (this.stateBytes.length > 4) {
62
64
  this.stateBytes = this.stateBytes.slice(-4);
63
65
  }
66
+ this.headerBytes++;
64
67
 
65
68
  let b0 = this.stateBytes[this.stateBytes.length - 1];
66
69
  let b1 = this.stateBytes.length > 1 && this.stateBytes[this.stateBytes.length - 2];
67
70
  let b2 = this.stateBytes.length > 2 && this.stateBytes[this.stateBytes.length - 3];
68
71
 
69
- if (b0 === 0x0a && (b1 === 0x0a || (b1 === 0x0d && b2 === 0x0a))) {
72
+ if (
73
+ b0 === 0x0a &&
74
+ (b1 === 0x0a ||
75
+ (b1 === 0x0d && b2 === 0x0a) ||
76
+ // RFC 5322 section 2.1: an empty line ends the header section, and when
77
+ // the very first line is empty the header section is empty. Everything
78
+ // after it is body, even if it looks like header fields
79
+ this.headerBytes === 1 ||
80
+ (this.headerBytes === 2 && b1 === 0x0d))
81
+ ) {
70
82
  // found header ending
71
83
  if (i === chunk.length - 1) {
72
84
  //end of chunk
@@ -88,13 +100,14 @@ class MessageParser extends Writable {
88
100
  await this.nextChunk(chunk);
89
101
  }
90
102
 
103
+ // Rewrites every bare <LF> to <CRLF>. `lastByte` has to follow every byte, the <LF>s
104
+ // included: an <LF> directly after a <CRLF> is a bare one too, and it was left as it was
105
+ // when only the other bytes were tracked
91
106
  *ensureLinebreaks(input) {
92
107
  let pos = 0;
93
108
  for (let i = 0; i < input.length; i++) {
94
109
  let c = input[i];
95
- if (c !== 0x0a) {
96
- this.lastByte = c;
97
- } else if (this.lastByte !== 0x0d) {
110
+ if (c === 0x0a && this.lastByte !== 0x0d) {
98
111
  // emit line break
99
112
  let buf;
100
113
  if (i === 0 || pos === i) {
@@ -106,6 +119,7 @@ class MessageParser extends Writable {
106
119
 
107
120
  pos = i + 1;
108
121
  }
122
+ this.lastByte = c;
109
123
  }
110
124
  if (pos === 0) {
111
125
  yield input;
@@ -3,31 +3,37 @@
3
3
  /// <reference types="node" />
4
4
 
5
5
  import { Transform } from 'stream';
6
- import { MessageInput, DKIMSignOptions, DKIMSignResult } from '../../index';
6
+ import { MessageInput, DKIMSignOptions, DKIMSignResult, DKIMSignError, DKIMSignWarning } from '../../index';
7
7
 
8
8
  /**
9
9
  * Signs an email message with DKIM signature(s)
10
10
  *
11
11
  * @param input - RFC822 formatted message (stream, buffer, or string)
12
- * @param options - DKIM signing options
13
- * @returns DKIM signature header(s) and any errors
12
+ * @param options - DKIM signing options, including `strict`
13
+ * @returns DKIM signature header(s), an empty string if none was created, plus errors and warnings
14
14
  */
15
15
  export function dkimSign(input: MessageInput, options: DKIMSignOptions): Promise<DKIMSignResult>;
16
16
 
17
17
  /**
18
18
  * Transform stream for DKIM signing
19
- * Prepends DKIM-Signature header to the message stream
19
+ * Prepends the DKIM-Signature header(s) to the message stream. When no signature could be
20
+ * created the message is passed through unchanged
20
21
  */
21
22
  export class DkimSignStream extends Transform {
22
23
  /**
23
24
  * Creates a DKIM signing stream
24
25
  *
25
- * @param options - DKIM signing options
26
+ * @param options - DKIM signing options, including `strict`
26
27
  */
27
28
  constructor(options: DKIMSignOptions);
28
29
 
29
30
  /**
30
- * Any errors encountered during signing
31
+ * The signatures that could not be created, set when the stream ends
31
32
  */
32
- errors: Error[] | null;
33
+ errors: DKIMSignError[] | null;
34
+
35
+ /**
36
+ * What was signed although strict mode would have refused it, set when the stream ends
37
+ */
38
+ warnings: DKIMSignWarning[] | null;
33
39
  }
package/lib/dkim/sign.js CHANGED
@@ -3,12 +3,23 @@
3
3
  const { writeToStream } = require('../../lib/tools');
4
4
  const { DkimSigner } = require('./dkim-signer');
5
5
  const { Transform } = require('node:stream');
6
+ const { Buffer } = require('node:buffer');
7
+
8
+ // The DKIM-Signature header lines to prepend to the message, each ending with a CRLF. An
9
+ // empty string when no signature was created: a lone CRLF in front of the message would be
10
+ // the empty line that ends the header section, and turn every header of it into body
11
+ const formatSignatures = signatureHeaders => (signatureHeaders.length ? signatureHeaders.join('\r\n') + '\r\n' : '');
6
12
 
7
13
  const dkimSign = async (input, options) => {
8
14
  let dkimSigner = new DkimSigner(options);
9
15
  await writeToStream(dkimSigner, input);
10
16
 
11
- return { signatures: dkimSigner.signatureHeaders.join('\r\n') + '\r\n', arc: dkimSigner.arc, errors: dkimSigner.errors };
17
+ return {
18
+ signatures: formatSignatures(dkimSigner.signatureHeaders),
19
+ arc: dkimSigner.arc,
20
+ errors: dkimSigner.errors,
21
+ warnings: dkimSigner.warnings
22
+ };
12
23
  };
13
24
 
14
25
  class DkimSignStream extends Transform {
@@ -20,6 +31,7 @@ class DkimSignStream extends Transform {
20
31
  this.chunklen = 0;
21
32
 
22
33
  this.errors = null;
34
+ this.warnings = null;
23
35
 
24
36
  this.finished = false;
25
37
  this.finishCb = null;
@@ -40,8 +52,12 @@ class DkimSignStream extends Transform {
40
52
  this.finishCb = null;
41
53
 
42
54
  this.errors = this.signer.errors;
55
+ this.warnings = this.signer.warnings;
43
56
 
44
- this.push(Buffer.from(this.signer.signatureHeaders.join('\r\n') + '\r\n'));
57
+ let signatures = formatSignatures(this.signer.signatureHeaders);
58
+ if (signatures) {
59
+ this.push(Buffer.from(signatures));
60
+ }
45
61
  this.push(Buffer.concat(this.chunks, this.chunklen));
46
62
  done();
47
63
  }
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ // Grammar shared by the DKIM and ARC signers and verifiers. ARC reuses the DKIM tag syntax
4
+ // (RFC 8617 section 4.1), so both read these from here. This module requires nothing, so
5
+ // that lib/tools.js can use it as well.
6
+
7
+ // RFC 6376 section 3.5 sub-domain
8
+ const DOMAIN_LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
9
+ // domain-name = sub-domain 1*("." sub-domain)
10
+ const DOMAIN_NAME = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})+$`);
11
+ // selector = sub-domain *("." sub-domain)
12
+ const SELECTOR = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
13
+ // sig-t-tag and sig-x-tag are 1*12DIGIT, sig-l-tag is 1*76DIGIT
14
+ const TIMESTAMP = /^[0-9]{1,12}$/;
15
+ const BODY_LENGTH = /^[0-9]{1,76}$/;
16
+
17
+ // RFC 8617 section 4.2.1: instance values range from 1 to 50
18
+ const MAX_ARC_INSTANCE = 50;
19
+
20
+ // the header fields of an ARC set (RFC 8617 section 4.1)
21
+ const ARC_HEADER_KEYS = ['arc-seal', 'arc-message-signature', 'arc-authentication-results'];
22
+
23
+ module.exports = {
24
+ DOMAIN_LABEL,
25
+ DOMAIN_NAME,
26
+ SELECTOR,
27
+ TIMESTAMP,
28
+ BODY_LENGTH,
29
+ MAX_ARC_INSTANCE,
30
+ ARC_HEADER_KEYS
31
+ };
@@ -10,6 +10,7 @@ const dkimVerify = async (input, options) => {
10
10
  const result = {
11
11
  //headers: dkimVerifier.headers,
12
12
  headerFrom: dkimVerifier.headerFrom,
13
+ fromFields: dkimVerifier.fromFields,
13
14
  envelopeFrom: dkimVerifier.envelopeFrom,
14
15
  results: dkimVerifier.results
15
16
  };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const { formatDomain, toALabel } = require('../tools');
4
+
5
+ // Characters that can not appear in a domain name taken from an address. Anything like this
6
+ // means the address was malformed and the text after the "@" is not a domain.
7
+ const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]]/;
8
+
9
+ // A domain literal (RFC 5322 3.4.1). It is kept as is, it can not publish a DMARC record.
10
+ const DOMAIN_LITERAL = /^\[[^[\]\\]*\]$/;
11
+
12
+ // Removes an obsolete source route (RFC 5322 4.4 obs-angle-addr), as in "@relay.example:ceo@bank.example",
13
+ // where the route ends with the first ":" that is not inside a domain literal
14
+ const stripRoute = address => {
15
+ if (address.charAt(0) !== '@') {
16
+ return address;
17
+ }
18
+ let inLiteral = false;
19
+ for (let i = 1; i < address.length; i++) {
20
+ let c = address.charAt(i);
21
+ if (c === '[') {
22
+ inLiteral = true;
23
+ } else if (c === ']') {
24
+ inLiteral = false;
25
+ } else if (c === ':' && !inLiteral) {
26
+ return address.slice(i + 1);
27
+ }
28
+ }
29
+ return address;
30
+ };
31
+
32
+ // Returns the positions of the "@" signs that separate a local-part from a domain. An "@" inside
33
+ // a quoted string of the local-part (RFC 5322 3.2.4) or inside a domain literal does not count.
34
+ // With an unterminated quoted string the quotes are not honored, so that the "@" of an address
35
+ // such as '"ceo@bank.example' still counts and the domain the reader sees is not dropped.
36
+ const findSeparators = (address, honorQuotes) => {
37
+ let positions = [];
38
+ let inQuote = false;
39
+ let escaped = false;
40
+ let inLiteral = false;
41
+
42
+ for (let i = 0; i < address.length; i++) {
43
+ let c = address.charAt(i);
44
+ if (inQuote) {
45
+ if (escaped) {
46
+ escaped = false;
47
+ } else if (c === '\\') {
48
+ escaped = true;
49
+ } else if (c === '"') {
50
+ inQuote = false;
51
+ }
52
+ continue;
53
+ }
54
+ if (inLiteral) {
55
+ if (c === ']') {
56
+ inLiteral = false;
57
+ }
58
+ continue;
59
+ }
60
+ if (c === '"' && honorQuotes && !positions.length) {
61
+ inQuote = true;
62
+ } else if (c === '[' && positions.length) {
63
+ inLiteral = true;
64
+ } else if (c === '@') {
65
+ positions.push(i);
66
+ }
67
+ }
68
+
69
+ if (inQuote) {
70
+ return findSeparators(address, false);
71
+ }
72
+
73
+ return positions;
74
+ };
75
+
76
+ // Normalizes the domain part of an address. Returns { domain, authorDomain } or null when it is
77
+ // not a domain. "domain" is lower case and an A-label (RFC 9989 5.3.1), "authorDomain" also has
78
+ // the trailing root dot removed and is the name that is queried and compared.
79
+ const normalizeDomain = value => {
80
+ let domain = (value || '').trim().toLowerCase();
81
+ if (!domain) {
82
+ return null;
83
+ }
84
+
85
+ if (!DOMAIN_LITERAL.test(domain)) {
86
+ if (INVALID_DOMAIN_CHARS.test(domain)) {
87
+ return null;
88
+ }
89
+ domain = toALabel(domain);
90
+ }
91
+
92
+ let authorDomain = formatDomain(domain);
93
+ if (!authorDomain) {
94
+ // "user@." has no domain name, only the root
95
+ return null;
96
+ }
97
+
98
+ return { domain, authorDomain };
99
+ };
100
+
101
+ // Returns the normalized domains found in one address. A valid addr-spec has one domain, the text
102
+ // after the "@" that ends the local-part. An address with more than one "@" outside a quoted string
103
+ // is malformed and every candidate is returned, so that it counts as more than one Author Domain.
104
+ const getAddressDomains = address => {
105
+ if (typeof address !== 'string') {
106
+ return [];
107
+ }
108
+
109
+ address = address.trim();
110
+
111
+ if (address.indexOf('@') < 0) {
112
+ // not an address, but dmarc() has always accepted a bare domain name here
113
+ let entry = normalizeDomain(address);
114
+ return entry ? [entry] : [];
115
+ }
116
+
117
+ address = stripRoute(address);
118
+
119
+ let positions = findSeparators(address, true);
120
+ let domains = [];
121
+ for (let i = 0; i < positions.length; i++) {
122
+ let entry = normalizeDomain(address.slice(positions[i] + 1, i + 1 < positions.length ? positions[i + 1] : address.length));
123
+ if (entry) {
124
+ domains.push(entry);
125
+ }
126
+ }
127
+
128
+ return domains;
129
+ };
130
+
131
+ // Extracts the Author Domain from the RFC5322.From addresses (RFC 9989 5.3.1). Several mailboxes
132
+ // are fine as long as they all use the same domain. Returns
133
+ // { domain, authorDomain, mailboxes } when there is exactly one Author Domain, or
134
+ // { reason, domains, mailboxes } when DMARC validation is not possible, where "reason" is
135
+ // "no-author-domain", "multiple-author-domains", "invalid-author-domain" or
136
+ // "multiple-from-fields".
137
+ // "fromFields" is the number of From header fields the addresses came from, if known. RFC 5322
138
+ // allows only one, and with several the DKIM signature can cover a different field than the one
139
+ // a mail client shows, so their addresses are not merged into one Author Domain.
140
+ const getAuthorDomain = (headerFrom, fromFields) => {
141
+ let addresses = [].concat(headerFrom === undefined || headerFrom === null ? [] : headerFrom);
142
+
143
+ let found = new Map();
144
+ let invalid = false;
145
+ for (let address of addresses) {
146
+ let entries = getAddressDomains(address);
147
+ if (!entries.length) {
148
+ // a mailbox without a usable domain is not skipped, the domain a reader sees for it
149
+ // is unknown, so the Author Domain can not be determined
150
+ invalid = true;
151
+ }
152
+ for (let entry of entries) {
153
+ if (!found.has(entry.authorDomain)) {
154
+ found.set(entry.authorDomain, entry);
155
+ }
156
+ }
157
+ }
158
+
159
+ let mailboxes = addresses.length;
160
+
161
+ if (fromFields > 1) {
162
+ return { reason: 'multiple-from-fields', domains: Array.from(found.keys()), mailboxes };
163
+ }
164
+
165
+ if (invalid && found.size) {
166
+ return { reason: 'invalid-author-domain', domains: Array.from(found.keys()), mailboxes };
167
+ }
168
+
169
+ if (found.size === 1) {
170
+ let [entry] = found.values();
171
+ return { domain: entry.domain, authorDomain: entry.authorDomain, mailboxes };
172
+ }
173
+
174
+ return {
175
+ reason: found.size ? 'multiple-author-domains' : 'no-author-domain',
176
+ domains: Array.from(found.keys()),
177
+ mailboxes
178
+ };
179
+ };
180
+
181
+ module.exports = { getAuthorDomain, getAddressDomains };