@limetech/lime-elements 39.45.2 → 40.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 (103) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cjs/lime-elements.cjs.js +1 -1
  3. package/dist/cjs/limel-action-bar_3.cjs.entry.js +1 -1
  4. package/dist/cjs/limel-ai-avatar.cjs.entry.js +1 -1
  5. package/dist/cjs/limel-callout.cjs.entry.js +1 -1
  6. package/dist/cjs/limel-chart.cjs.entry.js +1 -1
  7. package/dist/cjs/limel-chip-set.cjs.entry.js +1 -1
  8. package/dist/cjs/limel-chip_2.cjs.entry.js +1 -1
  9. package/dist/cjs/limel-code-diff.cjs.entry.js +1 -1
  10. package/dist/cjs/limel-code-editor.cjs.entry.js +1 -1
  11. package/dist/cjs/limel-collapsible-section.cjs.entry.js +1 -1
  12. package/dist/cjs/limel-drag-handle.cjs.entry.js +1 -1
  13. package/dist/cjs/limel-email-viewer.cjs.entry.js +1 -1
  14. package/dist/cjs/limel-file-viewer.cjs.entry.js +701 -345
  15. package/dist/cjs/limel-file.cjs.entry.js +1 -1
  16. package/dist/cjs/limel-flatpickr-adapter.cjs.entry.js +1 -1
  17. package/dist/cjs/limel-form.cjs.entry.js +17 -43
  18. package/dist/cjs/limel-list-item.cjs.entry.js +1 -1
  19. package/dist/cjs/limel-picker.cjs.entry.js +1 -1
  20. package/dist/cjs/limel-profile-picture.cjs.entry.js +1 -1
  21. package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +40 -40
  22. package/dist/cjs/limel-slider.cjs.entry.js +140 -16
  23. package/dist/cjs/limel-snackbar.cjs.entry.js +1 -1
  24. package/dist/cjs/limel-table.cjs.entry.js +1 -1
  25. package/dist/cjs/limel-text-editor-link-menu.cjs.entry.js +1 -1
  26. package/dist/cjs/loader.cjs.js +1 -1
  27. package/dist/cjs/{translations-Bnw00bkf.js → translations-Do_D3pxu.js} +39 -2
  28. package/dist/collection/components/slider/slider.css +95 -0
  29. package/dist/collection/components/slider/slider.js +174 -21
  30. package/dist/collection/global/translations.js +15 -2
  31. package/dist/collection/translations/da.js +3 -0
  32. package/dist/collection/translations/de.js +3 -0
  33. package/dist/collection/translations/en.js +3 -0
  34. package/dist/collection/translations/fi.js +3 -0
  35. package/dist/collection/translations/fr.js +3 -0
  36. package/dist/collection/translations/nl.js +3 -0
  37. package/dist/collection/translations/no.js +3 -0
  38. package/dist/collection/translations/sv.js +3 -0
  39. package/dist/esm/lime-elements.js +1 -1
  40. package/dist/esm/limel-action-bar_3.entry.js +1 -1
  41. package/dist/esm/limel-ai-avatar.entry.js +1 -1
  42. package/dist/esm/limel-callout.entry.js +1 -1
  43. package/dist/esm/limel-chart.entry.js +1 -1
  44. package/dist/esm/limel-chip-set.entry.js +1 -1
  45. package/dist/esm/limel-chip_2.entry.js +1 -1
  46. package/dist/esm/limel-code-diff.entry.js +1 -1
  47. package/dist/esm/limel-code-editor.entry.js +1 -1
  48. package/dist/esm/limel-collapsible-section.entry.js +1 -1
  49. package/dist/esm/limel-drag-handle.entry.js +1 -1
  50. package/dist/esm/limel-email-viewer.entry.js +1 -1
  51. package/dist/esm/limel-file-viewer.entry.js +701 -345
  52. package/dist/esm/limel-file.entry.js +1 -1
  53. package/dist/esm/limel-flatpickr-adapter.entry.js +1 -1
  54. package/dist/esm/limel-form.entry.js +17 -43
  55. package/dist/esm/limel-list-item.entry.js +1 -1
  56. package/dist/esm/limel-picker.entry.js +1 -1
  57. package/dist/esm/limel-profile-picture.entry.js +1 -1
  58. package/dist/esm/limel-prosemirror-adapter.entry.js +40 -40
  59. package/dist/esm/limel-slider.entry.js +140 -16
  60. package/dist/esm/limel-snackbar.entry.js +1 -1
  61. package/dist/esm/limel-table.entry.js +1 -1
  62. package/dist/esm/limel-text-editor-link-menu.entry.js +1 -1
  63. package/dist/esm/loader.js +1 -1
  64. package/dist/esm/{translations-Cb9R_zmF.js → translations-DgqE77nk.js} +39 -2
  65. package/dist/lime-elements/lime-elements.esm.js +1 -1
  66. package/dist/lime-elements/{p-421612f9.entry.js → p-21b54196.entry.js} +4 -4
  67. package/dist/lime-elements/p-3e316685.entry.js +1 -0
  68. package/dist/lime-elements/{p-c8116a01.entry.js → p-565f688a.entry.js} +1 -1
  69. package/dist/lime-elements/{p-aa080a8f.entry.js → p-5dde5d72.entry.js} +1 -1
  70. package/dist/lime-elements/{p-31713caf.entry.js → p-5e432a44.entry.js} +1 -1
  71. package/dist/lime-elements/{p-4a09fb2e.entry.js → p-62a25deb.entry.js} +1 -1
  72. package/dist/lime-elements/{p-8aa4a38e.entry.js → p-72a1d432.entry.js} +1 -1
  73. package/dist/lime-elements/{p-9a04a686.entry.js → p-83bdbaf7.entry.js} +1 -1
  74. package/dist/lime-elements/{p-ca0b86ee.entry.js → p-8c3798ee.entry.js} +1 -1
  75. package/dist/lime-elements/{p-8aeeeb53.entry.js → p-8cf79fa1.entry.js} +1 -1
  76. package/dist/lime-elements/{p-7487d65d.entry.js → p-9ed9d0de.entry.js} +1 -1
  77. package/dist/lime-elements/p-DgqE77nk.js +1 -0
  78. package/dist/lime-elements/{p-2284caa7.entry.js → p-a6500046.entry.js} +1 -1
  79. package/dist/lime-elements/{p-e545c9e8.entry.js → p-b04c6602.entry.js} +1 -1
  80. package/dist/lime-elements/{p-d97df2cd.entry.js → p-b4b43404.entry.js} +1 -1
  81. package/dist/lime-elements/p-b6528083.entry.js +1 -0
  82. package/dist/lime-elements/{p-6722e6e2.entry.js → p-cafebc7d.entry.js} +1 -1
  83. package/dist/lime-elements/{p-2f223e46.entry.js → p-cdcd9880.entry.js} +1 -1
  84. package/dist/lime-elements/{p-2e5d5e79.entry.js → p-dd116bb1.entry.js} +1 -1
  85. package/dist/lime-elements/{p-d7bb4310.entry.js → p-dda63b8b.entry.js} +1 -1
  86. package/dist/lime-elements/{p-7f7e2180.entry.js → p-e8783848.entry.js} +1 -1
  87. package/dist/lime-elements/{p-19c81ded.entry.js → p-eeddce38.entry.js} +1 -1
  88. package/dist/lime-elements/{p-9ae7d11b.entry.js → p-f53d8bda.entry.js} +1 -1
  89. package/dist/lime-elements/{p-03fd2181.entry.js → p-fa4edcf5.entry.js} +1 -1
  90. package/dist/types/components/slider/slider.d.ts +67 -4
  91. package/dist/types/components.d.ts +25 -10
  92. package/dist/types/translations/da.d.ts +3 -0
  93. package/dist/types/translations/de.d.ts +3 -0
  94. package/dist/types/translations/en.d.ts +3 -0
  95. package/dist/types/translations/fi.d.ts +3 -0
  96. package/dist/types/translations/fr.d.ts +3 -0
  97. package/dist/types/translations/nl.d.ts +3 -0
  98. package/dist/types/translations/no.d.ts +3 -0
  99. package/dist/types/translations/sv.d.ts +3 -0
  100. package/package.json +9 -9
  101. package/dist/lime-elements/p-Cb9R_zmF.js +0 -1
  102. package/dist/lime-elements/p-ee58f0b4.entry.js +0 -1
  103. package/dist/lime-elements/p-f51b5651.entry.js +0 -1
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var index$1 = require('./index-DYg_7kkT.js');
4
- var translations = require('./translations-Bnw00bkf.js');
4
+ var translations = require('./translations-Do_D3pxu.js');
5
5
  var index = require('./index-ETPz91V5.js');
6
6
  var image_template = require('./image.template-DZC6_5a4.js');
