@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.
- package/CHANGELOG.md +29 -0
- package/dist/cjs/lime-elements.cjs.js +1 -1
- package/dist/cjs/limel-action-bar_3.cjs.entry.js +1 -1
- package/dist/cjs/limel-ai-avatar.cjs.entry.js +1 -1
- package/dist/cjs/limel-callout.cjs.entry.js +1 -1
- package/dist/cjs/limel-chart.cjs.entry.js +1 -1
- package/dist/cjs/limel-chip-set.cjs.entry.js +1 -1
- package/dist/cjs/limel-chip_2.cjs.entry.js +1 -1
- package/dist/cjs/limel-code-diff.cjs.entry.js +1 -1
- package/dist/cjs/limel-code-editor.cjs.entry.js +1 -1
- package/dist/cjs/limel-collapsible-section.cjs.entry.js +1 -1
- package/dist/cjs/limel-drag-handle.cjs.entry.js +1 -1
- package/dist/cjs/limel-email-viewer.cjs.entry.js +1 -1
- package/dist/cjs/limel-file-viewer.cjs.entry.js +701 -345
- package/dist/cjs/limel-file.cjs.entry.js +1 -1
- package/dist/cjs/limel-flatpickr-adapter.cjs.entry.js +1 -1
- package/dist/cjs/limel-form.cjs.entry.js +17 -43
- package/dist/cjs/limel-list-item.cjs.entry.js +1 -1
- package/dist/cjs/limel-picker.cjs.entry.js +1 -1
- package/dist/cjs/limel-profile-picture.cjs.entry.js +1 -1
- package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +40 -40
- package/dist/cjs/limel-slider.cjs.entry.js +140 -16
- package/dist/cjs/limel-snackbar.cjs.entry.js +1 -1
- package/dist/cjs/limel-table.cjs.entry.js +1 -1
- package/dist/cjs/limel-text-editor-link-menu.cjs.entry.js +1 -1
- package/dist/cjs/loader.cjs.js +1 -1
- package/dist/cjs/{translations-Bnw00bkf.js → translations-Do_D3pxu.js} +39 -2
- package/dist/collection/components/slider/slider.css +95 -0
- package/dist/collection/components/slider/slider.js +174 -21
- package/dist/collection/global/translations.js +15 -2
- package/dist/collection/translations/da.js +3 -0
- package/dist/collection/translations/de.js +3 -0
- package/dist/collection/translations/en.js +3 -0
- package/dist/collection/translations/fi.js +3 -0
- package/dist/collection/translations/fr.js +3 -0
- package/dist/collection/translations/nl.js +3 -0
- package/dist/collection/translations/no.js +3 -0
- package/dist/collection/translations/sv.js +3 -0
- package/dist/esm/lime-elements.js +1 -1
- package/dist/esm/limel-action-bar_3.entry.js +1 -1
- package/dist/esm/limel-ai-avatar.entry.js +1 -1
- package/dist/esm/limel-callout.entry.js +1 -1
- package/dist/esm/limel-chart.entry.js +1 -1
- package/dist/esm/limel-chip-set.entry.js +1 -1
- package/dist/esm/limel-chip_2.entry.js +1 -1
- package/dist/esm/limel-code-diff.entry.js +1 -1
- package/dist/esm/limel-code-editor.entry.js +1 -1
- package/dist/esm/limel-collapsible-section.entry.js +1 -1
- package/dist/esm/limel-drag-handle.entry.js +1 -1
- package/dist/esm/limel-email-viewer.entry.js +1 -1
- package/dist/esm/limel-file-viewer.entry.js +701 -345
- package/dist/esm/limel-file.entry.js +1 -1
- package/dist/esm/limel-flatpickr-adapter.entry.js +1 -1
- package/dist/esm/limel-form.entry.js +17 -43
- package/dist/esm/limel-list-item.entry.js +1 -1
- package/dist/esm/limel-picker.entry.js +1 -1
- package/dist/esm/limel-profile-picture.entry.js +1 -1
- package/dist/esm/limel-prosemirror-adapter.entry.js +40 -40
- package/dist/esm/limel-slider.entry.js +140 -16
- package/dist/esm/limel-snackbar.entry.js +1 -1
- package/dist/esm/limel-table.entry.js +1 -1
- package/dist/esm/limel-text-editor-link-menu.entry.js +1 -1
- package/dist/esm/loader.js +1 -1
- package/dist/esm/{translations-Cb9R_zmF.js → translations-DgqE77nk.js} +39 -2
- package/dist/lime-elements/lime-elements.esm.js +1 -1
- package/dist/lime-elements/{p-421612f9.entry.js → p-21b54196.entry.js} +4 -4
- package/dist/lime-elements/p-3e316685.entry.js +1 -0
- package/dist/lime-elements/{p-c8116a01.entry.js → p-565f688a.entry.js} +1 -1
- package/dist/lime-elements/{p-aa080a8f.entry.js → p-5dde5d72.entry.js} +1 -1
- package/dist/lime-elements/{p-31713caf.entry.js → p-5e432a44.entry.js} +1 -1
- package/dist/lime-elements/{p-4a09fb2e.entry.js → p-62a25deb.entry.js} +1 -1
- package/dist/lime-elements/{p-8aa4a38e.entry.js → p-72a1d432.entry.js} +1 -1
- package/dist/lime-elements/{p-9a04a686.entry.js → p-83bdbaf7.entry.js} +1 -1
- package/dist/lime-elements/{p-ca0b86ee.entry.js → p-8c3798ee.entry.js} +1 -1
- package/dist/lime-elements/{p-8aeeeb53.entry.js → p-8cf79fa1.entry.js} +1 -1
- package/dist/lime-elements/{p-7487d65d.entry.js → p-9ed9d0de.entry.js} +1 -1
- package/dist/lime-elements/p-DgqE77nk.js +1 -0
- package/dist/lime-elements/{p-2284caa7.entry.js → p-a6500046.entry.js} +1 -1
- package/dist/lime-elements/{p-e545c9e8.entry.js → p-b04c6602.entry.js} +1 -1
- package/dist/lime-elements/{p-d97df2cd.entry.js → p-b4b43404.entry.js} +1 -1
- package/dist/lime-elements/p-b6528083.entry.js +1 -0
- package/dist/lime-elements/{p-6722e6e2.entry.js → p-cafebc7d.entry.js} +1 -1
- package/dist/lime-elements/{p-2f223e46.entry.js → p-cdcd9880.entry.js} +1 -1
- package/dist/lime-elements/{p-2e5d5e79.entry.js → p-dd116bb1.entry.js} +1 -1
- package/dist/lime-elements/{p-d7bb4310.entry.js → p-dda63b8b.entry.js} +1 -1
- package/dist/lime-elements/{p-7f7e2180.entry.js → p-e8783848.entry.js} +1 -1
- package/dist/lime-elements/{p-19c81ded.entry.js → p-eeddce38.entry.js} +1 -1
- package/dist/lime-elements/{p-9ae7d11b.entry.js → p-f53d8bda.entry.js} +1 -1
- package/dist/lime-elements/{p-03fd2181.entry.js → p-fa4edcf5.entry.js} +1 -1
- package/dist/types/components/slider/slider.d.ts +67 -4
- package/dist/types/components.d.ts +25 -10
- package/dist/types/translations/da.d.ts +3 -0
- package/dist/types/translations/de.d.ts +3 -0
- package/dist/types/translations/en.d.ts +3 -0
- package/dist/types/translations/fi.d.ts +3 -0
- package/dist/types/translations/fr.d.ts +3 -0
- package/dist/types/translations/nl.d.ts +3 -0
- package/dist/types/translations/no.d.ts +3 -0
- package/dist/types/translations/sv.d.ts +3 -0
- package/package.json +9 -9
- package/dist/lime-elements/p-Cb9R_zmF.js +0 -1
- package/dist/lime-elements/p-ee58f0b4.entry.js +0 -1
- 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-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
let
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
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
|
-
|
|
176
|
-
['
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
+
return new TextDecoder(charset);
|
|
187
241
|
} catch (err) {
|
|
188
|
-
|
|
242
|
+
return null;
|
|
189
243
|
}
|
|
244
|
+
}
|
|
190
245
|
|
|
191
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
let
|
|
263
|
-
if (
|
|
264
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
this.remainder += str;
|
|
628
|
+
pushChunk(base64Str) {
|
|
629
|
+
if (base64Str.length) {
|
|
630
|
+
this.chunks.push(decodeBase64(base64Str));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
459
633
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
634
|
+
flushRemainder() {
|
|
635
|
+
this.pushChunk(this.remainder);
|
|
636
|
+
this.remainder = '';
|
|
637
|
+
}
|
|
463
638
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
const
|
|
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(
|
|
495
|
-
opts = opts || {};
|
|
496
|
-
|
|
497
|
-
this.decoder = opts.decoder || new TextDecoder();
|
|
498
|
-
|
|
672
|
+
constructor() {
|
|
499
673
|
this.maxChunkSize = 100 * 1024;
|
|
500
674
|
|
|
501
|
-
this.
|
|
675
|
+
this.buffer = new Uint8Array(this.maxChunkSize);
|
|
676
|
+
this.bufferPos = 0;
|
|
502
677
|
|
|
503
678
|
this.chunks = [];
|
|
504
679
|
}
|
|
505
680
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
685
|
+
this.buffer[this.bufferPos++] = byte;
|
|
513
686
|
}
|
|
514
687
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
567
|
-
this.
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
if (
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
742
|
+
if (!softBreak) {
|
|
743
|
+
this.writeByte(CHR_LF);
|
|
744
|
+
}
|
|
595
745
|
}
|
|
596
746
|
|
|
597
747
|
finalize() {
|
|
598
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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 =
|
|
1036
|
+
key = takeValue().trim().toLowerCase();
|
|
780
1037
|
stage = 'value';
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
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:
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
//
|
|
893
|
-
for (let
|
|
894
|
-
let rawLine =
|
|
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
|
|
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
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
let
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
3700
|
-
|
|
3701
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4538
|
-
|
|
4539
|
-
|
|
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
|
-
|
|
4558
|
-
.
|
|
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
|
|
4628
|
-
message.headerLines = (this.root.rawHeaderLines || []).slice()
|
|
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':
|