mailauth 4.13.3 → 5.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "4.13.3"
2
+ ".": "5.0.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.0](https://github.com/postalsys/mailauth/compare/v4.13.3...v5.0.0) (2026-08-06)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * Node.js 20 is no longer supported, the minimum is now 22.19.0 (the engine floor of undici 8).
9
+ * **dmarc:** adkim=s and aspf=s now actually fail when only the organizational domains match. Previously strict alignment fell through to the relaxed comparison, so these records behaved as adkim=r/aspf=r and such messages passed. Senders that publish strict alignment but sign or send from a different host under the same organizational domain will flip from pass to fail. dmarc.status.header.from now reports the From domain rather than the organizational domain, dmarc.status.header.d reports the domain the record was found at, and dkim.results[].status.aligned is false for org-level-only matches when the domain publishes adkim=s.
10
+
11
+ ### Features
12
+
13
+ * add seal-only mode to the seal CLI command ([#119](https://github.com/postalsys/mailauth/issues/119)) ([afdfc3d](https://github.com/postalsys/mailauth/commit/afdfc3d1cb7b61aecbf60eda96f2c0be87b0c9cf))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **arc:** report public key failures instead of a bare arc=fail ([3464be7](https://github.com/postalsys/mailauth/commit/3464be70dd2449038e3c0381acbe4565d28370ba))
19
+ * **cli:** validate seal-only options and dedupe the seal command paths ([17dd59c](https://github.com/postalsys/mailauth/commit/17dd59ca472efc477fe041de48d2c2d8a730f3e7))
20
+ * close residual parser and DMARC discovery edge cases ([f1c28e1](https://github.com/postalsys/mailauth/commit/f1c28e10f331bea1f23ff2d861e00c7700f3b97f))
21
+ * **dmarc:** normalize tag values and report the correct identifiers ([e5dc758](https://github.com/postalsys/mailauth/commit/e5dc75827ff190912515572546bea0745c3629ff))
22
+ * enforce DMARC strict identifier alignment (adkim=s/aspf=s) ([0c4bab7](https://github.com/postalsys/mailauth/commit/0c4bab7f747c70121f72b76b53ad704712f3a56d))
23
+ * harden tag and header parsers against crafted property keys ([7eb9ea2](https://github.com/postalsys/mailauth/commit/7eb9ea24ed19032b63e9f9993971b77b8d77109e))
24
+ * require Node.js >= 22.19.0 ([1c689c5](https://github.com/postalsys/mailauth/commit/1c689c5444821e5e188cef35c5aa3de3b2d8cfbe))
25
+ * **tools:** do not treat a legacy getAlignment options object as strict ([2007ab4](https://github.com/postalsys/mailauth/commit/2007ab41cccc41b531f3e5ec65899264b6e3441d))
26
+
3
27
  ## [4.13.3](https://github.com/postalsys/mailauth/compare/v4.13.2...v4.13.3) (2026-05-14)
4
28
 
5
29
 
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  - **BIMI** resolving and **VMC** validation
16
16
  - **MTA-STS** helper functions
17
17
 
18
- mailauth is a pure JavaScript implementation, requiring no external applications or compilation. It runs on any server or device with Node.js version 20.18.1 or later.
18
+ mailauth is a pure JavaScript implementation, requiring no external applications or compilation. It runs on any server or device with Node.js version 22.19.0 or later.
19
19
 
20
20
  > [!NOTE]
21
21
  > mailauth is used by [EmailEngine](https://emailengine.app/) for validating email authentication settings. See the [Email Authentication Testing documentation](https://learn.emailengine.app/docs/advanced/email-authentication-testing) for details.
package/bin/mailauth.js CHANGED
@@ -230,6 +230,51 @@ const argv = yargs(hideBin(process.argv))
230
230
  alias: 'o',
231
231
  type: 'boolean',
232
232
  description: 'If set, outputs only the ARC seal headers without the message body.'
233
+ })
234
+ .option('auth-results', {
235
+ type: 'string',
236
+ description:
237
+ 'Authentication-Results value to embed in the ARC-Authentication-Results header (the part after "i=N;"). When set, the message is sealed using this value as is and no authentication checks are performed.'
238
+ })
239
+ .option('auth-results-file', {
240
+ type: 'string',
241
+ description: 'Path to a file containing the Authentication-Results value. Same as --auth-results, but read from a file.'
242
+ })
243
+ .option('cv', {
244
+ type: 'string',
245
+ choices: ['none', 'pass', 'fail'],
246
+ description:
247
+ 'Chain validation status for the ARC-Seal header (cv= tag). Only used together with --auth-results or --auth-results-file. Defaults to "none".'
248
+ })
249
+ .option('instance', {
250
+ type: 'number',
251
+ description:
252
+ 'ARC instance number (i= tag). Only used together with --auth-results or --auth-results-file. Defaults to the next instance number based on the existing ARC chain, or 1.'
253
+ })
254
+ .conflicts('auth-results', 'auth-results-file')
255
+ .check(argv => {
256
+ for (let key of ['auth-results', 'auth-results-file', 'cv', 'instance']) {
257
+ if (Array.isArray(argv[key])) {
258
+ throw new Error(`--${key} can only be provided once`);
259
+ }
260
+ }
261
+ // a missing value would silently flip the command back into full
262
+ // authentication mode, the opposite of what the caller asked for
263
+ if ('auth-results' in argv && (typeof argv.authResults !== 'string' || !argv.authResults.trim())) {
264
+ throw new Error('--auth-results must not be empty');
265
+ }
266
+ if ('auth-results-file' in argv && (typeof argv.authResultsFile !== 'string' || !argv.authResultsFile.trim())) {
267
+ throw new Error('--auth-results-file must not be empty');
268
+ }
269
+ // .implies() cannot express "requires one of", so enforce it here
270
+ if (!argv.authResults && !argv.authResultsFile) {
271
+ for (let key of ['cv', 'instance']) {
272
+ if (key in argv) {
273
+ throw new Error(`--${key} can only be used together with --auth-results or --auth-results-file`);
274
+ }
275
+ }
276
+ }
277
+ return true;
233
278
  });
234
279
  yargs.positional('email', {
235
280
  describe: 'Path to the email message file in EML format. If not specified, the content is read from standard input.'
package/cli.md CHANGED
@@ -177,6 +177,20 @@ mailauth seal [options] [email]
177
177
  - `--header-fields "field1:field2"`, `-h "field1:field2"`: Colon-separated list of header fields to include in the seal (`h=` tag).
178
178
  - `--headers-only`, `-o`: Outputs only the ARC seal headers without the entire message.
179
179
 
180
+ **Seal-Only Options:**
181
+
182
+ By default, `seal` authenticates the message (SPF, DKIM, DMARC, ARC) and embeds the computed results in the `ARC-Authentication-Results` header. If the message was already authenticated elsewhere (for example at an edge MTA) and modified afterwards, you can instead provide the original `Authentication-Results` value yourself. When one of the following options is set, no authentication checks or DNS lookups are performed:
183
+
184
+ - `--auth-results "authserv-id; spf=pass ..."`: `Authentication-Results` value to embed in the `ARC-Authentication-Results` header (the part after `i=N;`). Used as is.
185
+ - `--auth-results-file /path/to/value.txt`: Same as `--auth-results`, but the value is read from a file. Useful for long or multi-line values. Cannot be combined with `--auth-results`.
186
+ - `--cv status`: Chain validation status for the `ARC-Seal` header (`cv=` tag): `none`, `pass`, or `fail`. Defaults to `none`.
187
+ - `--instance number`: ARC instance number (`i=` tag). Defaults to the next instance number based on the existing ARC chain of the message, or `1`.
188
+
189
+ ```bash
190
+ mailauth seal message.eml -d example.com -s s1 -k private.key \
191
+ --cv pass --auth-results-file auth-results.txt --headers-only
192
+ ```
193
+
180
194
  **Authentication Options (from `report` command):**
181
195
 
182
196
  - `--client-ip x.x.x.x`, `-i x.x.x.x`: IP address of the remote client that sent the email.
package/docs/dmarc.md CHANGED
@@ -41,10 +41,10 @@ const { dmarc } = await authenticate(message, {
41
41
 
42
42
  ### status.header Object
43
43
 
44
- | Field | Type | Description |
45
- | ------ | -------- | ------------------------------------------ |
46
- | `from` | `string` | Organizational domain from the From header |
47
- | `d` | `string` | Domain where DMARC record was found |
44
+ | Field | Type | Description |
45
+ | ------ | -------- | ----------------------------------- |
46
+ | `from` | `string` | Domain of the From header address |
47
+ | `d` | `string` | Domain where DMARC record was found |
48
48
 
49
49
  ## alignment Object
50
50
 
@@ -55,18 +55,20 @@ const { dmarc } = await authenticate(message, {
55
55
 
56
56
  ### alignment.spf Object
57
57
 
58
- | Field | Type | Description |
59
- | -------- | --------------- | ----------------------------------------------------------- |
60
- | `result` | `string\|false` | Aligned domain if SPF passed and aligned, otherwise `false` |
61
- | `strict` | `boolean` | Whether strict alignment is required (`aspf=s`) |
58
+ | Field | Type | Description |
59
+ | -------- | ------------------- | --------------------------------------------------------------- |
60
+ | `result` | `string\|undefined` | Aligned domain if SPF passed and aligned, otherwise `undefined` |
61
+ | `strict` | `boolean` | Whether strict alignment is required (`aspf=s`) |
62
62
 
63
63
  ### alignment.dkim Object
64
64
 
65
- | Field | Type | Description |
66
- | ------------ | --------------- | ------------------------------------------------------------ |
67
- | `result` | `string\|false` | Aligned domain if DKIM passed and aligned, otherwise `false` |
68
- | `strict` | `boolean` | Whether strict alignment is required (`adkim=s`) |
69
- | `underSized` | `number` | Number of unsigned body bytes (if `l=` tag limited body) |
65
+ | Field | Type | Description |
66
+ | ------------ | ------------------- | ---------------------------------------------------------------- |
67
+ | `result` | `string\|undefined` | Aligned domain if DKIM passed and aligned, otherwise `undefined` |
68
+ | `strict` | `boolean` | Whether strict alignment is required (`adkim=s`) |
69
+ | `underSized` | `number` | Number of unsigned body bytes (if `l=` tag limited body) |
70
+
71
+ `underSized` is reported for a signature that aligns at the organizational domain even when `adkim=s` rejected it, so that it stays a reliable content-integrity warning regardless of the alignment mode the domain publishes.
70
72
 
71
73
  ## Result Values
72
74
 
@@ -103,21 +105,19 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
103
105
  {
104
106
  "status": {
105
107
  "result": "pass",
108
+ "comment": "p=REJECT",
106
109
  "header": {
107
110
  "from": "example.com",
108
111
  "d": "example.com"
109
- },
110
- "comment": "p=REJECT arc=none"
112
+ }
111
113
  },
112
114
  "domain": "example.com",
113
115
  "policy": "reject",
114
116
  "p": "reject",
115
117
  "sp": "reject",
116
- "pct": 100,
117
118
  "rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
118
119
  "alignment": {
119
120
  "spf": {
120
- "result": false,
121
121
  "strict": false
122
122
  },
123
123
  "dkim": {
@@ -125,7 +125,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
125
125
  "strict": false
126
126
  }
127
127
  },
128
- "info": "dmarc=pass (p=REJECT arc=none) header.from=example.com"
128
+ "info": "dmarc=pass (p=REJECT) header.from=example.com header.d=example.com"
129
129
  }
130
130
  ```
131
131
 
@@ -135,29 +135,26 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
135
135
  {
136
136
  "status": {
137
137
  "result": "fail",
138
+ "comment": "p=REJECT",
138
139
  "header": {
139
140
  "from": "example.com",
140
141
  "d": "example.com"
141
- },
142
- "comment": "p=REJECT"
142
+ }
143
143
  },
144
144
  "domain": "example.com",
145
145
  "policy": "reject",
146
146
  "p": "reject",
147
147
  "sp": "reject",
148
- "pct": 100,
149
148
  "rr": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
150
149
  "alignment": {
151
150
  "spf": {
152
- "result": false,
153
151
  "strict": false
154
152
  },
155
153
  "dkim": {
156
- "result": false,
157
154
  "strict": false
158
155
  }
159
156
  },
160
- "info": "dmarc=fail (p=REJECT) header.from=example.com"
157
+ "info": "dmarc=fail (p=REJECT) header.from=example.com header.d=example.com"
161
158
  }
162
159
  ```
163
160
 
@@ -171,7 +168,7 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
171
168
  "from": "no-dmarc.example.com"
172
169
  }
173
170
  },
174
- "domain": "no-dmarc.example.com",
171
+ "domain": "example.com",
175
172
  "info": "dmarc=none header.from=no-dmarc.example.com"
176
173
  }
177
174
  ```
@@ -182,29 +179,27 @@ For example: `"p=REJECT sp=REJECT arc=pass"`
182
179
  {
183
180
  "status": {
184
181
  "result": "pass",
182
+ "comment": "p=NONE sp=QUARANTINE",
185
183
  "header": {
186
- "from": "example.com",
187
- "d": "sub.example.com"
188
- },
189
- "comment": "p=NONE sp=QUARANTINE"
184
+ "from": "sub.example.com",
185
+ "d": "example.com"
186
+ }
190
187
  },
191
188
  "domain": "example.com",
192
189
  "policy": "quarantine",
193
190
  "p": "none",
194
191
  "sp": "quarantine",
195
- "pct": 100,
196
192
  "rr": "v=DMARC1; p=none; sp=quarantine; rua=mailto:dmarc@example.com",
197
193
  "alignment": {
198
194
  "spf": {
199
- "result": "example.com",
195
+ "result": "sub.example.com",
200
196
  "strict": false
201
197
  },
202
198
  "dkim": {
203
- "result": false,
204
199
  "strict": false
205
200
  }
206
201
  },
207
- "info": "dmarc=pass (p=NONE sp=QUARANTINE) header.from=example.com"
202
+ "info": "dmarc=pass (p=NONE sp=QUARANTINE) header.from=sub.example.com header.d=example.com"
208
203
  }
209
204
  ```
210
205
 
package/index.d.ts CHANGED
@@ -179,8 +179,15 @@ export interface DKIMResult {
179
179
  * Verification status
180
180
  */
181
181
  status: AuthStatus & {
182
- aligned?: boolean;
183
- underSized?: boolean;
182
+ /**
183
+ * Signing domain if it aligns with the From domain, false otherwise
184
+ */
185
+ aligned?: string | false;
186
+
187
+ /**
188
+ * Number of body bytes left unsigned by an l= tag
189
+ */
190
+ underSized?: number;
184
191
  };
185
192
 
186
193
  /**
@@ -392,13 +399,25 @@ export interface DMARCResult {
392
399
  */
393
400
  alignment: {
394
401
  spf: {
395
- result: string | false;
402
+ /**
403
+ * Aligned SPF domain, or undefined if no domain aligned
404
+ */
405
+ result?: string;
396
406
  strict: boolean;
397
407
  };
398
408
  dkim: {
399
- result: string | false;
409
+ /**
410
+ * Aligned signing domain, or undefined if no domain aligned
411
+ */
412
+ result?: string;
400
413
  strict: boolean;
401
- underSized?: boolean;
414
+
415
+ /**
416
+ * Number of body bytes left unsigned by an l= tag. Reported for a signature that
417
+ * aligns at the organizational domain even if adkim=s rejected it, so that it stays
418
+ * a reliable content-integrity warning regardless of the alignment mode.
419
+ */
420
+ underSized?: number;
402
421
  };
403
422
  };
404
423
 
@@ -968,8 +987,8 @@ export interface DMARCOptions {
968
987
  dkimDomains?: Array<{
969
988
  id?: string;
970
989
  domain: string;
971
- aligned?: boolean;
972
- underSized?: boolean;
990
+ aligned?: string | false;
991
+ underSized?: number;
973
992
  }>;
974
993
 
975
994
  /**
package/lib/arc/index.js CHANGED
@@ -57,6 +57,8 @@ const verifyAS = async (chain, opts) => {
57
57
  publicKey = res?.publicKey;
58
58
  } catch (err) {
59
59
  err.queryDomain = queryDomain;
60
+ // the error code is what the ARC result reports as a comment
61
+ throw err;
60
62
  }
61
63
 
62
64
  let pass = crypto.verify(
@@ -419,7 +421,7 @@ const arc = async (data, opts) => {
419
421
  break;
420
422
 
421
423
  case 'ESHORTKEY':
422
- status.policy['dkim-rules'] = `weak-key`;
424
+ status.policy = { 'dkim-rules': 'weak-key' };
423
425
  if (err.queryDomain) {
424
426
  status.comment = `weak key for ${err.queryDomain}`;
425
427
  }
@@ -434,7 +436,8 @@ const arc = async (data, opts) => {
434
436
  if (result.authenticationResults.dkim && result.authenticationResults.dkim.length) {
435
437
  for (let entry of result.authenticationResults.dkim) {
436
438
  comment.push(`dkim=${entry.result}`);
437
- if (entry?.header?.i) {
439
+ // a crafted dotted key (header.i.x=1) parses into an object, not a string
440
+ if (typeof entry?.header?.i === 'string') {
438
441
  comment.push(`dkdomain=${entry.header.i.replace(/^@/, '')}`);
439
442
  }
440
443
  }
@@ -442,8 +445,8 @@ const arc = async (data, opts) => {
442
445
 
443
446
  if (result.authenticationResults.dmarc) {
444
447
  comment.push(`dmarc=${result.authenticationResults.dmarc.result}`);
445
- if (result.authenticationResults.dmarc?.header?.from) {
446
- comment.push(`fromdomain=${result.authenticationResults.dmarc?.header?.from}`);
448
+ if (typeof result.authenticationResults.dmarc?.header?.from === 'string') {
449
+ comment.push(`fromdomain=${result.authenticationResults.dmarc.header.from}`);
447
450
  }
448
451
  }
449
452
 
@@ -1,10 +1,40 @@
1
1
  'use strict';
2
2
 
3
3
  const { authenticate } = require('../mailauth');
4
+ const { sealMessage } = require('../arc');
4
5
  const fs = require('node:fs');
5
6
  const { GathererStream } = require('../gatherer-stream');
6
7
  const { resolve } = require('node:dns').promises;
7
8
 
9
+ const writeMessageBody = async gatherer =>
10
+ new Promise((resolve, reject) => {
11
+ let msgStream = gatherer.replay();
12
+
13
+ msgStream.pipe(process.stdout, { end: false });
14
+ msgStream.on('end', () => {
15
+ resolve();
16
+ });
17
+
18
+ msgStream.on('error', err => {
19
+ reject(err);
20
+ });
21
+ });
22
+
23
+ const printSignatureInfo = opts => {
24
+ if (opts.signingDomain) {
25
+ console.error(`Signing domain: ${opts.signingDomain}`);
26
+ }
27
+ if (opts.selector) {
28
+ console.error(`Key selector: ${opts.selector}`);
29
+ }
30
+ if (opts.algorithm) {
31
+ console.error(`Hashing algorithm: ${opts.algorithm}`);
32
+ }
33
+ if (opts.signTime) {
34
+ console.error(`Signing time: ${opts.signTime.toISOString()}`);
35
+ }
36
+ };
37
+
8
38
  const cmd = async argv => {
9
39
  let source = argv.email;
10
40
  let useStdin = false;
@@ -30,6 +60,7 @@ const cmd = async argv => {
30
60
  stream.on('error', err => gatherer.emit('error', err));
31
61
 
32
62
  let privateKey = await fs.promises.readFile(argv.privateKey, 'utf-8');
63
+
33
64
  let signatureOpts = {
34
65
  signingDomain: argv.domain,
35
66
  selector: argv.selector,
@@ -39,19 +70,46 @@ const cmd = async argv => {
39
70
  signTime: argv.time ? new Date(argv.time * 1000) : new Date()
40
71
  };
41
72
 
42
- if (argv.verbose) {
43
- if (signatureOpts.signingDomain) {
44
- console.error(`Signing domain: ${signatureOpts.signingDomain}`);
73
+ if (argv.authResults || argv.authResultsFile) {
74
+ // Seal-only mode: embed the caller-provided Authentication-Results value without authenticating the message
75
+ let authResults = argv.authResultsFile ? (await fs.promises.readFile(argv.authResultsFile, 'utf-8')).trim() : argv.authResults;
76
+
77
+ let sealOpts = Object.assign({}, signatureOpts, {
78
+ authResults,
79
+ cv: argv.cv || 'none'
80
+ });
81
+
82
+ if (argv.instance) {
83
+ sealOpts.i = argv.instance;
45
84
  }
46
- if (signatureOpts.selector) {
47
- console.error(`Key selector: ${signatureOpts.selector}`);
85
+
86
+ if (argv.verbose) {
87
+ console.error('Seal-only mode: sealing with provided authentication results, skipping authentication');
88
+ printSignatureInfo(sealOpts);
89
+ console.error(`Chain validation status: ${sealOpts.cv}`);
90
+ if (sealOpts.i) {
91
+ console.error(`ARC instance: ${sealOpts.i}`);
92
+ }
93
+ console.error('--------');
48
94
  }
49
- if (signatureOpts.algorithm) {
50
- console.error(`Hashing algorithm: ${signatureOpts.algorithm}`);
95
+
96
+ let sealHeaders = await sealMessage(gatherer, sealOpts);
97
+
98
+ // createSeal writes the instance it actually used back into the seal options
99
+ if (sealOpts.i > 1 && !argv.cv) {
100
+ console.error(`warning: sealed with i=${sealOpts.i} and cv=none; a chain with i>1 only validates when cv=pass (use --cv pass)`);
51
101
  }
52
- if (signatureOpts.signTime) {
53
- console.error(`Signing time: ${signatureOpts.signTime.toISOString()}`);
102
+
103
+ process.stdout.write(sealHeaders);
104
+ if (!argv.headersOnly) {
105
+ // print full message as well
106
+ await writeMessageBody(gatherer);
54
107
  }
108
+ return;
109
+ }
110
+
111
+ if (argv.verbose) {
112
+ printSignatureInfo(signatureOpts);
55
113
  if (signatureOpts.headerList) {
56
114
  console.error(`Header fields to seal: ${signatureOpts.headerList}`);
57
115
  }
@@ -113,18 +171,7 @@ const cmd = async argv => {
113
171
  process.stdout.write(result.headers);
114
172
  if (!argv.headersOnly) {
115
173
  // print full message as well
116
- await new Promise((resolve, reject) => {
117
- let msgStream = gatherer.replay();
118
-
119
- msgStream.pipe(process.stdout, { end: false });
120
- msgStream.on('end', () => {
121
- resolve();
122
- });
123
-
124
- msgStream.on('error', err => {
125
- reject(err);
126
- });
127
- });
174
+ await writeMessageBody(gatherer);
128
175
  }
129
176
  };
130
177
 
@@ -4,24 +4,26 @@ const dns = require('node:dns').promises;
4
4
  const tldts = require('tldts');
5
5
  const { TLDTS_OPTS } = require('../tools');
6
6
 
7
+ // Tags whose values are ABNF literals in RFC 7489 6.4 / RFC 9989 4.7 and are therefore
8
+ // case insensitive (RFC 5234 2.3). Values of other tags keep their case: "v" is defined
9
+ // with explicit hex literals and is case sensitive, "rua"/"ruf" are URIs.
10
+ const CASE_INSENSITIVE_TAGS = new Set(['p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rf', 't', 'psd']);
11
+
12
+ // Returns every DMARC record published for a domain. The caller distinguishes an empty set,
13
+ // which continues policy discovery at the organizational domain, from a multi record set,
14
+ // which terminates it (RFC 7489 6.6.3 step 5).
15
+ // The version tag must be the first tag and its value "DMARC1" must match precisely
16
+ // (RFC 9989 4.7 "v"), while the ABNF allows *WSP around "=" and a case insensitive
17
+ // tag name, hence [vV] instead of an /i flag that would also relax the value
18
+ const DMARC_VERSION_RE = /^[vV][ \t]*=[ \t]*DMARC1[ \t]*(?:;|$)/;
19
+
7
20
  const resolveTxt = async (domain, resolver) => {
8
21
  try {
9
22
  let txt = await resolver(`_dmarc.${domain}`, 'TXT');
10
- if (!txt || !txt.length) {
11
- return false;
12
- }
13
-
14
- txt = txt.map(row => row.join('').trim()).filter(row => /^v=DMARC1\b/i.test(row));
15
-
16
- if (txt.length !== 1) {
17
- //no records or multiple records yield in no policy
18
- return false;
19
- }
20
-
21
- return txt[0];
23
+ return (txt || []).map(row => row.join('').trim()).filter(row => DMARC_VERSION_RE.test(row));
22
24
  } catch (err) {
23
25
  if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
24
- return false;
26
+ return [];
25
27
  }
26
28
  throw err;
27
29
  }
@@ -30,22 +32,27 @@ const resolveTxt = async (domain, resolver) => {
30
32
  const getDmarcRecord = async (domain, resolver) => {
31
33
  resolver = resolver || dns.resolve;
32
34
 
33
- let txt = await resolveTxt(domain, resolver);
35
+ let records = await resolveTxt(domain, resolver);
34
36
  let isOrgRecord = false;
35
37
 
36
- if (!txt) {
38
+ if (!records.length) {
37
39
  let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
38
- if (orgDomain !== domain) {
40
+ // tldts returns null for IPs, single labels and other non-domains, and querying
41
+ // "_dmarc.null" would coerce that into a hostname
42
+ if (orgDomain && orgDomain !== domain) {
39
43
  // try org domain as well
40
- txt = await resolveTxt(orgDomain, resolver);
44
+ records = await resolveTxt(orgDomain, resolver);
41
45
  isOrgRecord = true;
42
46
  }
43
47
  }
44
48
 
45
- if (!txt) {
49
+ if (records.length !== 1) {
50
+ // no record, or a multi record set that terminates policy discovery
46
51
  return false;
47
52
  }
48
53
 
54
+ let txt = records[0];
55
+
49
56
  let parsed = Object.fromEntries(
50
57
  txt
51
58
  .split(';')
@@ -58,10 +65,13 @@ const getDmarcRecord = async (domain, resolver) => {
58
65
  } else if (splitPos === 0) {
59
66
  return [false, e];
60
67
  }
68
+ // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
61
69
  let key = e.substr(0, splitPos).toLowerCase().trim();
62
- let val = e.substr(splitPos + 1);
70
+ let val = e.substr(splitPos + 1).trim();
63
71
  if (['pct', 'ri'].includes(key)) {
64
72
  val = parseInt(val, 10) || 0;
73
+ } else if (CASE_INSENSITIVE_TAGS.has(key)) {
74
+ val = val.toLowerCase();
65
75
  }
66
76
  return [key, val];
67
77
  })
@@ -48,7 +48,9 @@ const verifyDmarc = async opts => {
48
48
  comment: false,
49
49
  // ptype properties
50
50
  header: {
51
- from: orgDomain || domain
51
+ // RFC 8601 2.7.2: header.from is the domain of the RFC5322.From address,
52
+ // not the organizational domain the policy may have been inherited from
53
+ from: domain
52
54
  }
53
55
  };
54
56
 
@@ -68,8 +70,8 @@ const verifyDmarc = async opts => {
68
70
  return formatResponse({ status, domain: orgDomain || domain });
69
71
  }
70
72
 
71
- status.header = status.header || {};
72
- status.header.d = domain.split('_dmarc.').pop();
73
+ // the domain the applied policy record was published at
74
+ status.header.d = dmarcRecord.isOrgRecord ? orgDomain : domain;
73
75
 
74
76
  status.comment = []
75
77
  .concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : [])
@@ -80,8 +82,16 @@ const verifyDmarc = async opts => {
80
82
  // use "sp" if this is a subdomain of an org domain and "sp" is set, otherwise use "p"
81
83
  const policy = dmarcRecord.isOrgRecord && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
82
84
 
83
- const dkimAlignment = getAlignment(domain, dkimDomains, { strict: dmarcRecord.adkim === 's' });
84
- const spfAlignment = getAlignment(domain, spfDomains, { strict: dmarcRecord.aspf === 's' });
85
+ const adkimStrict = dmarcRecord.adkim === 's';
86
+ const aspfStrict = dmarcRecord.aspf === 's';
87
+
88
+ const dkimAlignment = getAlignment(domain, dkimDomains, adkimStrict);
89
+ const spfAlignment = getAlignment(domain, spfDomains, aspfStrict);
90
+
91
+ // "underSized" warns that a passing DKIM signature covers only part of the body (l= tag).
92
+ // It describes the signature rather than the alignment mode, so it is still reported when
93
+ // strict alignment rejected a signature that aligns at the org level.
94
+ const underSized = (adkimStrict && !dkimAlignment ? getAlignment(domain, dkimDomains, false) : dkimAlignment)?.underSized;
85
95
 
86
96
  if (dkimAlignment || spfAlignment) {
87
97
  // pass
@@ -101,8 +111,8 @@ const verifyDmarc = async opts => {
101
111
  rr: dmarcRecord.rr,
102
112
 
103
113
  alignment: {
104
- spf: { result: spfAlignment?.domain, strict: dmarcRecord.aspf === 's' },
105
- dkim: { result: dkimAlignment?.domain, strict: dmarcRecord.adkim === 's', underSized: dkimAlignment?.underSized }
114
+ spf: { result: spfAlignment?.domain, strict: aspfStrict },
115
+ dkim: { result: dkimAlignment?.domain, strict: adkimStrict, underSized }
106
116
  }
107
117
  });
108
118
  };
package/lib/mailauth.js CHANGED
@@ -8,6 +8,7 @@ const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./a
8
8
  const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
9
9
  const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
10
10
  const { parseReceived } = require('./parse-received');
11
+ const { getAlignment } = require('./tools');
11
12
  const libmime = require('libmime');
12
13
  const os = require('node:os');
13
14
  const { isIP } = require('net');
@@ -133,6 +134,14 @@ const authenticate = async (input, opts) => {
133
134
  if (dmarcResult.info) {
134
135
  arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`);
135
136
  }
137
+
138
+ if (dmarcResult.alignment?.dkim?.strict) {
139
+ // the DKIM verifier only knows relaxed alignment, it runs before the DMARC record
140
+ let fromDomain = dmarcResult.status.header.from;
141
+ for (let row of dkimResult.results || []) {
142
+ row.status.aligned = (row.status.aligned && getAlignment(fromDomain, [row.signingDomain], true)?.domain) || false;
143
+ }
144
+ }
136
145
  }
137
146
 
138
147
  let bimiResult;
@@ -1,5 +1,13 @@
1
1
  'use strict';
2
2
 
3
+ const { DANGEROUS_KEYS } = require('./safe-keys');
4
+
5
+ // Keys the headerParser result pre-seeds: "header" holds the header name and "value" the
6
+ // authserv-id picked from the first key-only part. A crafted part must not overwrite them,
7
+ // readers expect strings there, not parsed entry objects. This is a schema fact of this
8
+ // parser (valueParser has its own reservation: "header" is a legitimate ptype there).
9
+ const RESERVED_RESULT_KEYS = new Set(['header', 'value']);
10
+
3
11
  // NB! fails to properly parse nested comments (should be rare enough though)
4
12
 
5
13
  const valueParser = str => {
@@ -97,8 +105,17 @@ const valueParser = str => {
97
105
  parts.slice(1).forEach(part => {
98
106
  if (part.key || part.value) {
99
107
  let path = part.key.split('.');
108
+
109
+ // A propspec is "ptype.property" (RFC 8601 2.2), so at most two segments, and
110
+ // "value" already holds the method result. Anything else would either overwrite
111
+ // that or nest an object where every reader of this shape expects a string.
112
+ if (path.length > 2 || path.some(p => p === 'value' || DANGEROUS_KEYS.has(p))) {
113
+ return;
114
+ }
115
+
100
116
  let curRes = result;
101
117
  let final = path.pop();
118
+
102
119
  for (let p of path) {
103
120
  if (typeof curRes[p] !== 'object' || !curRes[p]) {
104
121
  curRes[p] = {};
@@ -266,6 +283,11 @@ const headerParser = buf => {
266
283
  }
267
284
 
268
285
  parts.forEach(part => {
286
+ if (!part.key || RESERVED_RESULT_KEYS.has(part.key) || DANGEROUS_KEYS.has(part.key)) {
287
+ // nothing would be assigned, so do not parse the part either
288
+ return;
289
+ }
290
+
269
291
  let entry = {
270
292
  value: part.value
271
293
  };
@@ -279,15 +301,13 @@ const headerParser = buf => {
279
301
  entry.comment = part.comment;
280
302
  }
281
303
 
282
- if (part.key && !['__proto__', 'constructor'].includes(part.key)) {
283
- if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') {
284
- if (!result[part.key]) {
285
- result[part.key] = [];
286
- }
287
- result[part.key].push(entry);
288
- } else {
289
- result[part.key] = entry;
304
+ if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') {
305
+ if (!result[part.key]) {
306
+ result[part.key] = [];
290
307
  }
308
+ result[part.key].push(entry);
309
+ } else {
310
+ result[part.key] = entry;
291
311
  }
292
312
  });
293
313
 
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { DANGEROUS_KEYS } = require('./safe-keys');
4
+
3
5
  const parseReceived = buf => {
4
6
  let header = (buf || '').toString();
5
7
 
@@ -156,6 +158,10 @@ const parseReceived = buf => {
156
158
  result.tls = { value: '', ...(val.comment && { comment: val.comment }) };
157
159
  val.comment = '';
158
160
  }
161
+ if (DANGEROUS_KEYS.has(key)) {
162
+ // skip the assignment only: the transport security of the hop is worth keeping
163
+ continue;
164
+ }
159
165
  result[key] = { value: val.value, ...(val.comment && { comment: val.comment }) };
160
166
  } else if (!result.tls && /tls|cipher=|Google Transport Security/i.test(val.comment)) {
161
167
  result.tls = { value: val.value, ...(val.comment && { comment: val.comment }) };
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ // Property names that must never be used as parsed keys. Assigning "__proto__" replaces the
4
+ // prototype of the parsed object instead of adding a property to it, and assigning any other
5
+ // inherited Object.prototype member shadows that method, so later string coercion of the
6
+ // object (or a hasOwnProperty call on it) throws instead of returning a value.
7
+ // "prototype" is the only name here that is not already an own property of Object.prototype.
8
+ const DANGEROUS_KEYS = new Set(['prototype', ...Object.getOwnPropertyNames(Object.prototype)]);
9
+
10
+ module.exports = { DANGEROUS_KEYS };
package/lib/tools.js CHANGED
@@ -191,7 +191,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
191
191
  const header =
192
192
  `${headerKey}: ` +
193
193
  Object.keys(values)
194
- .filter(key => values[key] !== false && typeof values[key] !== 'undefined' && values.key !== null && keyOrdering.includes(key))
194
+ .filter(key => values[key] !== false && typeof values[key] !== 'undefined' && values[key] !== null && keyOrdering.includes(key))
195
195
  .sort((a, b) => keyOrdering.indexOf(a) - keyOrdering.indexOf(b))
196
196
  .map(key => {
197
197
  let val = values[key] || '';
@@ -471,10 +471,17 @@ const formatDomain = domain => {
471
471
  } catch (err) {
472
472
  // ignore punycode errors
473
473
  }
474
- return domain;
474
+ // the root label is not part of the name, so "example.com." and "example.com" are the same domain
475
+ return domain.replace(/\.+$/, '');
475
476
  };
476
477
 
477
478
  const getAlignment = (fromDomain, domainList, strict) => {
479
+ // This argument used to be an options object. Every object is truthy, so a leftover
480
+ // { strict: false } call would otherwise mean hard strict, the opposite of what it asks for.
481
+ if (strict && typeof strict === 'object') {
482
+ strict = strict.strict;
483
+ }
484
+
478
485
  domainList = []
479
486
  .concat(domainList || [])
480
487
  .map(entry => {
@@ -486,20 +493,28 @@ const getAlignment = (fromDomain, domainList, strict) => {
486
493
  .sort((a, b) => (a.underSized || 0) - (b.underSized || 0));
487
494
 
488
495
  if (strict) {
489
- fromDomain = formatDomain(fromDomain);
496
+ // strict alignment: the domains must be identical (RFC 9989 §3.2.10.2)
497
+ let from = formatDomain(fromDomain);
498
+ if (!from) {
499
+ // a bare root label normalizes to an empty string, which must not align with anything
500
+ return false;
501
+ }
490
502
  for (let entry of domainList) {
491
- let domain = formatDomain(tldts.getDomain(entry.domain, TLDTS_OPTS) || entry.domain);
492
- if (formatDomain(domain) === fromDomain) {
503
+ if (formatDomain(entry.domain) === from) {
493
504
  return entry;
494
505
  }
495
506
  }
507
+ return false;
496
508
  }
497
509
 
498
- // match org domains
499
- fromDomain = formatDomain(tldts.getDomain(fromDomain, TLDTS_OPTS) || fromDomain);
510
+ // relaxed alignment: the domains must share an Organizational Domain (RFC 9989 §3.2.10.1)
511
+ let fromOrg = formatDomain(tldts.getDomain(fromDomain, TLDTS_OPTS) || fromDomain);
512
+ if (!fromOrg) {
513
+ return false;
514
+ }
500
515
  for (let entry of domainList) {
501
- let domain = formatDomain(tldts.getDomain(entry.domain, TLDTS_OPTS) || entry.domain);
502
- if (domain === fromDomain) {
516
+ let entryOrg = formatDomain(tldts.getDomain(entry.domain, TLDTS_OPTS) || entry.domain);
517
+ if (entryOrg === fromOrg) {
503
518
  return entry;
504
519
  }
505
520
  }
@@ -589,7 +604,9 @@ function parseTagValueRecord(record, options = {}) {
589
604
 
590
605
  // Split on semicolons
591
606
  const parts = sanitized.split(';');
592
- const tags = {};
607
+ // no prototype, so that a tag named "__proto__" is stored as a normal key instead of
608
+ // replacing the prototype, and so that "toString" and friends are not seen as already set
609
+ const tags = Object.create(null);
593
610
  const validPairs = [];
594
611
  const errors = [];
595
612
  const warnings = [];
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "4.13.3",
3
+ "version": "5.0.0",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "types": "index.d.ts",
7
7
  "scripts": {
8
- "test": "eslint \"lib/**/*.js\" \"test/**/*.js\" && mocha --recursive \"./test/**/*.js\" --reporter spec",
8
+ "test": "eslint \"lib/**/*.js\" \"test/**/*.js\" && mocha --recursive \"./test/**/*-test.js\" --reporter spec",
9
9
  "format": "prettier --write .",
10
10
  "build-source": "rm -rf node_modules package-lock.json && npm install && npm run licenses && rm -rf node_modules package-lock.json && npm install --production && rm -rf package-lock.json",
11
11
  "build-dist": "npx pkg --compress Brotli package.json && rm -rf package-lock.json && npm install && node winconf.js",
@@ -35,30 +35,30 @@
35
35
  "homepage": "https://github.com/postalsys/mailauth",
36
36
  "devDependencies": {
37
37
  "chai": "4.4.1",
38
- "eslint": "10.3.0",
38
+ "eslint": "10.8.0",
39
39
  "eslint-config-nodemailer": "1.2.0",
40
40
  "eslint-config-prettier": "10.1.8",
41
- "js-yaml": "4.1.1",
42
- "license-report": "6.8.2",
41
+ "js-yaml": "5.2.3",
42
+ "license-report": "6.8.5",
43
43
  "mbox-reader": "1.2.0",
44
- "mocha": "11.7.5",
45
- "prettier": "3.8.3",
44
+ "mocha": "11.8.0",
45
+ "prettier": "3.9.6",
46
46
  "resedit": "3.0.2"
47
47
  },
48
48
  "dependencies": {
49
- "@postalsys/vmc": "1.1.4",
50
- "fast-xml-parser": "5.7.3",
51
- "ipaddr.js": "2.4.0",
52
- "joi": "18.2.1",
53
- "libmime": "5.3.8",
54
- "nodemailer": "8.0.7",
49
+ "@postalsys/vmc": "1.1.5",
50
+ "fast-xml-parser": "5.10.1",
51
+ "ipaddr.js": "2.5.0",
52
+ "joi": "18.2.3",
53
+ "libmime": "5.4.1",
54
+ "nodemailer": "9.0.4",
55
55
  "punycode.js": "2.3.1",
56
- "tldts": "7.0.30",
57
- "undici": "7.25.0",
56
+ "tldts": "7.4.10",
57
+ "undici": "8.10.0",
58
58
  "yargs": "17.7.2"
59
59
  },
60
60
  "engines": {
61
- "node": ">=20.18.1"
61
+ "node": ">=22.19.0"
62
62
  },
63
63
  "bin": {
64
64
  "mailauth": "bin/mailauth.js"
package/CLAUDE.md DELETED
@@ -1,66 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- mailauth is a Node.js library and CLI tool for email authentication. It implements SPF, DKIM, DMARC, ARC, BIMI, and MTA-STS protocols. Pure JavaScript, no native dependencies. Requires Node.js >= 20.18.1.
8
-
9
- ## Commands
10
-
11
- - **Run all tests (lint + mocha):** `npm test`
12
- - **Run a single test file:** `npx mocha test/dkim/body/relaxed-test.js --reporter spec`
13
- - **Run tests matching a pattern:** `npx mocha --recursive "./test/**/*.js" --reporter spec --grep "pattern"`
14
- - **Lint only:** `npx eslint "lib/**/*.js" "test/**/*.js"`
15
- - **Format code:** `npm run format` (uses Prettier)
16
-
17
- ## Code Style
18
-
19
- - CommonJS (`require`/`module.exports`), not ES modules
20
- - `'use strict'` at top of every file
21
- - Mocha + Chai (expect style) for tests
22
- - Prettier: 160 print width, 4-space indent, single quotes, no trailing commas, LF line endings
23
- - ESLint: unused vars prefixed with `_`, `no-console` allowed
24
-
25
- ## Architecture
26
-
27
- ### Entry Point
28
-
29
- `lib/mailauth.js` — exports `authenticate()` (the main all-in-one function) and individual protocol functions. The `authenticate` pipeline runs in order: DKIM verify → SPF → ARC → DMARC → BIMI, each step feeding results to the next.
30
-
31
- ### Protocol Modules
32
-
33
- Each protocol lives in its own directory under `lib/`:
34
-
35
- - **`lib/dkim/`** — DKIM signing (`sign.js`, `dkim-signer.js`, `DkimSignStream`) and verification (`verify.js`, `dkim-verifier.js`). Body canonicalization in `body/` (relaxed/simple), header canonicalization in `header/` (relaxed/simple). `message-parser.js` is the streaming RFC822 parser used by both signing and verification.
36
- - **`lib/spf/`** — SPF verification (`spf-verify.js`), macro expansion (`macro.js`). Entry point `index.js` wraps the verifier.
37
- - **`lib/dmarc/`** — DMARC verification (`verify.js`) and DNS record fetching (`get-dmarc-record.js`). Takes DKIM and SPF results as input.
38
- - **`lib/arc/`** — ARC chain validation and seal creation. `trustlist.js` contains known ARC trust anchors.
39
- - **`lib/bimi/`** — BIMI record resolution and SVG validation (`validate-svg.js`). Depends on DMARC passing.
40
- - **`lib/mta-sts.js`** — MTA-STS policy fetching and MX validation (standalone, not part of authenticate pipeline).
41
-
42
- ### Shared Utilities
43
-
44
- - **`lib/tools.js`** — DNS helpers, key parsing, header formatting, stream utilities, domain alignment checks
45
- - **`lib/parse-dkim-headers.js`** — Parses structured DKIM/ARC header fields
46
- - **`lib/parse-received.js`** — Parses Received headers
47
-
48
- ### CLI
49
-
50
- `bin/mailauth.js` — yargs-based CLI with subcommands: `report`, `sign`, `seal`, `spf`, `vmc`, `bodyhash`. Command implementations in `lib/commands/`.
51
-
52
- ### Tests
53
-
54
- Tests mirror the `lib/` structure under `test/`. Test fixtures in `test/fixtures/` include sample emails, DNS response caches, keys, and RFC test suite YAML files. Many tests use mock DNS resolvers that return fixture data instead of making real DNS queries.
55
-
56
- ### Type Definitions
57
-
58
- `index.d.ts` and per-module `.d.ts` files provide TypeScript type definitions for consumers.
59
-
60
- ### Key Design Pattern
61
-
62
- All protocol functions accept an optional `resolver` parameter (async function matching `dns.promises.resolve` signature) for DNS lookups. This enables testing with mock DNS and allows consumers to implement caching or custom resolution.
63
-
64
- ## Releases
65
-
66
- Version numbers, changelogs, and releases are managed automatically by release-please via GitHub Actions. Do not manually edit `package.json` version, `CHANGELOG.md`, or release-related files.