mailauth 6.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +113 -39
  4. package/bin/mailauth.js +15 -6
  5. package/cli.md +9 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +80 -33
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +508 -43
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +3 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +577 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +154 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +77 -36
  37. package/lib/mta-sts.d.ts +77 -11
  38. package/lib/mta-sts.js +497 -182
  39. package/lib/parse-dkim-headers.js +376 -191
  40. package/lib/parse-received.js +104 -32
  41. package/lib/spf/index.d.ts +37 -1
  42. package/lib/spf/index.js +260 -28
  43. package/lib/spf/macro.js +50 -13
  44. package/lib/spf/spf-verify.js +440 -204
  45. package/lib/spf/syntax.js +366 -0
  46. package/lib/tools.js +522 -116
  47. package/package.json +1 -1
package/lib/spf/index.js CHANGED
@@ -1,24 +1,50 @@
1
1
  'use strict';
2
2
 
3
3
  const { spfVerify } = require('./spf-verify');
4
+ const { parseIp, isValidTargetName } = require('./syntax');
4
5
  const os = require('node:os');
5
6
  const dns = require('node:dns');
6
7
  const libmime = require('libmime');
7
8
  const Joi = require('joi');
8
9
  const domainSchema = Joi.string().domain({ allowUnicode: false, tlds: false });
9
- const { formatAuthHeaderRow, escapeCommentValue } = require('../tools');
10
+ const { formatAuthHeaderRow, escapeCommentValue, formatDotAtomOrQuoted, MAX_HEADER_VALUE_LENGTH, toALabel } = require('../tools');
10
11
 
11
12
  const MAX_RESOLVE_COUNT = 10;
12
13
  const MAX_VOID_COUNT = 2;
13
14
 