7
7
  require('./_commonjsHelpers-CFO10eej.js');
@@ -134,25 +134,27 @@ for (let i = 0; i < base64Chars.length; i++) {
134
134
  }
135
135
 
136
136
  function decodeBase64(base64) {
137
- let bufferLength = Math.ceil(base64.length / 4) * 3;
138
- const len = base64.length;
139
-
140
- let p = 0;
137
+ // Padding carries no data, so the byte count comes from the payload alone. Sizing the
138
+ // buffer from the raw length instead treated '=' as a data character and left the
139
+ // output padded with NUL bytes, which then travelled into subjects and filenames.
140
+ let len = base64.length;
141
+ while (len > 0 && base64.charAt(len - 1) === '=') {
142
+ len--;
143
+ }
141
144
 
142
- if (base64.length % 4 === 3) {
143
- bufferLength--;
144
- } else if (base64.length % 4 === 2) {
145
- bufferLength -= 2;
146
- } else if (base64[base64.length - 1] === '=') {
147
- bufferLength--;
148
- if (base64[base64.length - 2] === '=') {
149
- bufferLength--;
150
- }
145
+ // A remainder of one character cannot encode a byte, it is a truncated group
146
+ if (len % 4 === 1) {
147
+ len--;
151
148
  }
152
149
 
150
+ const remainder = len % 4;
151
+ const bufferLength = Math.floor(len / 4) * 3 + (remainder ? remainder - 1 : 0);
152
+
153
153
  const arrayBuffer = new ArrayBuffer(bufferLength);
154
154
  const bytes = new Uint8Array(arrayBuffer);
155
155
 
156
+ let p = 0;
157
+
156
158
  for (let i = 0; i < len; i += 4) {
157
159
  let encoded1 = base64Lookup[base64.charCodeAt(i)];
158
160
  let encoded2 = base64Lookup[base64.charCodeAt(i + 1)];
@@ -160,37 +162,107 @@ function decodeBase64(base64) {
160
162
  let encoded4 = base64Lookup[base64.charCodeAt(i + 3)];
161
163
 
162
164
  bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
163
- bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
164
- bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
165
+ if (p < bufferLength) {
166
+ bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
167
+ }
168
+ if (p < bufferLength) {
169
+ bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
170
+ }
165
171
  }
166
172
 
167
173
  return arrayBuffer;
168
174
  }
169
175
 
170
- // Charset aliases that the WHATWG Encoding Standard (and thus TextDecoder in
171
- // browsers and Workers) does not recognize, but that map cleanly to a supported
172
- // encoding. Node's ICU-backed TextDecoder resolves these natively; strict WHATWG
173
- // runtimes would otherwise throw and fall back to windows-1252, emitting mojibake.
174
- // eg. Hebrew bodies declared as the logical (iso-8859-8-i) or explicit (iso-8859-8-e)
175
- // variants decode to the same code points as iso-8859-8.
176
+ // Charset labels the WHATWG Encoding Standard does not list, but that name an encoding
177
+ // TextDecoder can decode. Without an entry the TextDecoder constructor throws and
178
+ // getDecoder falls back to windows-1252, turning the body into mojibake with nothing to
179
+ // signal that the wrong decoder was used.
180
+ //
181
+ // Keys are normalized (see normalizeCharset), so one entry covers every spelling of a
182
+ // label, eg. `eucjp` also catches `euc_jp`, `x-eucjp` and `x-euc-jp`. Only labels that
183
+ // need real encoding knowledge belong here; anything that differs from a supported
184
+ // label by nothing but an `x-` prefix or a separator is handled by normalization alone.
176
185
  const charsetAliases = new Map([
177
- ['iso-8859-8-i', 'iso-8859-8'],
178
- ['iso-8859-8-e', 'iso-8859-8']
186
+ // Hebrew. The logical and explicit ordering variants share the iso-8859-8 index.
187
+ ['iso88598i', 'iso-8859-8'],
188
+ ['iso88598e', 'iso-8859-8'],
189
+
190
+ // Japanese. WHATWG shift_jis is the Windows-31J index, so cp932 text decodes
191
+ // identically, including the NEC and IBM extension rows.
192
+ ['shiftjis', 'shift_jis'],
193
+ ['windows31j', 'shift_jis'],
194
+ ['mskanji', 'shift_jis'],
195
+ ['eucjp', 'euc-jp'],
196
+
197
+ // ISO-2022-JP, including the -1 / -2 supersets. Escape sequences outside plain
198
+ // ISO-2022-JP (JIS X 0212, the non-Japanese G2 sets, and the SO/SI katakana shifts
199
+ // cp50222 uses) decode to replacement characters, but the Japanese text around them
200
+ // still comes out right.
201
+ ['iso2022jp', 'iso-2022-jp'],
202
+ ['iso2022jp1', 'iso-2022-jp'],
203
+ ['iso2022jp2', 'iso-2022-jp'],
204
+ ['junet', 'iso-2022-jp'],
205
+
206
+ // Korean. The WHATWG euc-kr index is the extended cp949 / UHC index.
207
+ ['euckr', 'euc-kr'],
208
+ ['uhc', 'euc-kr'],
209
+
210
+ // Thai.
211
+ ['tis620', 'windows-874']
179
212
  ]);
180
213
 
181
- function getDecoder(charset) {
182
- charset = (charset || 'utf8').trim().toLowerCase();
183
- charset = charsetAliases.get(charset) || charset;
214
+ // Windows and IBM code page numbers, as written in labels like cp932, windows-932, ms932
215
+ // or ibm932. An explicit allowlist rather than a derived one, because plenty of code
216
+ // pages that appear in mail (cp437, cp850, cp1361) have no equivalent to map onto and
217
+ // have to keep falling back.
218
+ const codePageAliases = new Map([
219
+ ['932', 'shift_jis'],
220
+ ['936', 'gbk'],
221
+ ['949', 'euc-kr'],
222
+ ['950', 'big5'],
223
+ ['874', 'windows-874'],
224
+ // Microsoft's EUC-JP and ISO-2022-JP variants. euc-jp and iso-2022-jp cover
225
+ // everything they can express.
226
+ ['51932', 'euc-jp'],
227
+ ['50220', 'iso-2022-jp'],
228
+ ['50221', 'iso-2022-jp'],
229
+ ['50222', 'iso-2022-jp']
230
+ ]);
184
231
 
185
- let decoder;
232
+ const codePagePattern = /^(?:cp|windows|ms|ibm)(\d+)$/;
186
233
 
234
+ // Strip the decorations mail clients add to an otherwise standard label: an x- vendor
235
+ // prefix, the IANA cs- prefix, and any separators.
236
+ function normalizeCharset(charset) {
237
+ return charset.replace(/^(?:x-ms-|x-|cs)/, '').replace(/[\s._-]+/g, '');
238
+ }
239
+
240
+ function tryDecoder(charset) {
187
241
  try {
188
- decoder = new TextDecoder(charset);
242
+ return new TextDecoder(charset);
189
243
  } catch (err) {
190
- decoder = new TextDecoder('windows-1252');
244
+ return null;
191
245
  }
246
+ }
192
247
 
193
- return decoder;
248
+ function getDecoder(charset) {
249
+ charset = (charset || 'utf8').trim().toLowerCase();
250
+
251
+ // Try the label as written first, so the alias table only ever adds to what the
252
+ // runtime already supports instead of shadowing it.
253
+ const decoder = tryDecoder(charset);
254
+ if (decoder) {
255
+ return decoder;
256
+ }
257
+
258
+ const normalized = normalizeCharset(charset);
259
+ const codePage = normalized.match(codePagePattern);
260
+
261
+ // The normalized label is itself the last candidate, which resolves everything that
262
+ // differed from a supported label only by a prefix or a separator, eg. x-big5.
263
+ const alias = (codePage && codePageAliases.get(codePage[1])) || charsetAliases.get(normalized) || normalized;
264
+
265
+ return tryDecoder(alias) || new TextDecoder('windows-1252');
194
266
  }
195
267
 
196
268
  /**
@@ -218,15 +290,23 @@ async function blobToArrayBuffer(blob) {
218
290
  });
219
291
  }
220
292
 
221
- function getHex(c) {
222
- if (
223
- (c >= 0x30 /* 0 */ && c <= 0x39) /* 9 */ ||
224
- (c >= 0x61 /* a */ && c <= 0x66) /* f */ ||
225
- (c >= 0x41 /* A */ && c <= 0x46) /* F */
226
- ) {
227
- return String.fromCharCode(c);
293
+ /**
294
+ * Numeric value of an ASCII hex digit
295
+ *
296
+ * @param {Number} c Byte to read
297
+ * @return {Number} Value 0-15, or -1 if the byte is not a hex digit
298
+ */
299
+ function hexNibble(c) {
300
+ if (c >= 0x30 /* 0 */ && c <= 0x39 /* 9 */) {
301
+ return c - 0x30;
302
+ }
303
+ if (c >= 0x61 /* a */ && c <= 0x66 /* f */) {
304
+ return c - 0x61 + 10;
228
305
  }
229
- return false;
306
+ if (c >= 0x41 /* A */ && c <= 0x46 /* F */) {
307
+ return c - 0x41 + 10;
308
+ }
309
+ return -1;
230
310
  }
231
311
 
232
312
  /**
@@ -260,11 +340,10 @@ function decodeWord(charset, encoding, str) {
260
340
  for (let i = 0, len = buf.length; i < len; i++) {
261
341
  let c = buf[i];
262
342
  if (i <= len - 2 && c === 0x3d /* = */) {
263
- let c1 = getHex(buf[i + 1]);
264
- let c2 = getHex(buf[i + 2]);
265
- if (c1 && c2) {
266
- let c = parseInt(c1 + c2, 16);
267
- encodedBytes.push(c);
343
+ let high = hexNibble(buf[i + 1]);
344
+ let low = hexNibble(buf[i + 2]);
345
+ if (high >= 0 && low >= 0) {
346
+ encodedBytes.push((high << 4) | low);
268
347
  i += 2;
269
348
  continue;
270
349
  }
@@ -286,59 +365,135 @@ function decodeWord(charset, encoding, str) {
286
365
  return getDecoder(charset).decode(byteStr);
287
366
  }
288
367
 
289
- function decodeWords(str) {
290
- let joinString = true;
291
-
292
- while (true) {
293
- let result = (str || '')
294
- .toString()
295
- // find base64 words that can be joined
296
- .replace(
297
- /(=\?([^?]+)\?[Bb]\?([^?]*)\?=)\s*(?==\?([^?]+)\?[Bb]\?[^?]*\?=)/g,
298
- (match, left, chLeft, encodedLeftStr, chRight) => {
299
- if (!joinString) {
300
- return match;
301
- }
302
- // only mark b64 chunks to be joined if charsets match and left side does not end with =
303
- if (chLeft === chRight && encodedLeftStr.length % 4 === 0 && !/=$/.test(encodedLeftStr)) {
304
- // set a joiner marker
305
- return left + '__\x00JOIN\x00__';
306
- }
368
+ // A charset label runs to the next '?' so that labels containing punctuation, eg.
369
+ // ISO_8859-1:1987, are recognised. Whitespace is excluded so a stray '=?' in running text
370
+ // can not swallow the rest of the line.
371
+ const ENCODED_WORD_PATTERN = '=\\?([^?\\s]+)\\?([QqBb])\\?([^?]*)\\?=';
372
+ const ENCODED_WORD_REGEX = new RegExp(ENCODED_WORD_PATTERN, 'g');
307
373
 
308
- return match;
309
- }
310
- )
311
- // find QP words that can be joined
312
- .replace(
313
- /(=\?([^?]+)\?[Qq]\?[^?]*\?=)\s*(?==\?([^?]+)\?[Qq]\?[^?]*\?=)/g,
314
- (match, left, chLeft, chRight) => {
315
- if (!joinString) {
316
- return match;
317
- }
318
- // only mark QP chunks to be joined if charsets match
319
- if (chLeft === chRight) {
320
- // set a joiner marker
321
- return left + '__\x00JOIN\x00__';
322
- }
323
- return match;
324
- }
325
- )
326
- // join base64 encoded words
327
- .replace(/(\?=)?__\x00JOIN\x00__(=\?([^?]+)\?[QqBb]\?)?/g, '')
328
- // remove spaces between mime encoded words
329
- .replace(/(=\?[^?]+\?[QqBb]\?[^?]*\?=)\s+(?==\?[^?]+\?[QqBb]\?[^?]*\?=)/g, '$1')
330
- // decode words
331
- .replace(/=\?([\w_\-*]+)\?([QqBb])\?([^?]*)\?=/g, (m, charset, encoding, text) =>
332
- decodeWord(charset, encoding, text)
333
- );
334
-
335
- if (joinString && result.indexOf('\ufffd') >= 0) {
336
- // text contains \ufffd (EF BF BD), so unicode conversion failed, retry without joining strings
337
- joinString = false;
338
- } else {
339
- return result;
374
+ // Only linear whitespace separates encoded words, the rest is content
375
+ const WORD_SEPARATOR_REGEX = /^[ \t\r\n]+$/;
376
+
377
+ const ENCODED_WORDS_ONLY_REGEX = new RegExp(`^(?:${ENCODED_WORD_PATTERN}\\s*)+$`);
378
+
379
+ /**
380
+ * Checks whether a string is nothing but RFC 2047 encoded words. Kept next to the grammar
381
+ * it depends on, so the pattern has a single definition.
382
+ *
383
+ * @param {String} str String to check
384
+ * @return {Boolean} true if the string holds encoded words and nothing else
385
+ */
386
+ function isEncodedWordsOnly(str) {
387
+ return ENCODED_WORDS_ONLY_REGEX.test(str);
388
+ }
389
+
390
+ /**
391
+ * Splits a string into encoded words and the literal text around them.
392
+ *
393
+ * Working on a token list rather than marking joinable words with an in band sentinel
394
+ * means the input can not contain the marker, which previously let a sender delete text
395
+ * from a subject or a display name by writing the marker into the header themselves.
396
+ *
397
+ * @param {String} str String to split
398
+ * @return {Array} Array of `{text}` and `{charset, encoding, encodedText}` tokens
399
+ */
400
+ function splitEncodedWords(str) {
401
+ const tokens = [];
402
+
403
+ ENCODED_WORD_REGEX.lastIndex = 0;
404
+
405
+ let pos = 0;
406
+ let match;
407
+
408
+ while ((match = ENCODED_WORD_REGEX.exec(str))) {
409
+ if (match.index > pos) {
410
+ tokens.push({ text: str.substring(pos, match.index) });
411
+ }
412
+ tokens.push({ charset: match[1], encoding: match[2], encodedText: match[3] });
413
+ pos = match.index + match[0].length;
414
+ }
415
+
416
+ if (pos < str.length) {
417
+ tokens.push({ text: str.substring(pos) });
418
+ }
419
+
420
+ return tokens;
421
+ }
422
+
423
+ /**
424
+ * Checks if two adjacent encoded words may be decoded as a single unit. A multi byte
425
+ * character is often split across two words, so the bytes have to be concatenated before
426
+ * they are decoded. Base64 additionally needs the left chunk to end on a group boundary,
427
+ * otherwise the concatenation shifts every byte that follows.
428
+ */
429
+ function canJoinWords(left, right) {
430
+ const encoding = left.encoding.toUpperCase();
431
+
432
+ if (left.charset !== right.charset || encoding !== right.encoding.toUpperCase()) {
433
+ return false;
434
+ }
435
+
436
+ if (encoding === 'B') {
437
+ return left.encodedText.length % 4 === 0 && !/=$/.test(left.encodedText);
438
+ }
439
+
440
+ return true;
441
+ }
442
+
443
+ /**
444
+ * Decodes a token list into a string, optionally merging adjacent encoded words.
445
+ *
446
+ * @param {Array} tokens Tokens from splitEncodedWords
447
+ * @param {Boolean} joinWords Whether adjacent encoded words may be decoded as one unit
448
+ * @return {String} Decoded string
449
+ */
450
+ function renderTokens(tokens, joinWords) {
451
+ let result = '';
452
+ let pending = null;
453
+
454
+ for (let i = 0; i < tokens.length; i++) {
455
+ const token = tokens[i];
456
+
457
+ if (token.text !== undefined) {
458
+ // whitespace between two encoded words is a folding artifact, not content
459
+ const nextToken = tokens[i + 1];
460
+ if (pending && nextToken && nextToken.text === undefined && WORD_SEPARATOR_REGEX.test(token.text)) {
461
+ continue;
462
+ }
463
+ if (pending) {
464
+ result += decodeWord(pending.charset, pending.encoding, pending.encodedText);
465
+ pending = null;
466
+ }
467
+ result += token.text;
468
+ continue;
340
469
  }
470
+
471
+ if (pending && joinWords && canJoinWords(pending, token)) {
472
+ pending.encodedText += token.encodedText;
473
+ continue;
474
+ }
475
+
476
+ if (pending) {
477
+ result += decodeWord(pending.charset, pending.encoding, pending.encodedText);
478
+ }
479
+ pending = { charset: token.charset, encoding: token.encoding, encodedText: token.encodedText };
480
+ }
481
+
482
+ if (pending) {
483
+ result += decodeWord(pending.charset, pending.encoding, pending.encodedText);
341
484
  }
485
+
486
+ return result;
487
+ }
488
+
489
+ function decodeWords(str) {
490
+ const tokens = splitEncodedWords((str || '').toString());
491
+
492
+ const result = renderTokens(tokens, true);
493
+
494
+ // A replacement character means the bytes did not decode, which happens when two
495
+ // words were joined that should have stayed apart. Retry keeping them separate.
496
+ return result.indexOf('\ufffd') < 0 ? result : renderTokens(tokens, false);
342
497
  }
343
498
 
344
499
  function decodeURIComponentWithCharset(encodedStr, charset) {
@@ -401,25 +556,45 @@ function decodeParameterValueContinuations(header) {
401
556
  }
402
557
 
403
558
  let value = header.params[key];
404
- if (nr === 0 && match[0].charAt(match[0].length - 1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
559
+ // RFC 2231 section 4.1: only a section whose name ends in '*' is percent encoded.
560
+ // A plain `name*0=` section is literal text, so decoding it invents characters
561
+ // that never appeared on the wire, turning `a%2F..%2Fetc` into a path traversal.
562
+ let encoded = match[0].charAt(match[0].length - 1) === '*';
563
+
564
+ if (nr === 0 && encoded && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
405
565
  paramVal.charset = match[1] || 'utf-8';
406
566
  value = match[2];
407
567
  }
408
568
 
409
- paramVal.values.push({ nr, value });
569
+ paramVal.values.push({ nr, value, encoded });
410
570
 
411
571
  // remove the old reference
412
572
  delete header.params[key];
413
573
  });
414
574
 
415
575
  paramKeys.forEach((paramVal, key) => {
416
- header.params[key] = decodeURIComponentWithCharset(
417
- paramVal.values
418
- .sort((a, b) => a.nr - b.nr)
419
- .map(a => a.value)
420
- .join(''),
421
- paramVal.charset
422
- );
576
+ let result = '';
577
+ // Adjacent encoded sections are decoded together, because a single multi byte
578
+ // character may be percent encoded across a section boundary.
579
+ let pending = '';
580
+
581
+ for (let part of paramVal.values.sort((a, b) => a.nr - b.nr)) {
582
+ if (part.encoded) {
583
+ pending += part.value;
584
+ continue;
585
+ }
586
+ if (pending) {
587
+ result += decodeURIComponentWithCharset(pending, paramVal.charset);
588
+ pending = '';
589
+ }
590
+ result += part.value;
591
+ }
592
+
593
+ if (pending) {
594
+ result += decodeURIComponentWithCharset(pending, paramVal.charset);
595
+ }
596
+
597
+ header.params[key] = result;
423
598
  });
424
599
  }
425
600
 
@@ -452,162 +627,155 @@ class Base64Decoder {
452
627
  this.remainder = '';
453
628
  }
454
629
 
455
- update(buffer) {
456
- let str = this.decoder.decode(buffer);
457
-
458
- str = str.replace(/[^a-zA-Z0-9+\/]+/g, '');
459
-
460
- this.remainder += str;
630
+ pushChunk(base64Str) {
631
+ if (base64Str.length) {
632
+ this.chunks.push(decodeBase64(base64Str));
633
+ }
634
+ }
461
635
 
462
- if (this.remainder.length >= this.maxChunkSize) {
463
- let allowedBytes = Math.floor(this.remainder.length / 4) * 4;
464
- let base64Str;
636
+ flushRemainder() {
637
+ this.pushChunk(this.remainder);
638
+ this.remainder = '';
639
+ }
465
640
 
466
- if (allowedBytes === this.remainder.length) {
467
- base64Str = this.remainder;
468
- this.remainder = '';
469
- } else {
470
- base64Str = this.remainder.substr(0, allowedBytes);
471
- this.remainder = this.remainder.substr(allowedBytes);
641
+ update(buffer) {
642
+ let str = this.decoder.decode(buffer).replace(/[^a-zA-Z0-9+/=]+/g, '');
643
+
644
+ // '=' terminates a base64 unit. Some mailers pad every line, and erasing the
645
+ // padding used to concatenate the units, which knocked everything after the first
646
+ // embedded pad out of 4 character alignment and decoded it to garbage.
647
+ const units = str.split(/=+/);
648
+ for (let i = 0; i < units.length; i++) {
649
+ this.remainder += units[i];
650
+ // the trailing piece is not followed by padding, so it stays open
651
+ if (i < units.length - 1) {
652
+ this.flushRemainder();
472
653
  }
654
+ }
473
655
 
474
- if (base64Str.length) {
475
- this.chunks.push(decodeBase64(base64Str));
476
- }
656
+ if (this.remainder.length >= this.maxChunkSize) {
657
+ const alignedLength = Math.floor(this.remainder.length / 4) * 4;
658
+ this.pushChunk(this.remainder.substring(0, alignedLength));
659
+ this.remainder = this.remainder.substring(alignedLength);
477
660
  }
478
661
  }
479
662
 
480
663
  finalize() {
481
- if (this.remainder && !/^=+$/.test(this.remainder)) {
482
- this.chunks.push(decodeBase64(this.remainder));
483
- }
664
+ this.flushRemainder();
484
665
 
485
666
  return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
486
667
  }
487
668
  }
488
669
 
489
- // Regex patterns compiled once for performance
490
- const VALID_QP_REGEX = /^=[a-f0-9]{2}$/i;
491
- const QP_SPLIT_REGEX = /(?==[a-f0-9]{2})/i;
492
- const SOFT_LINE_BREAK_REGEX = /=\r?\n/g;
493
- const PARTIAL_QP_ENDING_REGEX = /=[a-fA-F0-9]?$/;
670
+ const CHR_EQUALS = 0x3d;
671
+ const CHR_LF = 0x0a;
494
672
 
495
673
  class QPDecoder {
496
- constructor(opts) {
497
- opts = opts || {};
498
-
499
- this.decoder = opts.decoder || new TextDecoder();
500
-
674
+ constructor() {
501
675
  this.maxChunkSize = 100 * 1024;
502
676
 
503
- this.remainder = '';
677
+ this.buffer = new Uint8Array(this.maxChunkSize);
678
+ this.bufferPos = 0;
504
679
 
505
680
  this.chunks = [];
506
681
  }
507
682
 
508
- decodeQPBytes(encodedBytes) {
509
- let buf = new ArrayBuffer(encodedBytes.length);
510
- let dataView = new DataView(buf);
511
- for (let i = 0, len = encodedBytes.length; i < len; i++) {
512
- dataView.setUint8(i, parseInt(encodedBytes[i], 16));
683
+ writeByte(byte) {
684
+ if (this.bufferPos >= this.buffer.length) {
685
+ this.flushBuffer();
513
686
  }
514
- return buf;
687
+ this.buffer[this.bufferPos++] = byte;
515
688
  }
516
689
 
517
- decodeChunks(str) {
518
- // unwrap newlines
519
- str = str.replace(SOFT_LINE_BREAK_REGEX, '');
520
-
521
- let list = str.split(QP_SPLIT_REGEX);
522
- let encodedBytes = [];
523
- for (let part of list) {
524
- if (part.charAt(0) !== '=') {
525
- if (encodedBytes.length) {
526
- this.chunks.push(this.decodeQPBytes(encodedBytes));
527
- encodedBytes = [];
528
- }
529
- this.chunks.push(part);
530
- continue;
531
- }
532
-
533
- if (part.length === 3) {
534
- // Validate that this is actually a valid QP sequence
535
- if (VALID_QP_REGEX.test(part)) {
536
- encodedBytes.push(part.substr(1));
537
- } else {
538
- // Not a valid QP sequence, treat as literal text
539
- if (encodedBytes.length) {
540
- this.chunks.push(this.decodeQPBytes(encodedBytes));
541
- encodedBytes = [];
542
- }
543
- this.chunks.push(part);
544
- }
545
- continue;
546
- }
547
-
548
- if (part.length > 3) {
549
- // First 3 chars should be a valid QP sequence
550
- const firstThree = part.substr(0, 3);
551
- if (VALID_QP_REGEX.test(firstThree)) {
552
- encodedBytes.push(part.substr(1, 2));
553
- this.chunks.push(this.decodeQPBytes(encodedBytes));
554
- encodedBytes = [];
555
-
556
- part = part.substr(3);
557
- this.chunks.push(part);
558
- } else {
559
- // Not a valid QP sequence, treat entire part as literal
560
- if (encodedBytes.length) {
561
- this.chunks.push(this.decodeQPBytes(encodedBytes));
562
- encodedBytes = [];
563
- }
564
- this.chunks.push(part);
565
- }
690
+ // Literal text is the bulk of a typical body, so it is copied in runs rather than a
691
+ // byte at a time
692
+ writeBytes(line, start, end) {
693
+ while (start < end) {
694
+ if (this.bufferPos >= this.buffer.length) {
695
+ this.flushBuffer();
566
696
  }
567
- }
568
- if (encodedBytes.length) {
569
- this.chunks.push(this.decodeQPBytes(encodedBytes));
697
+ const count = Math.min(end - start, this.buffer.length - this.bufferPos);
698
+ this.buffer.set(line.subarray(start, start + count), this.bufferPos);
699
+ this.bufferPos += count;
700
+ start += count;
570
701
  }
571
702
  }
572
703
 
573
- update(buffer) {
574
- // expect full lines, so add line terminator as well
575
- let str = this.decoder.decode(buffer) + '\n';
704
+ flushBuffer() {
705
+ if (this.bufferPos) {
706
+ this.chunks.push(this.buffer.slice(0, this.bufferPos));
707
+ this.bufferPos = 0;
708
+ }
709
+ }
576
710
 
577
- str = this.remainder + str;
711
+ // Quoted-printable source is 7 bit by definition, so it is decoded byte by byte and
712
+ // the result is handed on as bytes. Running the body charset over the encoded source
713
+ // instead corrupted every part whose charset was not ASCII compatible: the same
714
+ // content that decoded correctly in base64 came out as mojibake in quoted-printable.
715
+ update(line) {
716
+ let len = line.length;
578
717
 
579
- if (str.length < this.maxChunkSize) {
580
- this.remainder = str;
581
- return;
718
+ // a line ending in '=' is a soft line break, the newline is not part of the content
719
+ const softBreak = len > 0 && line[len - 1] === CHR_EQUALS;
720
+ if (softBreak) {
721
+ len--;
582
722
  }
583
723
 
584
- this.remainder = '';
724
+ let literalStart = 0;
725
+ for (let i = 0; i < len; i++) {
726
+ if (line[i] !== CHR_EQUALS || i + 2 >= len) {
727
+ continue;
728
+ }
585
729
 
586
- let partialEnding = str.match(PARTIAL_QP_ENDING_REGEX);
587
- if (partialEnding) {
588
- if (partialEnding.index === 0) {
589
- this.remainder = str;
590
- return;
730
+ const high = hexNibble(line[i + 1]);
731
+ const low = hexNibble(line[i + 2]);
732
+ if (high < 0 || low < 0) {
733
+ // not a valid escape sequence, keep it as literal text
734
+ continue;
591
735
  }
592
- this.remainder = str.substr(partialEnding.index);
593
- str = str.substr(0, partialEnding.index);
736
+
737
+ this.writeBytes(line, literalStart, i);
738
+ this.writeByte((high << 4) | low);
739
+ i += 2;
740
+ literalStart = i + 1;
594
741
  }
742
+ this.writeBytes(line, literalStart, len);
595
743
 
596
- this.decodeChunks(str);
744
+ if (!softBreak) {
745
+ this.writeByte(CHR_LF);
746
+ }
597
747
  }
598
748
 
599
749
  finalize() {
600
- if (this.remainder.length) {
601
- this.decodeChunks(this.remainder);
602
- this.remainder = '';
603
- }
750
+ this.flushBuffer();
604
751
 
605
752
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
606
753
  return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
607
754
  }
608
755
  }
609
756
 
610
- const defaultDecoder = getDecoder();
757
+ // Header lines are decoded with ignoreBOM so that a U+FEFF at the start of a line is
758
+ // kept as a character instead of being swallowed. A stripped BOM turns a line a strict
759
+ // parser skips into a genuine header, which is how a second `From:` gets smuggled past
760
+ // anything that inspects the raw message.
761
+ const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
762
+
763
+ // Trims only the whitespace RFC 5322 allows around a field name. String.prototype.trim
764
+ // also strips U+00A0, U+FEFF, U+2028 and the rest of the Unicode spaces, which turns a
765
+ // line that a strict parser rejects into a canonical field name: ` From:` became a
766
+ // `from` header, and since the first occurrence of a header wins it outranked the real
767
+ // sender. Leaving the character in the key keeps the line visible without letting it
768
+ // collide with a genuine header.
769
+ const trimWsp = str => str.replace(/^[ \t]+|[ \t]+$/g, '');
770
+
771
+ // Headers that decide how this part's body is read, see processHeaders
772
+ const CONTENT_HEADERS = new Set([
773
+ 'content-type',
774
+ 'content-transfer-encoding',
775
+ 'content-disposition',
776
+ 'content-id',
777
+ 'content-description'
778
+ ]);
611
779
 
612
780
  class MimeNode {
613
781
  constructor(options) {
@@ -615,8 +783,11 @@ class MimeNode {
615
783
 
616
784
  this.postalMime = this.options.postalMime;
617
785
 
618
- this.root = !!this.options.parentNode;
619
786
  this.childNodes = [];
787
+ // Cursor into childNodes for finalizeChildNodes. Every new part of a multipart
788
+ // finalizes its parent's children, so re-walking the whole array each time is
789
+ // quadratic in the number of parts.
790
+ this.finalizedChildCount = 0;
620
791
 
621
792
  if (this.options.parentNode) {
622
793
  this.parentNode = this.options.parentNode;
@@ -634,15 +805,15 @@ class MimeNode {
634
805
  this.state = 'header';
635
806
 
636
807
  this.headerLines = [];
637
- this.headerSize = 0;
638
808
 
639
809
  // RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
640
810
  const parentMultipartType = this.options.parentMultipartType || null;
641
811
  const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
642
812
 
813
+ // Replaced by the first matching header, see the CONTENT_HEADERS pass in
814
+ // processHeaders
643
815
  this.contentType = {
644
- value: defaultContentType,
645
- default: true
816
+ value: defaultContentType
646
817
  };
647
818
 
648
819
  this.contentTransferEncoding = {
@@ -662,7 +833,7 @@ class MimeNode {
662
833
  if (/base64/i.test(transferEncoding)) {
663
834
  this.contentDecoder = new Base64Decoder();
664
835
  } else if (/quoted-printable/i.test(transferEncoding)) {
665
- this.contentDecoder = new QPDecoder({ decoder: getDecoder(this.contentType.parsed.params.charset) });
836
+ this.contentDecoder = new QPDecoder();
666
837
  } else {
667
838
  this.contentDecoder = new PassThroughDecoder();
668
839
  }
@@ -700,17 +871,32 @@ class MimeNode {
700
871
  }
701
872
 
702
873
  async finalizeChildNodes() {
703
- for (let childNode of this.childNodes) {
704
- await childNode.finalize();
874
+ // Children are only ever appended, so everything before the cursor is already
875
+ // finished and re-visiting it only costs time.
876
+ while (this.finalizedChildCount < this.childNodes.length) {
877
+ await this.childNodes[this.finalizedChildCount++].finalize();
705
878
  }
706
879
  }
707
880
 
708
- // Strip RFC 822 comments (parenthesized text) from structured header values
881
+ // Strip RFC 822 comments (parenthesized text) from structured header values.
882
+ //
883
+ // Inside an unquoted parameter value a parenthesis that continues the current token is
884
+ // content, because `filename=Invoice(1).pdf` is a filename and not a token followed by
885
+ // a comment, and deleting the parens silently renames the attachment.
709
886
  stripComments(str) {
710
887
  let result = '';
711
888
  let depth = 0;
712
889
  let escaped = false;
713
890
  let inQuote = false;
891
+ // where the outermost comment opened, for the unbalanced case below
892
+ let commentStart = -1;
893
+ // A parameter value starts at `=` and ends at the `;` that begins the next one
894
+ let inParameterValue = false;
895
+
896
+ // A comment may only appear where linear whitespace is allowed, so inside a
897
+ // parameter value the parenthesis has to follow whitespace to open one. Outside
898
+ // one, eg. after the type itself, anything goes.
899
+ const opensComment = () => !inParameterValue || !result.length || /[ \t]$/.test(result);
714
900
 
715
901
  for (let i = 0; i < str.length; i++) {
716
902
  const chr = str.charAt(i);
@@ -738,7 +924,10 @@ class MimeNode {
738
924
  }
739
925
 
740
926
  if (!inQuote) {
741
- if (chr === '(') {
927
+ if (chr === '(' && opensComment()) {
928
+ if (depth === 0) {
929
+ commentStart = i;
930
+ }
742
931
  depth++;
743
932
  continue;
744
933
  }
@@ -746,6 +935,13 @@ class MimeNode {
746
935
  depth--;
747
936
  continue;
748
937
  }
938
+ if (depth === 0) {
939
+ if (chr === '=') {
940
+ inParameterValue = true;
941
+ } else if (chr === ';') {
942
+ inParameterValue = false;
943
+ }
944
+ }
749
945
  }
750
946
 
751
947
  if (depth === 0) {
@@ -753,7 +949,14 @@ class MimeNode {
753
949
  }
754
950
  }
755
951
 
756
- return result;
952
+ if (depth === 0) {
953
+ return result;
954
+ }
955
+
956
+ // An unbalanced `(` is not a comment. Dropping everything after it would take any
957
+ // parameter that follows with it, including the boundary that holds the message
958
+ // together, so the dangling text is only discarded when nothing follows it.
959
+ return str.indexOf(';', commentStart) < 0 ? result : str;
757
960
  }
758
961
 
759
962
  parseStructuredHeader(str) {
@@ -769,62 +972,123 @@ class MimeNode {
769
972
  let value = '';
770
973
  let stage = 'value';
771
974
 
975
+ // Whitespace seen outside a quoted string is held back until a significant
976
+ // character follows it, so surrounding whitespace can be dropped without
977
+ // trimming spaces the sender quoted on purpose. Trimming the stored value
978
+ // instead loses the trailing space in `filename*0="Annual Report "`, which the
979
+ // next continuation section is meant to be appended to.
980
+ let pendingSpace = '';
981
+ let quoteClosed = false;
982
+
772
983
  let quote = false;
773
984
  let escaped = false;
774
985
  let chr;
775
986
 
987
+ const addChr = c => {
988
+ if (value.length) {
989
+ value += pendingSpace;
990
+ }
991
+ pendingSpace = '';
992
+ value += c;
993
+ };
994
+
995
+ const takeValue = () => {
996
+ const result = value;
997
+ value = '';
998
+ pendingSpace = '';
999
+ quoteClosed = false;
1000
+ return result;
1001
+ };
1002
+
1003
+ // A duplicated parameter resolves to its first occurrence, matching how duplicated
1004
+ // headers are resolved. Letting the last one win means `boundary="b"; boundary="c"`
1005
+ // registers a boundary that no delimiter in the message matches, which drops the
1006
+ // body without an error. hasOwnProperty, because a parameter may be named
1007
+ // `constructor` or `toString`.
1008
+ const storeParam = (name, result) => {
1009
+ if (!Object.prototype.hasOwnProperty.call(response.params, name)) {
1010
+ response.params[name] = result;
1011
+ }
1012
+ };
1013
+
1014
+ const storeValue = () => {
1015
+ const result = takeValue();
1016
+ if (key === false) {
1017
+ response.value = result;
1018
+ } else {
1019
+ storeParam(key, result);
1020
+ }
1021
+ };
1022
+
1023
+ // A parameter name with no `=` is a valueless parameter, not the start of the
1024
+ // next one. Without this the name would keep growing across the `;` and swallow
1025
+ // whatever followed, which is how `x=1; flag; boundary="AAA"` loses its boundary.
1026
+ const storeEmptyKey = () => {
1027
+ const name = takeValue().trim();
1028
+ if (name) {
1029
+ storeParam(name.toLowerCase(), '');
1030
+ }
1031
+ };
1032
+
776
1033
  for (let i = 0, len = str.length; i < len; i++) {
777
1034
  chr = str.charAt(i);
778
1035
  switch (stage) {
779
1036
  case 'key':
780
1037
  if (chr === '=') {
781
- key = value.trim().toLowerCase();
1038
+ key = takeValue().trim().toLowerCase();
782
1039
  stage = 'value';
783
- value = '';
1040
+ break;
1041
+ }
1042
+ if (chr === ';') {
1043
+ storeEmptyKey();
784
1044
  break;
785
1045
  }
786
1046
  value += chr;
787
1047
  break;
788
1048
  case 'value':
789
1049
  if (escaped) {
790
- value += chr;
791
- } else if (chr === '\\') {
1050
+ addChr(chr);
1051
+ } else if (quote && chr === '\\') {
1052
+ // backslash only escapes inside a quoted string, everywhere else
1053
+ // it is an ordinary character. Treating it as an escape turns
1054
+ // `filename=C:\Users\me\a.txt` into `C:Usersmea.txt`.
792
1055
  escaped = true;
793
1056
  continue;
794
1057
  } else if (quote && chr === quote) {
795
1058
  quote = false;
1059
+ quoteClosed = true;
796
1060
  } else if (!quote && chr === '"') {
797
1061
  quote = chr;
798
- } else if (!quote && chr === ';') {
799
- if (key === false) {
800
- response.value = value.trim();
801
- } else {
802
- response.params[key] = value.trim();
1062
+ // whitespace before a quote that opens the value is padding, but
1063
+ // between a token and a quoted string it is content
1064
+ if (value.length) {
1065
+ value += pendingSpace;
803
1066
  }
1067
+ pendingSpace = '';
1068
+ } else if (!quote && chr === ';') {
1069
+ storeValue();
804
1070
  stage = 'key';
805
- value = '';
806
- } else {
807
- value += chr;
1071
+ } else if (!quote && (chr === ' ' || chr === '\t')) {
1072
+ pendingSpace += chr;
1073
+ } else if (!quoteClosed) {
1074
+ addChr(chr);
808
1075
  }
1076
+ // Anything else is trailing junk after a closed quoted string. RFC 2045
1077
+ // says a parameter value is a token or a quoted string, not both, and
1078
+ // appending the junk is how `boundary="AAA" (unterminated comment`
1079
+ // turned into a boundary that no delimiter in the message matches.
809
1080
  escaped = false;
810
1081
  break;
811
1082
  }
812
1083
  }
813
1084
 
814
1085
  // finalize remainder
815
- value = value.trim();
816
1086
  if (stage === 'value') {
817
- if (key === false) {
818
- // default value
819
- response.value = value;
820
- } else {
821
- // subkey value
822
- response.params[key] = value;
823
- }
824
- } else if (value) {
1087
+ storeValue();
1088
+ } else {
825
1089
  // treat as key without value, see emptykey:
826
1090
  // Header-Key: somevalue; key=value; emptykey
827
- response.params[value.toLowerCase()] = '';
1091
+ storeEmptyKey();
828
1092
  }
829
1093
 
830
1094
  if (response.value) {
@@ -841,6 +1105,11 @@ class MimeNode {
841
1105
  return (
842
1106
  str
843
1107
  .split(/\r?\n/)
1108
+ // remove whitespace stuffing before anything else
1109
+ // http://tools.ietf.org/html/rfc3676#section-4.4
1110
+ // doing it after the join leaves the stuffed space of a continuation line
1111
+ // sitting in the middle of the joined paragraph
1112
+ .map(line => (line.charAt(0) === ' ' ? line.slice(1) : line))
844
1113
  // remove soft linebreaks
845
1114
  // soft linebreaks are added after space symbols
846
1115
  .reduce((previousValue, currentValue) => {
@@ -856,9 +1125,6 @@ class MimeNode {
856
1125
  return previousValue + '\n' + currentValue;
857
1126
  }
858
1127
  })
859
- // remove whitespace stuffing
860
- // http://tools.ietf.org/html/rfc3676#section-4.4
861
- .replace(/^ /gm, '')
862
1128
  );
863
1129
  }
864
1130
 
@@ -877,27 +1143,38 @@ class MimeNode {
877
1143
  }
878
1144
 
879
1145
  processHeaders() {
880
- // First pass: merge folded headers (backward iteration)
881
- for (let i = this.headerLines.length - 1; i >= 0; i--) {
882
- let line = this.headerLines[i];
883
- if (i && /^\s/.test(line)) {
884
- this.headerLines[i - 1] += '\n' + line;
885
- this.headerLines.splice(i, 1);
1146
+ // First pass: group folded continuation lines with the header they belong to.
1147
+ //
1148
+ // Only SP and HTAB continue a header (RFC 5322 3.2.2 WSP). JS `\s` also matches
1149
+ // NBSP, vertical tab, form feed and U+2028, so a line starting with one of those
1150
+ // used to be absorbed into the header above it and disappear from both `headers`
1151
+ // and `headerLines` while a strict parser still sees it as a header of its own.
1152
+ //
1153
+ // Collecting into an array and joining once keeps this linear. Appending onto the
1154
+ // previous string in a backward pass re-scans the joined value on every line,
1155
+ // which is quadratic in the number of folds and lets a message that fits inside
1156
+ // maxHeadersSize burn seconds of CPU.
1157
+ let foldedLines = [];
1158
+ for (let line of this.headerLines) {
1159
+ if (foldedLines.length && /^[ \t]/.test(line)) {
1160
+ foldedLines[foldedLines.length - 1].push(line);
1161
+ } else {
1162
+ foldedLines.push([line]);
886
1163
  }
887
1164
  }
888
1165
 
889
1166
  // Initialize rawHeaderLines to store unmodified lines
890
1167
  this.rawHeaderLines = [];
891
1168
 
892
- // Second pass: process headers (MUST be backward to maintain this.headers order)
893
- // The existing code iterates backward and postal-mime.js calls .reverse()
894
- // We must preserve this behavior to avoid breaking changes
895
- for (let i = this.headerLines.length - 1; i >= 0; i--) {
896
- let rawLine = this.headerLines[i];
1169
+ let seenContentHeaders = new Set();
1170
+
1171
+ // Second pass: process headers in document order
1172
+ for (let parts of foldedLines) {
1173
+ let rawLine = parts.join('\n');
897
1174
 
898
1175
  // Extract key from raw line for rawHeaderLines
899
1176
  let sep = rawLine.indexOf(':');
900
- let rawKey = sep < 0 ? rawLine.trim() : rawLine.substr(0, sep).trim();
1177
+ let rawKey = trimWsp(sep < 0 ? rawLine : rawLine.substr(0, sep));
901
1178
 
902
1179
  // Store raw line with lowercase key
903
1180
  this.rawHeaderLines.push({
@@ -905,31 +1182,44 @@ class MimeNode {
905
1182
  line: rawLine
906
1183
  });
907
1184
 
908
- // Normalize for this.headers (existing behavior - order preserved)
909
- let normalizedLine = rawLine.replace(/\s+/g, ' ');
910
- sep = normalizedLine.indexOf(':');
911
- let key = sep < 0 ? normalizedLine.trim() : normalizedLine.substr(0, sep).trim();
912
- let value = sep < 0 ? '' : normalizedLine.substr(sep + 1).trim();
1185
+ // Unfolding removes the line break and keeps the folding whitespace, so
1186
+ // `Subject: Hello\r\n World` stays `Hello World`. Collapsing every
1187
+ // whitespace run instead also rewrote boundary values and filenames, and it
1188
+ // replaced the non-ASCII spaces that raw UTF-8 headers (RFC 6532) may carry.
1189
+ let unfoldedLine = parts.join('');
1190
+ sep = unfoldedLine.indexOf(':');
1191
+ let key = trimWsp(sep < 0 ? unfoldedLine : unfoldedLine.substr(0, sep));
1192
+ // A bare CR is not legal in a field body. It used to be folded into a space by
1193
+ // the whitespace collapse, and passing it through would hand consumers that
1194
+ // write the value back out a line of their own.
1195
+ let value = sep < 0 ? '' : trimWsp(unfoldedLine.substr(sep + 1).replace(/[\r\n]+/g, ' '));
913
1196
  this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
914
1197
 
915
- switch (key.toLowerCase()) {
916
- case 'content-type':
917
- if (this.contentType.default) {
1198
+ // A header that decides how the body is read must resolve the same way every
1199
+ // time it is duplicated, otherwise a message can present one Content-Type to a
1200
+ // scanner and a different one here. Every one of these takes the first
1201
+ // occurrence and later copies are ignored.
1202
+ const lowerKey = key.toLowerCase();
1203
+ if (CONTENT_HEADERS.has(lowerKey) && !seenContentHeaders.has(lowerKey)) {
1204
+ seenContentHeaders.add(lowerKey);
1205
+
1206
+ switch (lowerKey) {
1207
+ case 'content-type':
918
1208
  this.contentType = { value, parsed: {} };
919
- }
920
- break;
921
- case 'content-transfer-encoding':
922
- this.contentTransferEncoding = { value, parsed: {} };
923
- break;
924
- case 'content-disposition':
925
- this.contentDisposition = { value, parsed: {} };
926
- break;
927
- case 'content-id':
928
- this.contentId = value;
929
- break;
930
- case 'content-description':
931
- this.contentDescription = value;
932
- break;
1209
+ break;
1210
+ case 'content-transfer-encoding':
1211
+ this.contentTransferEncoding = { value, parsed: {} };
1212
+ break;
1213
+ case 'content-disposition':
1214
+ this.contentDisposition = { value, parsed: {} };
1215
+ break;
1216
+ case 'content-id':
1217
+ this.contentId = value;
1218
+ break;
1219
+ case 'content-description':
1220
+ this.contentDescription = value;
1221
+ break;
1222
+ }
933
1223
  }
934
1224
  }
935
1225
 
@@ -948,10 +1238,13 @@ class MimeNode {
948
1238
 
949
1239
  this.contentDisposition.parsed = this.parseStructuredHeader(this.contentDisposition.value);
950
1240
 
951
- this.contentTransferEncoding.encoding = this.contentTransferEncoding.value
1241
+ // Take the first token rather than splitting on the first non-token character.
1242
+ // `split()` returns an empty string for anything that does not start with a word
1243
+ // character, so `(comment) base64` and `"base64"` used to fall through to the
1244
+ // pass-through decoder and hand the caller undecoded base64 as the message body.
1245
+ this.contentTransferEncoding.encoding = (this.stripComments(this.contentTransferEncoding.value)
952
1246
  .toLowerCase()
953
- .split(/[^\w-]/)
954
- .shift();
1247
+ .match(/[\w-]+/) || [''])[0];
955
1248
 
956
1249
  this.setupContentDecoder(this.contentTransferEncoding.encoding);
957
1250
  }
@@ -964,14 +1257,17 @@ class MimeNode {
964
1257
  return this.processHeaders();
965
1258
  }
966
1259
 
967
- this.headerSize += line.length;
1260
+ // Counted across the whole message, not per part. A per-node budget lets a
1261
+ // multipart carry the limit again for every part it declares, so a message
1262
+ // many times over the limit still parses.
1263
+ this.postalMime.headerSize += line.length;
968
1264
 
969
- if (this.headerSize > this.options.maxHeadersSize) {
1265
+ if (this.postalMime.headerSize > this.options.maxHeadersSize) {
970
1266
  let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
971
1267
  throw error;
972
1268
  }
973
1269
 
974
- this.headerLines.push(defaultDecoder.decode(line));
1270
+ this.headerLines.push(headerDecoder.decode(line));
975
1271
  break;
976
1272
  case 'body': {
977
1273
  // add line to body
@@ -3311,6 +3607,30 @@ function htmlToText(str) {
3311
3607
  return str;
3312
3608
  }
3313
3609
 
3610
+ // A message date is not always a date. PostalMime keeps the raw header value when it does
3611
+ // not parse, and Intl.DateTimeFormat throws a RangeError on that, which used to reject the
3612
+ // whole parse of any message carrying a forwarded copy with a broken Date header.
3613
+ function formatDate(date) {
3614
+ if (typeof Intl === 'undefined') {
3615
+ return date;
3616
+ }
3617
+
3618
+ const parsed = new Date(date);
3619
+ if (isNaN(parsed.getTime())) {
3620
+ return date;
3621
+ }
3622
+
3623
+ return new Intl.DateTimeFormat('default', {
3624
+ year: 'numeric',
3625
+ month: 'numeric',
3626
+ day: 'numeric',
3627
+ hour: 'numeric',
3628
+ minute: 'numeric',
3629
+ second: 'numeric',
3630
+ hour12: false
3631
+ }).format(parsed);
3632
+ }
3633
+
3314
3634
  function formatTextAddress(address) {
3315
3635
  return []
3316
3636
  .concat(address.name || [])
@@ -3416,7 +3736,9 @@ function formatTextHeader(message) {
3416
3736
  let rows = [];
3417
3737
 
3418
3738
  if (message.from) {
3419
- rows.push({ key: 'From', val: formatTextAddress(message.from) });
3739
+ // through the plural formatter, because `From:` may hold RFC 5322 group syntax
3740
+ // and a group has no address of its own
3741
+ rows.push({ key: 'From', val: formatTextAddresses([message.from]) });
3420
3742
  }
3421
3743
 
3422
3744
  if (message.subject) {
@@ -3424,22 +3746,7 @@ function formatTextHeader(message) {
3424
3746
  }
3425
3747
 
3426
3748
  if (message.date) {
3427
- let dateOptions = {
3428
- year: 'numeric',
3429
- month: 'numeric',
3430
- day: 'numeric',
3431
- hour: 'numeric',
3432
- minute: 'numeric',
3433
- second: 'numeric',
3434
- hour12: false
3435
- };
3436
-
3437
- let dateStr =
3438
- typeof Intl === 'undefined'
3439
- ? message.date
3440
- : new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
3441
-
3442
- rows.push({ key: 'Date', val: dateStr });
3749
+ rows.push({ key: 'Date', val: formatDate(message.date) });
3443
3750
  }
3444
3751
 
3445
3752
  if (message.to && message.to.length) {
@@ -3506,7 +3813,9 @@ function formatHtmlHeader(message) {
3506
3813
 
3507
3814
  if (message.from) {
3508
3815
  rows.push(
3509
- `<div class="postal-email-header-key">From</div><div class="postal-email-header-value">${formatHtmlAddress(message.from)}</div>`
3816
+ // through the plural formatter, because `From:` may hold RFC 5322 group syntax
3817
+ // and a group has no address of its own
3818
+ `<div class="postal-email-header-key">From</div><div class="postal-email-header-value">${formatHtmlAddresses([message.from])}</div>`
3510
3819
  );
3511
3820
  }
3512
3821
 
@@ -3519,25 +3828,10 @@ function formatHtmlHeader(message) {
3519
3828
  }
3520
3829
 
3521
3830
  if (message.date) {
3522
- let dateOptions = {
3523
- year: 'numeric',
3524
- month: 'numeric',
3525
- day: 'numeric',
3526
- hour: 'numeric',
3527
- minute: 'numeric',
3528
- second: 'numeric',
3529
- hour12: false
3530
- };
3531
-
3532
- let dateStr =
3533
- typeof Intl === 'undefined'
3534
- ? message.date
3535
- : new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
3536
-
3537
3831
  rows.push(
3538
3832
  `<div class="postal-email-header-key">Date</div><div class="postal-email-header-value postal-email-header-date" data-date="${escapeHtml(
3539
3833
  message.date
3540
- )}">${escapeHtml(dateStr)}</div>`
3834
+ )}">${escapeHtml(formatDate(message.date))}</div>`
3541
3835
  );
3542
3836
  }
3543
3837
 
@@ -3566,6 +3860,56 @@ function formatHtmlHeader(message) {
3566
3860
  return template;
3567
3861
  }
3568
3862
 
3863
+ const WORD_CHAR_REGEX = /\w/;
3864
+ const NON_SPACE_TOKEN_REGEX = /[^\s]+/g;
3865
+
3866
+ /**
3867
+ * Finds the first address looking token in a run of text.
3868
+ *
3869
+ * This replaces a `\s*\b[^@\s]+@[^\s]+\b\s*` scan over the whole string, which backtracks
3870
+ * quadratically: the leading `\s*` makes every position inside a whitespace run a viable
3871
+ * start, and `[^@\s]+` then gives back one character at a time looking for an '@'. A
3872
+ * single header well inside the default size limit could hold a core busy for minutes.
3873
+ *
3874
+ * Scanning whitespace delimited tokens instead is linear and keeps the word boundary
3875
+ * semantics of the regex: the local part has to open on a word character and the domain
3876
+ * has to end on one.
3877
+ *
3878
+ * @param {String} text Text to search
3879
+ * @return {Object|null} `{index, length, value}` of the address, or null if there is none
3880
+ */
3881
+ function findAddressInText(text) {
3882
+ NON_SPACE_TOKEN_REGEX.lastIndex = 0;
3883
+
3884
+ let match;
3885
+ while ((match = NON_SPACE_TOKEN_REGEX.exec(text))) {
3886
+ const token = match[0];
3887
+ const at = token.indexOf('@');
3888
+
3889
+ // `\b[^@\s]+@` needs at least one character before the '@'
3890
+ let start = 0;
3891
+ while (start < at && !WORD_CHAR_REGEX.test(token.charAt(start))) {
3892
+ start++;
3893
+ }
3894
+ if (start >= at) {
3895
+ continue;
3896
+ }
3897
+
3898
+ // `[^\s]+\b` needs at least one character after the '@', ending on a word character
3899
+ let end = token.length;
3900
+ while (end > at + 1 && !WORD_CHAR_REGEX.test(token.charAt(end - 1))) {
3901
+ end--;
3902
+ }
3903
+ if (end <= at + 1) {
3904
+ continue;
3905
+ }
3906
+
3907
+ return { index: match.index + start, length: end - start, value: token.substring(start, end) };
3908
+ }
3909
+
3910
+ return null;
3911
+ }
3912
+
3569
3913
  /**
3570
3914
  * Converts tokens for a single address into an address object
3571
3915
  *
@@ -3684,25 +4028,24 @@ function _handleAddress(tokens, depth) {
3684
4028
  }
3685
4029
  }
3686
4030
 
3687
- let _regexHandler = function (address) {
3688
- if (!data.address.length) {
3689
- data.address = [address.trim()];
3690
- return ' ';
3691
- } else {
3692
- return address;
3693
- }
3694
- };
3695
-
3696
4031
  // still no address
3697
4032
  if (!data.address.length) {
3698
4033
  for (i = data.text.length - 1; i >= 0; i--) {
3699
4034
  // Security fix: Do not extract email addresses from quoted strings
3700
4035
  if (!data.textWasQuoted[i]) {
3701
- // fixed the regex to parse email address correctly when email address has more than one @
3702
- data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
3703
- if (data.address.length) {
4036
+ // handles an email address that has more than one @
4037
+ const found = findAddressInText(data.text[i]);
4038
+ if (found) {
4039
+ data.address = [found.value];
4040
+ // the address and the whitespace around it collapse to one space
4041
+ data.text[i] = (
4042
+ data.text[i].substring(0, found.index).replace(/\s+$/, '') +
4043
+ ' ' +
4044
+ data.text[i].substring(found.index + found.length).replace(/^\s+/, '')
4045
+ ).trim();
3704
4046
  break;
3705
4047
  }
4048
+ data.text[i] = data.text[i].trim();
3706
4049
  }
3707
4050
  }
3708
4051
  }
@@ -3723,7 +4066,10 @@ function _handleAddress(tokens, depth) {
3723
4066
  data.text = data.text.join(' ');
3724
4067
  data.address = data.address.join(' ');
3725
4068
 
3726
- if (!data.address && /^=\?[^=]+?=$/.test(data.text.trim())) {
4069
+ // `^=\?[^=]+?=$` could not match a base64 word whose padding puts an '=' inside it,
4070
+ // so whether a bare encoded word was decoded or left to become the address itself
4071
+ // came down to whether its payload happened to need padding.
4072
+ if (!data.address && isEncodedWordsOnly(data.text.trim())) {
3727
4073
  // try to extract words from text content
3728
4074
  const decodedText = decodeWords(data.text);
3729
4075
  // Security: only re-parse if decoded text contains angle-bracket addresses.
@@ -4087,6 +4433,9 @@ class PostalMime {
4087
4433
  });
4088
4434
  this.boundaries = [];
4089
4435
 
4436
+ // Header bytes seen across every part of this message, see MimeNode.feed
4437
+ this.headerSize = 0;
4438
+
4090
4439
  this.textContent = {};
4091
4440
  this.attachments = [];
4092
4441
 
@@ -4316,7 +4665,9 @@ class PostalMime {
4316
4665
  }
4317
4666
 
4318
4667
  if (node.contentDescription) {
4319
- attachment.description = node.contentDescription;
4668
+ // decoded like filename, it is an unstructured header that may
4669
+ // carry encoded words
4670
+ attachment.description = decodeWords(node.contentDescription);
4320
4671
  }
4321
4672
 
4322
4673
  if (node.contentId) {
@@ -4536,9 +4887,12 @@ class PostalMime {
4536
4887
  buf = await blobToArrayBuffer(buf);
4537
4888
  }
4538
4889
 
4539
- // Cast Node.js Buffer object or Uint8Array into ArrayBuffer
4540
- if (buf.buffer instanceof ArrayBuffer) {
4541
- buf = new Uint8Array(buf).buffer;
4890
+ // Cast a Node.js Buffer, a typed array or a DataView into an ArrayBuffer.
4891
+ // `new Uint8Array(view)` only works for array-likes, so a DataView produced an
4892
+ // empty buffer and the message parsed to nothing without an error. Slicing off
4893
+ // byteOffset also keeps views over a larger buffer from reading their neighbours.
4894
+ if (ArrayBuffer.isView(buf)) {
4895
+ buf = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
4542
4896
  }
4543
4897
 
4544
4898
  this.buf = buf;
@@ -4555,9 +4909,11 @@ class PostalMime {
4555
4909
  await this.processNodeTree();
4556
4910
 
4557
4911
  const message = {
4558
- headers: this.root.headers
4559
- .map(entry => ({ key: entry.key, originalKey: entry.originalKey, value: entry.value }))
4560
- .reverse()
4912
+ headers: this.root.headers.map(entry => ({
4913
+ key: entry.key,
4914
+ originalKey: entry.originalKey,
4915
+ value: entry.value
4916
+ }))
4561
4917
  };
4562
4918
 
4563
4919
  for (const key of ['from', 'sender']) {
@@ -4626,8 +4982,8 @@ class PostalMime {
4626
4982
 
4627
4983
  message.attachments = this.attachments;
4628
4984
 
4629
- // Expose raw header lines (reversed to match headers array order)
4630
- message.headerLines = (this.root.rawHeaderLines || []).slice().reverse();
4985
+ // Expose raw header lines, in the same order as the headers array
4986
+ message.headerLines = (this.root.rawHeaderLines || []).slice();
4631
4987
 
4632
4988
  switch (this.attachmentEncoding) {
4633
4989
  case 'arraybuffer':