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