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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/bin/mailauth.js +45 -0
- package/cli.md +14 -0
- package/docs/dmarc.md +28 -33
- package/index.d.ts +26 -7
- package/lib/arc/index.js +7 -4
- package/lib/commands/seal.js +68 -21
- package/lib/dmarc/get-dmarc-record.js +29 -19
- package/lib/dmarc/verify.js +17 -7
- package/lib/mailauth.js +9 -0
- package/lib/parse-dkim-headers.js +28 -8
- package/lib/parse-received.js +6 -0
- package/lib/safe-keys.js +10 -0
- package/lib/tools.js +27 -10
- package/package.json +16 -16
- package/CLAUDE.md +0 -66
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
|
|
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` |
|
|
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
|
|
59
|
-
| -------- |
|
|
60
|
-
| `result` | `string\|
|
|
61
|
-
| `strict` | `boolean`
|
|
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
|
|
66
|
-
| ------------ |
|
|
67
|
-
| `result` | `string\|
|
|
68
|
-
| `strict` | `boolean`
|
|
69
|
-
| `underSized` | `number`
|
|
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
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Aligned signing domain, or undefined if no domain aligned
|
|
411
|
+
*/
|
|
412
|
+
result?: string;
|
|
400
413
|
strict: boolean;
|
|
401
|
-
|
|
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?:
|
|
972
|
-
underSized?:
|
|
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
|
|
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
|
-
|
|
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
|
|
448
|
+
if (typeof result.authenticationResults.dmarc?.header?.from === 'string') {
|
|
449
|
+
comment.push(`fromdomain=${result.authenticationResults.dmarc.header.from}`);
|
|
447
450
|
}
|
|
448
451
|
}
|
|
449
452
|
|
package/lib/commands/seal.js
CHANGED
|
@@ -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.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
35
|
+
let records = await resolveTxt(domain, resolver);
|
|
34
36
|
let isOrgRecord = false;
|
|
35
37
|
|
|
36
|
-
if (!
|
|
38
|
+
if (!records.length) {
|
|
37
39
|
let orgDomain = tldts.getDomain(domain, TLDTS_OPTS);
|
|
38
|
-
|
|
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
|
-
|
|
44
|
+
records = await resolveTxt(orgDomain, resolver);
|
|
41
45
|
isOrgRecord = true;
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
if (
|
|
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
|
})
|
package/lib/dmarc/verify.js
CHANGED
|
@@ -48,7 +48,9 @@ const verifyDmarc = async opts => {
|
|
|
48
48
|
comment: false,
|
|
49
49
|
// ptype properties
|
|
50
50
|
header: {
|
|
51
|
-
|
|
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
|
-
|
|
72
|
-
status.header.d = domain
|
|
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
|
|
84
|
-
const
|
|
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:
|
|
105
|
-
dkim: { result: dkimAlignment?.domain, strict:
|
|
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 (
|
|
283
|
-
if ([
|
|
284
|
-
|
|
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
|
|
package/lib/parse-received.js
CHANGED
|
@@ -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 }) };
|
package/lib/safe-keys.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
499
|
-
|
|
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
|
|
502
|
-
if (
|
|
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
|
-
|
|
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": "
|
|
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
|
|
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.
|
|
38
|
+
"eslint": "10.8.0",
|
|
39
39
|
"eslint-config-nodemailer": "1.2.0",
|
|
40
40
|
"eslint-config-prettier": "10.1.8",
|
|
41
|
-
"js-yaml": "
|
|
42
|
-
"license-report": "6.8.
|
|
41
|
+
"js-yaml": "5.2.3",
|
|
42
|
+
"license-report": "6.8.5",
|
|
43
43
|
"mbox-reader": "1.2.0",
|
|
44
|
-
"mocha": "11.
|
|
45
|
-
"prettier": "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.
|
|
50
|
-
"fast-xml-parser": "5.
|
|
51
|
-
"ipaddr.js": "2.
|
|
52
|
-
"joi": "18.2.
|
|
53
|
-
"libmime": "5.
|
|
54
|
-
"nodemailer": "
|
|
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.
|
|
57
|
-
"undici": "
|
|
56
|
+
"tldts": "7.4.10",
|
|
57
|
+
"undici": "8.10.0",
|
|
58
58
|
"yargs": "17.7.2"
|
|
59
59
|
},
|
|
60
60
|
"engines": {
|
|
61
|
-
"node": ">=
|
|
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.
|