14
- const formatHeaders = result => {
15
- let header = `Received-SPF: ${result.status.result}${result.status.comment ? ` (${escapeCommentValue(result.status.comment)})` : ''} client-ip=${
16
- result['client-ip']
17
- };`;
15
+ /**
16
+ * Formats the Received-SPF header field (RFC 7208 section 9.1)
17
+ */
18
+ const formatHeaders = (result, keys) => {
19
+ let pairs = [];
20
+ for (let key of ['client-ip', 'envelope-from', 'helo']) {
21
+ if (keys[key]) {
22
+ let value = formatDotAtomOrQuoted(keys[key]);
23
+ // a value this long can not be folded and would take the line past the 998
24
+ // character limit of RFC 5322 section 2.1.1, so the optional pair is left out
25
+ if (Buffer.byteLength(value) <= MAX_HEADER_VALUE_LENGTH) {
26
+ pairs.push(`${key}=${value};`);
27
+ }
28
+ }
29
+ }
30
+
31
+ let header = `Received-SPF: ${result.status.result}${result.status.comment ? ` (${escapeCommentValue(result.status.comment)})` : ''}${
32
+ pairs.length ? ` ${pairs.join(' ')}` : ''
33
+ }`;
18
34
 
19
35
  return libmime.foldLines(header, 160);
20
36
  };
21
37
 
38
+ const timeLimitError = () => {
39
+ let err = new Error('SPF evaluation time limit exceeded');
40
+ err.code = 'ESPFTIMELIMIT';
41
+ err.spfResult = {
42
+ error: 'temperror',
43
+ text: 'SPF evaluation time limit exceeded'
44
+ };
45
+ return err;
46
+ };
47
+
22
48
  /**
23
49
  * Dual-stack DNS resolver for SPF A/AAAA mechanism queries
24
50
  *
@@ -37,13 +63,17 @@ const formatHeaders = result => {
37
63
  * - AAAA query returns: ENODATA (empty)
38
64
  * - Result: Returns empty AAAA array (no match), but does NOT count as void
39
65
  *
66
+ * RFC 7208 section 4.6.4 counts the query for the client's address type only, so this is used
67
+ * in the default mode only. `state.clientVoid` is set if that query alone was void.
68
+ *
40
69
  * @param {Function} resolver - Base DNS resolver function
41
70
  * @param {String} domain - Domain to query
42
71
  * @param {Object} opts - Options object with clientIpType (4 or 6)
72
+ * @param {Object} [state] - Updated with the clientVoid flag
43
73
  * @returns {Promise<Array>} - Array of IP addresses matching client type
44
74
  * @throws {Error} - Throws on real DNS errors or when both A and AAAA are void
45
75
  */
46
- let dualStackResolver = async (resolver, domain, opts) => {
76
+ let dualStackResolver = async (resolver, domain, opts, state) => {
47
77
  const isIPv6 = opts.clientIpType === 6;
48
78
 
49
79
  // Query both A and AAAA records in parallel for efficiency
@@ -79,6 +109,11 @@ let dualStackResolver = async (resolver, domain, opts) => {
79
109
  throw voidError;
80
110
  }
81
111
 
112
+ if (state && (isIPv6 ? aaaaIsVoid : aIsVoid)) {
113
+ // RFC 7208 would count this as a void lookup
114
+ state.clientVoid = true;
115
+ }
116
+
82
117
  // Return only the records matching the client's IP type
83
118
  // Empty arrays are valid (host exists but doesn't match client IP type)
84
119
  return isIPv6 ? aaaaRecords : aRecords;
@@ -96,11 +131,19 @@ let dualStackResolver = async (resolver, domain, opts) => {
96
131
  * @param {Number} maxResolveCount - Maximum DNS lookups allowed (default: 10)
97
132
  * @param {Number} maxVoidCount - Maximum void lookups allowed (default: 2)
98
133
  * @param {Boolean} ignoreFirst - If true, don't count the first DNS lookup (used for initial TXT record fetch)
134
+ * @param {Object} [options]
135
+ * @param {Boolean} [options.strict] - If true, follow RFC 7208 exactly (no dual-stack void optimization)
136
+ * @param {Object} [options.ctx] - Shared evaluation state, lax acceptances are added to ctx.warnings
137
+ * @param {Number} [options.deadline] - Timestamp after which no more queries are made
99
138
  * @returns {Function} - Rate-limited resolver function with signature: (domain, type, opts) => Promise<Array>
100
139
  */
101
- let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) => {
140
+ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst, options) => {
141
+ options = options || {};
142
+
102
143
  let resolveCount = 0;
103
144
  let voidCount = 0;
145
+ // void lookups as RFC 7208 counts them, without the dual-stack optimization
146
+ let rfcVoidCount = 0;
104
147
 
105
148
  let subResolveCounts = {};
106
149
  let firstCounted = !ignoreFirst;
@@ -108,7 +151,18 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
108
151
  maxResolveCount = maxResolveCount || MAX_RESOLVE_COUNT;
109
152
  maxVoidCount = maxVoidCount || MAX_VOID_COUNT;
110
153
 
154
+ let checkRfcVoidCount = () => {
155
+ if (rfcVoidCount > maxVoidCount && options.ctx && options.ctx.warnings) {
156
+ // strict mode would have returned permerror here
157
+ options.ctx.warnings.add('void-lookup-limit');
158
+ }
159
+ };
160
+
111
161
  let resolverFunc = async (domain, type, opts) => {
162
+ if (options.deadline && Date.now() >= options.deadline) {
163
+ throw timeLimitError();
164
+ }
165
+
112
166
  // Increment DNS lookup counter
113
167
  // Note: Dual-stack queries (A+AAAA) still count as 1 lookup
114
168
  if (firstCounted) {
@@ -130,7 +184,8 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
130
184
  // Validate domain name format before querying
131
185
  // This is a lenient check to pass test suites and prevent obvious invalid queries
132
186
  try {
133
- if (!/^([\x20-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/i.test(domain)) {
187
+ // strict mode also accepts every name that follows the domain-spec rules of RFC 7208
188
+ if (!/^([\x20-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/i.test(domain) && !(options.strict && isValidTargetName(domain))) {
134
189
  throw new Error('Failed to validate domain');
135
190
  }
136
191
  } catch (err) {
@@ -142,24 +197,45 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
142
197
  }
143
198
 
144
199
  // Execute DNS query with dual-stack optimization for A/AAAA queries
200
+ let state = {};
145
201
  try {
146
202
  // Use dual-stack resolver when:
147
203
  // 1. Query type is A or AAAA (address lookups)
148
204
  // 2. Client IP type is provided (4 for IPv4, 6 for IPv6)
205
+ // 3. Not in strict mode, RFC 7208 only looks at the query for the client's address type
149
206
  // This prevents single-stack hosts from being counted as void lookups
150
- if (opts?.clientIpType && (type === 'A' || type === 'AAAA')) {
151
- return await dualStackResolver(resolver, domain, opts);
207
+ let result;
208
+ if (!options.strict && opts?.clientIpType && (type === 'A' || type === 'AAAA')) {
209
+ result = await dualStackResolver(resolver, domain, opts, state);
152
210
  } else {
153
211
  // Standard single-query resolution for other record types (TXT, MX, PTR, etc.) and A if no client info provided.
154
- return await resolver(domain, type);
212
+ result = await resolver(domain, type);
155
213
  }
214
+
215
+ if (state.clientVoid) {
216
+ rfcVoidCount++;
217
+ checkRfcVoidCount();
218
+ }
219
+
220
+ return result;
156
221
  } catch (err) {
222
+ if (!err || typeof err !== 'object') {
223
+ // a custom resolver rejected with something other than an error object
224
+ let wrapped = new Error(`DNS error when resolving ${domain}: ${err}`);
225
+ wrapped.spfResult = {
226
+ error: 'temperror',
227
+ text: wrapped.message
228
+ };
229
+ throw wrapped;
230
+ }
231
+
157
232
  switch (err.code) {
158
233
  case 'ENOTFOUND': // Domain does not exist
159
234
  case 'ENODATA': {
160
235
  // Domain exists but has no records of this type
161
236
  // Increment void lookup counter
162
237
  voidCount++;
238
+ rfcVoidCount++;
163
239
  if (voidCount > maxVoidCount) {
164
240
  err.spfResult = {
165
241
  error: 'permerror',
@@ -167,6 +243,7 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
167
243
  };
168
244
  throw err;
169
245
  }
246
+ checkRfcVoidCount();
170
247
  // Return empty array to continue SPF evaluation
171
248
  return [];
172
249
  }
@@ -187,8 +264,27 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
187
264
  };
188
265
  throw err;
189
266
 
267
+ case 'ESPFTIMELIMIT':
268
+ throw err;
269
+
270
+ case 'EBADNAME':
271
+ // the resolver rejected the name (for example a label longer than 63 characters),
272
+ // handled like the names that fail the format check above
273
+ err.spfResult = {
274
+ error: 'permerror',
275
+ text: `Invalid domain ${domain}`
276
+ };
277
+ throw err;
278
+
190
279
  default:
191
- // Unknown error - propagate as-is
280
+ // Any other DNS error (SERVFAIL, connection refused, bad response, ...) is a
281
+ // temporary error (RFC 7208 sections 4.4 and 5), also inside include and redirect
282
+ if (!err.spfResult) {
283
+ err.spfResult = {
284
+ error: 'temperror',
285
+ text: err.message
286
+ };
287
+ }
192
288
  throw err;
193
289
  }
194
290
  }
@@ -210,6 +306,50 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
210
306
  return resolverFunc;
211
307
  };
212
308
 
309
+ /**
310
+ * Wraps a DNS resolver so that each question is sent once per SPF evaluation. The lookup limits
311
+ * are counted by limitedResolver before it calls this, so every query still counts. Nothing
312
+ * mutable is handed out twice: limitedResolver writes spfResult onto the error it gets, and
313
+ * the MX list is sorted in place, so every caller gets its own copy of the error or the list
314
+ *
315
+ * @param {Function} resolver Base DNS resolver
316
+ * @returns {Function} Resolver with the same signature
317
+ */
318
+ const memoizeResolver = resolver => {
319
+ const answers = new Map();
320
+
321
+ const copyError = err => {
322
+ if (!err || typeof err !== 'object') {
323
+ // not an error object, there is nothing on it to share
324
+ return err;
325
+ }
326
+ let copy = Object.assign(new Error(err.message), err);
327
+ copy.code = err.code;
328
+ if (err.spfResult && typeof err.spfResult === 'object') {
329
+ copy.spfResult = Object.assign({}, err.spfResult);
330
+ }
331
+ return copy;
332
+ };
333
+
334
+ return (name, type) => {
335
+ let key = `${type}:${name}`;
336
+ let answer = answers.get(key);
337
+ if (!answer) {
338
+ // a resolver that throws synchronously still does so, and is not cached
339
+ answer = Promise.resolve(resolver(name, type));
340
+ // failures are reported to the callers below, never as an unhandled rejection
341
+ answer.catch(() => false);
342
+ answers.set(key, answer);
343
+ }
344
+ return answer.then(
345
+ value => (Array.isArray(value) ? value.slice() : value),
346
+ err => {
347
+ throw copyError(err);
348
+ }
349
+ );
350
+ };
351
+ };
352
+
213
353
  /**
214
354
  *
215
355
  * @param {Object} opts
@@ -219,30 +359,57 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
219
359
  * @param {String} [opts.mta] Hostname of the MTA or MX server that processes the message
220
360
  * @param {String} [opts.maxResolveCount=10] Maximum DNS lookups allowed
221
361
  * @param {String} [opts.maxVoidCount=2] Maximum empty DNS lookups allowed
362
+ * @param {Number} [opts.maxElapsedTime] Maximum time in milliseconds for the whole evaluation, temperror if exceeded (no limit by default)
363
+ * @param {Boolean} [opts.strict=false] If true, follow RFC 7208 exactly instead of the lenient default
222
364
  */
223
365
  const verify = async opts => {
224
- let { sender, ip, helo, mta, maxResolveCount, maxVoidCount, resolver } = opts || {};
366
+ let { sender, ip, helo, mta, maxResolveCount, maxVoidCount, resolver, strict, maxElapsedTime } = opts || {};
367
+
368
+ strict = !!strict;
225
369
 
226
370
  mta = mta || os.hostname();
227
371
 
372
+ // a null reverse-path means that the HELO identity is checked (RFC 7208 section 2.4)
373
+ let nullSender = !sender;
374
+
228
375
  sender = sender || `postmaster@${helo}`;
229
376
 
230
- // convert mapped IPv6 IP addresses to IPv4
231
- let mappingMatch = (ip || '').toString().match(/^[:A-F]+:((\d+\.){3}\d+)$/i);
232
- if (mappingMatch) {
233
- ip = mappingMatch[1];
377
+ // IPv4-mapped IPv6 addresses (::ffff:0:0/96, dotted or hex form) are IPv4 clients (RFC 7208 section 5),
378
+ // no other IPv6 address is ever treated as IPv4
379
+ let parsedIp = ip ? parseIp(ip) : null;
380
+ if (parsedIp) {
381
+ ip = ip.toString().trim();
382
+ }
383
+ if (parsedIp && parsedIp.kind() === 'ipv6' && parsedIp.isIPv4MappedAddress()) {
384
+ parsedIp = parsedIp.toIPv4Address();
385
+ ip = parsedIp.toString();
234
386
  }
235
387
 
236
- let atPos = sender.indexOf('@');
388
+ // the local-part may be a quoted string that contains "@", the domain follows the last "@"
389
+ let atPos = sender.lastIndexOf('@');
237
390
  if (atPos < 0) {
238
391
  sender = `postmaster@${sender}`;
239
392
  } else if (atPos === 0) {
240
393
  sender = `postmaster${sender}`;
241
394
  }
395
+ atPos = sender.lastIndexOf('@');
396
+
397
+ let domain =
398
+ sender
399
+ .substr(atPos + 1)
400
+ .toLowerCase()
401
+ .trim() || '-';
402
+
403
+ // RFC 8616 section 4: U-labels are converted to A-labels before SPF validation, this also
404
+ // applies to the sender domain used in macro expansion
405
+ let evalSender = sender;
406
+ if (/[^\x00-\x7F]/.test(domain)) {
407
+ domain = toALabel(domain);
408
+ evalSender = `${sender.substr(0, atPos)}@${domain}`;
409
+ }
242
410
 
243
- let domain = sender.split('@').pop().toLowerCase().trim() || '-';
244
-
245
- resolver = resolver || dns.promises.resolve;
411
+ // one DNS query per question for the whole evaluation, includes and redirects included
412
+ resolver = memoizeResolver(resolver || dns.promises.resolve);
246
413
 
247
414
  let status = {
248
415
  result: 'neutral',
@@ -254,10 +421,35 @@ const verify = async opts => {
254
421
  }
255
422
  };
256
423
 
257
- let verifyResolver = limitedResolver(resolver, maxResolveCount, maxVoidCount, true);
424
+ // shared evaluation state
425
+ let ctx = { warnings: new Set(), usedLocalPart: false };
426
+
427
+ let deadline = typeof maxElapsedTime === 'number' && maxElapsedTime > 0 ? Date.now() + maxElapsedTime : false;
428
+ let resolverOptions = { strict, ctx, deadline };
429
+
430
+ let verifyResolver = limitedResolver(resolver, maxResolveCount, maxVoidCount, true, resolverOptions);
431
+
432
+ // the explanation lookup is not counted toward the lookup limits (RFC 7208 section 4.6.4)
433
+ let expResolver = async (name, type) => {
434
+ if (deadline && Date.now() >= deadline) {
435
+ throw timeLimitError();
436
+ }
437
+ return await resolver(name, type);
438
+ };
258
439
 
259
440
  let result;
441
+ let timer;
260
442
  try {
443
+ if (!parsedIp) {
444
+ // <ip> is a required argument of check_host() (RFC 7208 section 4.1)
445
+ let err = new Error('Invalid client IP');
446
+ err.spfResult = {
447
+ error: 'temperror',
448
+ text: 'missing or invalid client IP address'
449
+ };
450
+ throw err;
451
+ }
452
+
261
453
  let validation = domainSchema.validate(domain);
262
454
  if (validation.error) {
263
455
  let err = validation.error;
@@ -268,18 +460,35 @@ const verify = async opts => {
268
460
  throw err;
269
461
  }
270
462
 
271
- result = await spfVerify(domain, {
272
- sender,
463
+ let evaluation = spfVerify(domain, {
464
+ sender: evalSender,
273
465
  ip,
274
466
  mta,
275
467
  helo,
468
+ strict,
469
+ ctx,
470
+ expResolver,
276
471
 
277
472
  // generate DNS handler
278
473
  resolver: verifyResolver,
279
474
 
280
- // allow to create sub resolvers
281
- createSubResolver: () => limitedResolver(resolver, maxResolveCount, maxVoidCount)
475
+ // allow to create sub resolvers. Address lookups of MX and PTR names do not count as void lookups
476
+ createSubResolver: subOptions =>
477
+ limitedResolver(resolver, maxResolveCount, subOptions && subOptions.ignoreVoid ? Infinity : maxVoidCount, false, resolverOptions)
282
478
  });
479
+
480
+ if (deadline) {
481
+ // RFC 7208 section 4.6.4: a limit on the elapsed time, temperror if exceeded
482
+ evaluation.catch(() => false);
483
+ result = await Promise.race([
484
+ evaluation,
485
+ new Promise((resolve, reject) => {
486
+ timer = setTimeout(() => reject(timeLimitError()), Math.max(deadline - Date.now(), 0));
487
+ })
488
+ ]);
489
+ } else {
490
+ result = await evaluation;
491
+ }
283
492
  } catch (err) {
284
493
  if (err.spfResult) {
285
494
  result = err.spfResult;
@@ -289,6 +498,8 @@ const verify = async opts => {
289
498
  text: err.message
290
499
  };
291
500
  }
501
+ } finally {
502
+ clearTimeout(timer);
292
503
  }
293
504
 
294
505
  if (result && typeof result === 'object') {
@@ -353,13 +564,24 @@ const verify = async opts => {
353
564
  break;
354
565
  }
355
566
 
567
+ if (strict) {
568
+ // RFC 8601 section 2.7.2: only the identity that was checked is reported, and the
569
+ // local-part only if the policy has provisions for it
570
+ status.smtp = nullSender ? { helo } : { mailfrom: ctx.usedLocalPart ? sender : sender.substr(atPos + 1) };
571
+ }
572
+
356
573
  if (result.rr) {
357
574
  response.rr = result.rr;
358
575
  }
359
576
 
360
577
  response.status = status;
361
- response.header = formatHeaders(response);
362
- response.info = formatAuthHeaderRow('spf', status);
578
+ response.header = formatHeaders(response, {
579
+ 'client-ip': parsedIp ? ip : false,
580
+ // RFC 7208 section 9.1: "envelope-from" if the "MAIL FROM" identity was checked
581
+ 'envelope-from': nullSender ? false : sender,
582
+ helo
583
+ });
584
+ response.info = formatAuthHeaderRow('spf', status, { strict });
363
585
 
364
586
  if (typeof response.status.comment === 'boolean') {
365
587
  delete response.status.comment;
@@ -369,6 +591,16 @@ const verify = async opts => {
369
591
  response.lookups = result.lookups;
370
592
  }
371
593
 
594
+ if (result.explanation && status.result === 'fail') {
595
+ // explanation string published by the domain owner (RFC 7208 section 6.2), third party text
596
+ response.explanation = result.explanation;
597
+ }
598
+
599
+ if (ctx.warnings.size && status.result !== 'permerror') {
600
+ // the default mode accepted something that strict mode would have rejected
601
+ response.warnings = Array.from(ctx.warnings);
602
+ }
603
+
372
604
  return response;
373
605
  };
374
606
 
package/lib/spf/macro.js CHANGED
@@ -1,8 +1,19 @@
1
1
  'use strict';
2
2
 
3
- const net = require('net');
4
- const ipaddr = require('ipaddr.js');
5
3
  const os = require('node:os');
4
+ const { parseIp } = require('./syntax');
5
+
6
+ // RFC 3986 unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
7
+ const urlEscape = value =>
8
+ Array.from(Buffer.from(value, 'utf-8'))
9
+ .map(byte => {
10
+ let chr = String.fromCharCode(byte);
11
+ if (/^[A-Za-z0-9\-._~]$/.test(chr)) {
12
+ return chr;
13
+ }
14
+ return `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
15
+ })
16
+ .join('');
6
17
 
7
18
  /**
8
19
  * Renders macro into an output string.
@@ -13,20 +24,31 @@ const os = require('node:os');
13
24
  * @param {String} values.helo Client's HELO/EHLO domain
14
25
  * @param {String} [values.mta] Hostname of the MTA or MX server that processes the message
15
26
  * @param {String} [values.domain] Domain currently being evaluated by check_host(), changes on include/redirect recursion (RFC 7208 sections 5.2, 6.1)
27
+ * @param {String} [values.p] Validated domain name of the client IP (RFC 7208 section 7.3), "unknown" if not set
28
+ * @param {Object} [values.addr] `values.ip` parsed with parseIp(), if the caller already has it
16
29
  */
17
30
  const macro = (input, values) => {
18
31
  input = (input || '').toString();
19
32
 
20
- let { sender, ip, helo, mta, domain } = values || {};
33
+ if (input.indexOf('%') < 0) {
34
+ // nothing to expand
35
+ return input;
36
+ }
37
+
38
+ let { sender, ip, helo, mta, domain, p, addr } = values || {};
21
39
 
22
40
  sender = (sender || '').toString();
23
41
  ip = (ip || '').toString();
24
42
  helo = (helo || '').toString();
25
43
 
26
- let atPos = sender.indexOf('@');
44
+ // the local-part may be a quoted string that contains "@", the domain follows the last "@"
45
+ let atPos = sender.lastIndexOf('@');
27
46
  let senderLocal = atPos >= 0 ? sender.substr(0, atPos) : '';
28
47
  let senderDomain = atPos >= 0 ? sender.substr(atPos + 1) : sender;
29
- let vStr = net.isIPv4(ip) ? 'in-addr' : net.isIPv6(ip) ? 'ip6' : '';
48
+
49
+ // the caller can pass the client IP already parsed, it is then not parsed again
50
+ let parsedIp = addr || parseIp(ip);
51
+ let vStr = parsedIp ? (parsedIp.kind() === 'ipv4' ? 'in-addr' : 'ip6') : '';
30
52
 
31
53
  return input.replace(/%%|%_|%-|%\{([^}]+)\}|%/gi, (m, c) => {
32
54
  if (m === '%') {
@@ -65,18 +87,25 @@ const macro = (input, values) => {
65
87
  break;
66
88
 
67
89
  case 'o':
68
- case 'p': // validated domain name is unsupported, instead we use the unvalidated sender domain
69
90
  curval = senderDomain;
70
91
  break;
71
92
 
93
+ case 'p':
94
+ // validated domain name of the client IP, resolved by the caller (RFC 7208 section 7.3)
95
+ curval = p || 'unknown';
96
+ break;
97
+
72
98
  case 'd':
73
99
  // current <domain> of check_host(), not the sender domain (these only match at the top level)
74
100
  curval = domain || senderDomain;
75
101
  break;
76
102
 
77
103
  case 'i':
78
- curval = ipaddr.parse(ip).toNormalizedString();
79
- if (net.isIPv6(ip)) {
104
+ if (!parsedIp) {
105
+ throw new Error(`Invalid IP address ${ip}`);
106
+ }
107
+ curval = parsedIp.toNormalizedString();
108
+ if (parsedIp.kind() === 'ipv6') {
80
109
  curval = curval
81
110
  .split(':')
82
111
  .flatMap(p => {
@@ -94,11 +123,14 @@ const macro = (input, values) => {
94
123
  break;
95
124
 
96
125
  case 'h':
97
- curval = helo || `[${ipaddr.parse(ip).toString()}]`;
126
+ curval = helo || `[${parsedIp ? parsedIp.toString() : ip}]`;
98
127
  break;
99
128
 
100
129
  case 'c':
101
- curval = ipaddr.parse(ip).toString();
130
+ if (!parsedIp) {
131
+ throw new Error(`Invalid IP address ${ip}`);
132
+ }
133
+ curval = parsedIp.toString();
102
134
  break;
103
135
 
104
136
  case 'r':
@@ -134,8 +166,8 @@ const macro = (input, values) => {
134
166
  nr = parseInt(nr, 10);
135
167
  }
136
168
 
137
- // find the reverse transformer
138
- if (chars.length && chars[0] === 'r') {
169
+ // find the reverse transformer, ABNF literals are case-insensitive, so "R" is the same as "r"
170
+ if (chars.length && chars[0].toLowerCase() === 'r') {
139
171
  chars.shift();
140
172
  reversed = true;
141
173
  }
@@ -163,7 +195,12 @@ const macro = (input, values) => {
163
195
  }
164
196
 
165
197
  // no matter the expansion delimiter, values are joined with dots
166
- return curval.join('.');
198
+ curval = curval.join('.');
199
+ }
200
+
201
+ // uppercase macros expand exactly as their lowercase equivalents, and are then URL escaped (RFC 7208 section 7.3)
202
+ if (macroChar !== macroChar.toLowerCase()) {
203
+ curval = urlEscape(curval);
167
204
  }
168
205
 
169
206
  return curval;