mailauth 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +111 -39
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +80 -33
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +495 -43
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +140 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +75 -36
  37. package/lib/mta-sts.d.ts +77 -11
  38. package/lib/mta-sts.js +497 -182
  39. package/lib/parse-dkim-headers.js +376 -191
  40. package/lib/parse-received.js +104 -32
  41. package/lib/spf/index.d.ts +37 -1
  42. package/lib/spf/index.js +260 -28
  43. package/lib/spf/macro.js +50 -13
  44. package/lib/spf/spf-verify.js +440 -204
  45. package/lib/spf/syntax.js +366 -0
  46. package/lib/tools.js +522 -116
  47. package/package.json +1 -1
@@ -8,13 +8,166 @@ const { DANGEROUS_KEYS } = require('./safe-keys');
8
8
  // parser (valueParser has its own reservation: "header" is a legitimate ptype there).
9
9
  const RESERVED_RESULT_KEYS = new Set(['header', 'value']);
10
10
 
11
- // NB! fails to properly parse nested comments (should be rare enough though)
11
+ // Header fields whose value follows the Authentication-Results grammar of RFC 8601 section
12
+ // 2.2, which has RFC 5322 comments and quoted strings. Every other header field this module
13
+ // is used for (DKIM-Signature, ARC-Seal, ARC-Message-Signature, BIMI-Selector, and the DNS
14
+ // records passed in with a "DNS: TXT;" prefix) is a tag-list of RFC 6376 section 3.2, where
15
+ // quotes, parentheses and backslashes are ordinary value characters
16
+ const AR_HEADERS = new Set(['authentication-results', 'arc-authentication-results']);
17
+
18
+ // tags whose values have all whitespace removed (RFC 6376 section 3.5: FWS in b= and bh= is
19
+ // ignored, section 3.6.1 allows it anywhere in p=, and h= allows it around the colons)
20
+ const WHITESPACE_FREE_TAGS = new Set(['bh', 'b', 'p', 'h']);
21
+ // tags whose values are converted to numbers when they look like one
22
+ const NUMERIC_TAGS = new Set(['l', 'v', 't', 'x']);
23
+
24
+ // RFC 6376 section 3.2: tag-name = ALPHA *ALNUMPUNC
25
+ const TAG_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
26
+ // FWS around a tag-spec, only SP, HTAB and the CRLF of a fold
27
+ const FWS_EDGES = /^[ \t\r\n]+|[ \t\r\n]+$/g;
28
+
29
+ const normalizeString = value => value.replace(/\s+/g, ' ').trim();
30
+
31
+ // Converts the value of a known tag (lower case `key`) to the form readers expect: no
32
+ // whitespace in the values of WHITESPACE_FREE_TAGS, numbers for NUMERIC_TAGS and for the
33
+ // instance of an ARC header field
34
+ const normalizeTagValue = (key, value, headerKey) => {
35
+ if (WHITESPACE_FREE_TAGS.has(key)) {
36
+ return value.replace(/\s+/g, '');
37
+ }
38
+ if (NUMERIC_TAGS.has(key) && !isNaN(value)) {
39
+ return Number(value);
40
+ }
41
+ if (key === 'i' && /^arc-/i.test(headerKey || '')) {
42
+ return Number(value);
43
+ }
44
+ return value;
45
+ };
46
+
47
+ // Builds the { key: { value } } map of a tag-list from tags whose values tagListParser has
48
+ // already normalized. `caseSensitive` keeps tag names as they are (RFC 6376 section 3.2,
49
+ // "Tags MUST be interpreted in a case-sensitive manner"), the default folds them to lower
50
+ // case the way this parser always has
51
+ const buildTagMap = (tags, headerKey, caseSensitive) => {
52
+ let result = {
53
+ header: headerKey
54
+ };
55
+
56
+ for (let tag of tags) {
57
+ let key = normalizeString(tag.name);
58
+ if (!caseSensitive) {
59
+ key = key.toLowerCase();
60
+ }
61
+
62
+ if (!key || RESERVED_RESULT_KEYS.has(key) || DANGEROUS_KEYS.has(key)) {
63
+ continue;
64
+ }
65
+
66
+ let value = tag.value;
67
+ if (!caseSensitive || key === key.toLowerCase()) {
68
+ // every tag name this module interprets is lower case, so in the case-sensitive
69
+ // mode a name with an upper case letter in it is an unknown tag, and unknown tags
70
+ // are kept verbatim
71
+ value = normalizeTagValue(key.toLowerCase(), value, headerKey);
72
+ }
73
+
74
+ result[key] = { value };
75
+ }
76
+
77
+ return result;
78
+ };
79
+
80
+ /**
81
+ * Parses a tag-list (RFC 6376 section 3.2). Semicolons separate the tag-specs, the first
82
+ * "=" separates a tag name from its value, and nothing else is syntax: a tag-value can hold
83
+ * any printable character except the semicolon, so quotes, parentheses and backslashes in
84
+ * n= notes, z= copies or unknown tags are part of the value.
85
+ *
86
+ * Returns the same shape the parser always had ({ parsed }) plus
87
+ * - tags: every tag-spec in order, as { name, value }, with the name case preserved and the
88
+ * value a string (FWS around it removed, inner whitespace collapsed to single spaces)
89
+ * - syntaxErrors: a list of reasons why the tag-list is not valid RFC 6376 syntax. Empty for
90
+ * a valid tag-list. The lenient readers ignore it, strict readers reject the tag-list
91
+ *
92
+ * With `strict` set, `parsed` is keyed by the tag names as they are written (RFC 6376 section
93
+ * 3.2) and holds the tag-specs only: key-only parts, which are syntax errors, are left out
94
+ */
95
+ const tagListParser = (line, headerKey, strict) => {
96
+ let tags = [];
97
+ // parts without "=" after the first one
98
+ let keyOnly = [];
99
+ let syntaxErrors = [];
100
+ let defaultValue;
101
+
102
+ let segments = line.split(';');
103
+ let seen = new Set();
104
+
105
+ for (let i = 0; i < segments.length; i++) {
106
+ let segment = segments[i];
107
+ let trimmed = segment.replace(FWS_EDGES, '');
108
+
109
+ if (!trimmed) {
110
+ // one trailing semicolon is allowed, an empty tag-spec anywhere else is not
111
+ if (i < segments.length - 1 || segments.length === 1) {
112
+ syntaxErrors.push('empty tag-spec');
113
+ }
114
+ continue;
115
+ }
12
116
 
117
+ let eqPos = segment.indexOf('=');
118
+ if (eqPos < 0) {
119
+ syntaxErrors.push(`tag-spec without "=": ${JSON.stringify(normalizeString(trimmed))}`);
120
+ // the first key-only part is the default value, which is how the "DNS: TXT;"
121
+ // prefix and the authserv-id of an AR header have always been exposed
122
+ if (typeof defaultValue === 'undefined') {
123
+ defaultValue = normalizeString(trimmed).toLowerCase();
124
+ } else {
125
+ keyOnly.push(normalizeString(trimmed));
126
+ }
127
+ continue;
128
+ }
129
+
130
+ let name = segment.substring(0, eqPos).replace(FWS_EDGES, '');
131
+ let value = normalizeString(segment.substring(eqPos + 1));
132
+
133
+ if (!TAG_NAME.test(name)) {
134
+ syntaxErrors.push(`invalid tag name: ${JSON.stringify(normalizeString(name))}`);
135
+ }
136
+
137
+ if (seen.has(name)) {
138
+ // RFC 6376 section 3.2: a duplicate tag name makes the entire tag-list invalid
139
+ syntaxErrors.push(`duplicate tag: ${JSON.stringify(name)}`);
140
+ }
141
+ seen.add(name);
142
+
143
+ tags.push({ name, value });
144
+ }
145
+
146
+ let parsed = buildTagMap(tags, headerKey, strict);
147
+
148
+ if (!strict) {
149
+ // key-only parts after the first one were stored as empty values, keep doing that
150
+ for (let name of keyOnly) {
151
+ let key = name.toLowerCase();
152
+ if (key && !RESERVED_RESULT_KEYS.has(key) && !DANGEROUS_KEYS.has(key)) {
153
+ parsed[key] = { value: '' };
154
+ }
155
+ }
156
+
157
+ if (typeof defaultValue !== 'undefined') {
158
+ parsed.value = defaultValue;
159
+ }
160
+ }
161
+
162
+ return { parsed, tags, syntaxErrors };
163
+ };
164
+
165
+ // Parses the resinfo payload of a single Authentication-Results method, eg.
166
+ // `pass header.i=@example.com header.s="sel 1"`, into { value, ptype: { property } }
13
167
  const valueParser = str => {
14
168
  let line = str.replace(/\s+/g, ' ').trim();
15
169
 
16
170
  let parts = [];
17
- let lastState = false;
18
171
 
19
172
  const createPart = () => {
20
173
  let part = {
@@ -27,74 +180,54 @@ const valueParser = str => {
27
180
 
28
181
  const parse = () => {
29
182
  let state = 'key';
30
- let escaped;
31
- let quote;
183
+ let quoted = false;
32
184
 
33
185
  let curPart = createPart();
34
186
 
35
187
  for (let i = 0; i < line.length; i++) {
36
188
  let c = line.charAt(i);
37
189
 
38
- switch (state) {
39
- case 'key':
40
- if (c === '=') {
41
- state = 'value';
42
- break;
43
- }
44
- // falls through
190
+ if (quoted) {
191
+ // RFC 5322 quoted-string, a quoted-pair stands for the character it escapes
192
+ if (c === '\\' && i < line.length - 1) {
193
+ curPart[state] += line.charAt(++i);
194
+ } else if (c === '"') {
195
+ quoted = false;
196
+ } else {
197
+ curPart[state] += c;
198
+ }
199
+ continue;
200
+ }
45
201
 
46
- case 'value': {
47
- if (escaped === true) {
202
+ switch (c) {
203
+ case '=':
204
+ if (state === 'key') {
205
+ state = 'value';
206
+ } else {
48
207
  curPart[state] += c;
49
- break;
50
- }
51
-
52
- switch (c) {
53
- case ' ':
54
- // start new part
55
- curPart = createPart();
56
- state = 'key';
57
- break;
58
-
59
- case '\\':
60
- escaped = true;
61
- break;
62
-
63
- case '"':
64
- case "'":
65
- lastState = state;
66
- state = 'quoted';
67
- quote = c;
68
- break;
69
-
70
- default:
71
- curPart[state] += c;
72
- break;
73
208
  }
209
+ break;
74
210
 
211
+ case ' ':
212
+ // start new part
213
+ curPart = createPart();
214
+ state = 'key';
75
215
  break;
76
- }
77
216
 
78
- case 'quoted':
79
- if (escaped === true) {
80
- curPart[lastState] += c;
81
- break;
217
+ case '\\':
218
+ // not meaningful outside a quoted string, kept as an escape of the next
219
+ // character the way this parser has always read it
220
+ if (i < line.length - 1) {
221
+ curPart[state] += line.charAt(++i);
82
222
  }
223
+ break;
83
224
 
84
- switch (c) {
85
- case '\\':
86
- escaped = true;
87
- break;
88
-
89
- case quote:
90
- state = lastState;
91
- break;
92
-
93
- default:
94
- curPart[lastState] += c;
95
- break;
96
- }
225
+ case '"':
226
+ quoted = true;
227
+ break;
97
228
 
229
+ default:
230
+ curPart[state] += c;
98
231
  break;
99
232
  }
100
233
  }
@@ -132,15 +265,11 @@ const valueParser = str => {
132
265
  return parse();
133
266
  };
134
267
 
135
- const headerParser = buf => {
136
- let line = (buf || '').toString().trim();
137
- let splitterPos = line.indexOf(':');
138
- let headerKey;
139
- if (splitterPos >= 0) {
140
- headerKey = line.substr(0, splitterPos).trim().toLowerCase();
141
- line = line.substr(splitterPos + 1).trim();
142
- }
143
-
268
+ // Authentication-Results grammar (RFC 8601 section 2.2): semicolons separate the resinfo
269
+ // parts unless they are inside a comment or a quoted string. Comments (which nest) are
270
+ // collected into the comment property of their part, quoted strings are kept as they are,
271
+ // quotes and quoted-pairs included, so that valueParser can read them
272
+ const arParser = (line, headerKey) => {
144
273
  let parts = [];
145
274
  let lastState = false;
146
275
 
@@ -155,166 +284,222 @@ const headerParser = buf => {
155
284
  return part;
156
285
  };
157
286
 
158
- const parse = () => {
159
- let state = 'key';
160
- let escaped;
161
- let quote;
287
+ let state = 'key';
288
+ let commentDepth = 0;
162
289
 
163
- let curPart = createPart();
290
+ let curPart = createPart();
164
291
 
165
- for (let i = 0; i < line.length; i++) {
166
- let c = line.charAt(i);
292
+ for (let i = 0; i < line.length; i++) {
293
+ let c = line.charAt(i);
167
294
 
168
- switch (state) {
169
- case 'key':
170
- if (c === '=') {
171
- state = 'value';
172
- curPart.hasValue = true;
295
+ switch (state) {
296
+ case 'key':
297
+ if (c === '=') {
298
+ state = 'value';
299
+ curPart.hasValue = true;
300
+ break;
301
+ }
302
+ // falls through
303
+
304
+ case 'value': {
305
+ switch (c) {
306
+ case ';':
307
+ // start new part
308
+ curPart = createPart();
309
+ state = 'key';
173
310
  break;
174
- }
175
- // falls through
176
311
 
177
- case 'value': {
178
- if (escaped === true) {
179
- curPart[state] += c;
180
- }
312
+ case '\\':
313
+ // not meaningful outside a quoted string or a comment, the next
314
+ // character is taken as it is
315
+ if (i < line.length - 1) {
316
+ curPart[state] += line.charAt(++i);
317
+ }
318
+ break;
181
319
 
182
- switch (c) {
183
- case ';':
184
- // start new part
185
- curPart = createPart();
186
- state = 'key';
187
- break;
188
-
189
- case '\\':
190
- escaped = true;
191
- break;
192
-
193
- case '(':
194
- lastState = state;
195
- state = 'comment';
196
- break;
197
-
198
- case '"':
199
- case "'":
200
- lastState = state;
201
- curPart[state] += c;
202
- state = 'quoted';
203
- quote = c;
204
- break;
205
-
206
- default:
207
- curPart[state] += c;
208
- break;
209
- }
320
+ case '(':
321
+ lastState = state;
322
+ state = 'comment';
323
+ commentDepth = 1;
324
+ if (curPart.comment) {
325
+ // a second comment in the same part
326
+ curPart.comment += ' ';
327
+ }
328
+ break;
210
329
 
211
- break;
330
+ case '"':
331
+ lastState = state;
332
+ curPart[state] += c;
333
+ state = 'quoted';
334
+ break;
335
+
336
+ default:
337
+ curPart[state] += c;
338
+ break;
212
339
  }
213
340
 
214
- case 'comment':
215
- switch (c) {
216
- case '\\':
217
- escaped = true;
218
- break;
341
+ break;
342
+ }
219
343
 
220
- case ')':
344
+ case 'comment':
345
+ switch (c) {
346
+ case '\\':
347
+ // quoted-pair
348
+ if (i < line.length - 1) {
349
+ curPart.comment += line.charAt(++i);
350
+ }
351
+ break;
352
+
353
+ case '(':
354
+ commentDepth++;
355
+ curPart.comment += c;
356
+ break;
357
+
358
+ case ')':
359
+ commentDepth--;
360
+ if (commentDepth <= 0) {
221
361
  state = lastState;
222
- break;
362
+ } else {
363
+ curPart.comment += c;
364
+ }
365
+ break;
223
366
 
224
- default:
225
- curPart[state] += c;
226
- break;
227
- }
367
+ default:
368
+ curPart.comment += c;
369
+ break;
370
+ }
228
371
 
229
- break;
372
+ break;
230
373
 
231
- case 'quoted':
232
- switch (c) {
233
- case '\\':
234
- escaped = true;
235
- break;
374
+ case 'quoted':
375
+ switch (c) {
376
+ case '\\':
377
+ // quoted-pair, kept for valueParser to decode
378
+ curPart[lastState] += c;
379
+ if (i < line.length - 1) {
380
+ curPart[lastState] += line.charAt(++i);
381
+ }
382
+ break;
236
383
 
237
- case quote:
238
- state = lastState;
239
- // falls through
384
+ case '"':
385
+ state = lastState;
386
+ curPart[lastState] += c;
387
+ break;
240
388
 
241
- default:
242
- curPart[lastState] += c;
243
- break;
244
- }
389
+ default:
390
+ curPart[lastState] += c;
391
+ break;
392
+ }
245
393
 
246
- break;
247
- }
394
+ break;
248
395
  }
396
+ }
249
397
 
250
- for (let i = parts.length - 1; i >= 0; i--) {
251
- for (let key of Object.keys(parts[i])) {
252
- if (typeof parts[i][key] === 'string') {
253
- parts[i][key] = parts[i][key].replace(/\s+/g, ' ').trim();
254
- }
398
+ for (let i = parts.length - 1; i >= 0; i--) {
399
+ for (let key of Object.keys(parts[i])) {
400
+ if (typeof parts[i][key] === 'string') {
401
+ parts[i][key] = normalizeString(parts[i][key]);
255
402
  }
403
+ }
256
404
 
257
- parts[i].key = parts[i].key.toLowerCase();
258
-
259
- if (!parts[i].key) {
260
- // remove empty value
261
- parts.splice(i, 1);
262
- } else if (['bh', 'b', 'p', 'h'].includes(parts[i].key)) {
263
- // remove unneeded whitespace
264
- parts[i].value = parts[i].value.replace(/\s+/g, '');
265
- } else if (['l', 'v', 't', 'x'].includes(parts[i].key) && !isNaN(parts[i].value)) {
266
- parts[i].value = Number(parts[i].value);
267
- } else if (parts[i].key === 'i' && /^arc-/i.test(headerKey)) {
268
- parts[i].value = Number(parts[i].value);
269
- }
405
+ parts[i].key = parts[i].key.toLowerCase();
406
+
407
+ if (!parts[i].key) {
408
+ // remove empty value
409
+ parts.splice(i, 1);
410
+ } else {
411
+ parts[i].value = normalizeTagValue(parts[i].key, parts[i].value, headerKey);
270
412
  }
413
+ }
271
414
 
272
- let result = {
273
- header: headerKey
274
- };
415
+ let result = {
416
+ header: headerKey
417
+ };
275
418
 
276
- for (let i = 0; i < parts.length; i++) {
277
- // find the first entry with key only and use it as the default value
278
- if (parts[i].key && !parts[i].hasValue) {
279
- result.value = parts[i].key;
280
- parts.splice(i, 1);
281
- break;
282
- }
419
+ for (let i = 0; i < parts.length; i++) {
420
+ // find the first entry with key only and use it as the default value
421
+ if (parts[i].key && !parts[i].hasValue) {
422
+ result.value = parts[i].key;
423
+ parts.splice(i, 1);
424
+ break;
283
425
  }
426
+ }
284
427
 
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
- }
428
+ parts.forEach(part => {
429
+ if (!part.key || RESERVED_RESULT_KEYS.has(part.key) || DANGEROUS_KEYS.has(part.key)) {
430
+ // nothing would be assigned, so do not parse the part either
431
+ return;
432
+ }
290
433
 
291
- let entry = {
292
- value: part.value
293
- };
434
+ let entry = {
435
+ value: part.value
436
+ };
294
437
 
295
- if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && typeof part.value === 'string') {
296
- // parse value into subparts as well
297
- entry = Object.assign(entry, valueParser(entry.value));
298
- }
438
+ if (AR_HEADERS.has(headerKey) && typeof part.value === 'string') {
439
+ // parse value into subparts as well
440
+ entry = Object.assign(entry, valueParser(entry.value));
441
+ }
299
442
 
300
- if (part.comment) {
301
- entry.comment = part.comment;
302
- }
443
+ if (part.comment) {
444
+ entry.comment = part.comment;
445
+ }
303
446
 
304
- if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') {
305
- if (!result[part.key]) {
306
- result[part.key] = [];
307
- }
308
- result[part.key].push(entry);
309
- } else {
310
- result[part.key] = entry;
447
+ if (AR_HEADERS.has(headerKey) && part.key === 'dkim') {
448
+ if (!result[part.key]) {
449
+ result[part.key] = [];
311
450
  }
312
- });
451
+ result[part.key].push(entry);
452
+ } else {
453
+ result[part.key] = entry;
454
+ }
455
+ });
313
456
 
314
- return result;
315
- };
457
+ return result;
458
+ };
459
+
460
+ /**
461
+ * Parses a header field. DKIM-Signature, ARC-Seal, ARC-Message-Signature and other tag-list
462
+ * fields are parsed with the RFC 6376 section 3.2 grammar, Authentication-Results and
463
+ * ARC-Authentication-Results with the RFC 8601 grammar. A value with no header name in front
464
+ * of it is parsed with the RFC 8601 grammar, as it always was; use parseTagList() for a bare
465
+ * tag-list such as a DNS record.
466
+ *
467
+ * @param {Buffer|String} buf Header line
468
+ * @param {Object} [options]
469
+ * @param {Boolean} [options.strict] Key the map of a tag-list by the tag names as written
470
+ * (RFC 6376 section 3.2), see tagListParser. Authentication-Results are not affected
471
+ * @returns {Object} { parsed, original } and, for tag-lists, also { tags, syntaxErrors }
472
+ */
473
+ const headerParser = (buf, options) => {
474
+ let line = (buf || '').toString().trim();
475
+ let splitterPos = line.indexOf(':');
476
+ let headerKey;
477
+ if (splitterPos >= 0) {
478
+ headerKey = line.substr(0, splitterPos).trim().toLowerCase();
479
+ line = line.substr(splitterPos + 1).trim();
480
+ }
481
+
482
+ if (headerKey && !AR_HEADERS.has(headerKey)) {
483
+ let { parsed, tags, syntaxErrors } = tagListParser(line, headerKey, !!options?.strict);
484
+ return { parsed, original: buf, tags, syntaxErrors };
485
+ }
486
+
487
+ return { parsed: arParser(line, headerKey), original: buf };
488
+ };
316
489
 
317
- return { parsed: parse(), original: buf };
490
+ /**
491
+ * Parses a bare tag-list, such as a DKIM key record, without looking for a header name
492
+ *
493
+ * @param {Buffer|String} value Tag-list
494
+ * @param {Object} [options]
495
+ * @param {Boolean} [options.strict] Key the map by the tag names as written, see tagListParser
496
+ * @returns {Object} { parsed, original, tags, syntaxErrors }
497
+ */
498
+ const parseTagList = (value, options) => {
499
+ let line = (value || '').toString().trim();
500
+ let { parsed, tags, syntaxErrors } = tagListParser(line, undefined, !!options?.strict);
501
+ return { parsed, original: value, tags, syntaxErrors };
318
502
  };
319
503
 
320
504
  module.exports = headerParser;
505
+ module.exports.parseTagList = parseTagList;