@zone-eu/mailsplit 5.4.27 → 5.4.28

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.
@@ -20,9 +20,17 @@ const MAX_PENDING_LINE_SIZE = 64 * 1024;
20
20
  // how many separate writes the pending line may be kept in before it is compacted
21
21
  const MAX_PENDING_LINE_CHUNKS = 1024;
22
22
 
23
+ // The most SP and HTAB a delimiter line may carry between the boundary value, or its closing
24
+ // "--", and the line ending. RFC 2046 5.1.1 allows the whitespace, and Python and postal-mime
25
+ // accept any amount of it, so a line that ends in a few spaces delimits the same parts here
26
+ // as there. The line still has to fit a header line (RFC 5322 2.1.1), so the pending line
27
+ // kept as a possible delimiter stays bounded
28
+ const BOUNDARY_LINE_TRAILING_WSP = 998;
29
+
23
30
  // what a delimiter line may carry after the boundary value: the "--" prefix, an optional
24
- // "--" suffix and the line terminator. This is the bound compareBoundary() accepts.
25
- const BOUNDARY_LINE_SUFFIX = 2 /* "--" prefix */ + 2 /* "--" suffix */ + 2; /* trailing <CR><LF> */
31
+ // "--" suffix, the trailing whitespace and the line terminator. This is the bound
32
+ // compareBoundary() accepts.
33
+ const BOUNDARY_LINE_SUFFIX = 2 /* "--" prefix */ + 2 /* "--" suffix */ + BOUNDARY_LINE_TRAILING_WSP + 2; /* trailing <CR><LF> */
26
34
 
27
35
  // checkBoundary() additionally allows a line ending in front of the delimiter, so this is
28
36
  // the longest a delimiter line can ever be once the boundary value is subtracted
@@ -389,7 +397,8 @@ class MessageSplitter extends Transform {
389
397
  * @returns {1 | 2 | false}
390
398
  */
391
399
  compareBoundary(line, startpos, boundary) {
392
- // --{boundary}\r\n or --{boundary}--\r\n
400
+ // --{boundary}\r\n or --{boundary}--\r\n, with SP and HTAB allowed in front of the
401
+ // line ending (RFC 2046 5.1.1), where Python and postal-mime accept them as well
393
402
  if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + BOUNDARY_LINE_SUFFIX + startpos) {
394
403
  return false;
395
404
  }
@@ -399,34 +408,37 @@ class MessageSplitter extends Transform {
399
408
  }
400
409
  }
401
410
 
402
- let pos = 0;
403
- for (let i = boundary.length + 2 + startpos; i < line.length; i++) {
404
- let c = line[i];
405
- if (pos === 0 && (c === 0x0d || c === 0x0a)) {
406
- // 1: next node
407
- return 1;
408
- }
409
- if (pos === 0 && c !== 0x2d) {
410
- // expecting "-"
411
- return false;
412
- }
413
- if (pos === 1 && c !== 0x2d) {
414
- // expecting "-"
415
- return false;
416
- }
417
- if (pos === 2 && c !== 0x0d && c !== 0x0a) {
418
- // expecting line terminator, either <CR> or <LF>
411
+ let i = boundary.length + 2 + startpos;
412
+ // 1: next node, 2: multipart end
413
+ let result = /** @type {1 | 2} */ (1);
414
+ if (line[i] === 0x2d) {
415
+ if (line[i + 1] !== 0x2d) {
416
+ // expecting "--"
419
417
  return false;
420
418
  }
421
- if (pos === 3 && c !== 0x0a) {
422
- // expecting line terminator <LF>
423
- return false;
424
- }
425
- pos++;
419
+ result = 2;
420
+ i += 2;
421
+ }
422
+
423
+ while (i < line.length && (line[i] === 0x20 || line[i] === 0x09)) {
424
+ i++;
426
425
  }
427
426
 
428
- // 2: multipart end
429
- return 2;
427
+ if (i === line.length) {
428
+ // only the closing delimiter may end the input without a line ending
429
+ return result === 2 ? 2 : false;
430
+ }
431
+
432
+ // expecting the line terminator and nothing after it: <CR><LF>, <CR> or <LF>, where
433
+ // the <CR> may be a run of them, the artifact of a second CR to CRLF conversion,
434
+ // which postal-mime reads as the line ending as well
435
+ while (i < line.length && line[i] === 0x0d) {
436
+ i++;
437
+ }
438
+ if (i < line.length && line[i] === 0x0a) {
439
+ i++;
440
+ }
441
+ return i === line.length ? result : false;
430
442
  }
431
443
 
432
444
  /**
@@ -46,7 +46,7 @@ declare class MimeNode implements MimeNodeShape {
46
46
  /** Decoded filename from Content-Disposition or Content-Type parameters, or `false` when absent. */
47
47
  filename: string | false;
48
48
 
49
- /** Whether this node is `text/*` with `format=flowed`. */
49
+ /** Whether this node is `text/plain` with `format=flowed`. */
50
50
  flowed: boolean;
51
51
 
52
52
  /** Whether flowed text uses `delsp=yes`. */
package/lib/mime-node.js CHANGED
@@ -27,6 +27,9 @@ const HEADER_LINE_MIN_COST = 64;
27
27
  * `value.replace(/\(.*\)/g, '')`, but in linear time. The regex backtracks quadratically
28
28
  * on a value with many `(` characters and no closing `)`.
29
29
  *
30
+ * Used for the Content-Transfer-Encoding only. The MIME headers with parameters go through
31
+ * libmime's parseHeaderValue with stripComments, which knows where a comment may stand.
32
+ *
30
33
  * @param {string} value
31
34
  * @returns {string}
32
35
  */
@@ -216,8 +219,14 @@ class MimeNode {
216
219
  this._headersLines = [];
217
220
  let headers = this.headers;
218
221
 
222
+ // The comments of the MIME headers are dropped, so that `boundary=b (comment)` names
223
+ // the boundary `b` that postal-mime and Python's email.policy.default split by. Read as
224
+ // content, it matched no delimiter line and the parts both of them find stayed hidden
225
+ // here. Python's legacy compat32 policy, the default of message_from_bytes() without a
226
+ // policy, keeps the comment and splits by `--b (comment)` lines instead: a split
227
+ // against it is the price of agreeing with the two readers a renderer is built on
219
228
  let dispositionHeader = headers.getFirst('Content-Disposition');
220
- this._parsedContentDisposition = this.libmime.parseHeaderValue(dispositionHeader);
229
+ this._parsedContentDisposition = this.libmime.parseHeaderValue(dispositionHeader, { stripComments: true });
221
230
  let parsedContentDisposition = /** @type {StructuredHeader} */ (this._parsedContentDisposition);
222
231
 
223
232
  // if content-type is missing default to plaintext
@@ -240,9 +249,12 @@ class MimeNode {
240
249
  }
241
250
  }
242
251
 
243
- this._parsedContentType = this.libmime.parseHeaderValue(contentHeader);
252
+ this._parsedContentType = this.libmime.parseHeaderValue(contentHeader, { stripComments: true });
244
253
  let parsedContentType = /** @type {StructuredHeader} */ (this._parsedContentType);
245
254
 
255
+ // The encoding is a bare token, and one written in quotes stays quoted, so that
256
+ // "base64" is not decoded, as Python and postal-mime do not decode it. parseHeaderValue
257
+ // would unquote it, so the transfer encoding keeps its own comment stripper
246
258
  this.encoding = stripComments(headers.getFirst('Content-Transfer-Encoding')).toLowerCase().trim();
247
259
  this.contentType = (parsedContentType.value || '').toLowerCase().trim() || false;
248
260
  this.charset = parsedContentType.params.charset || false;
@@ -259,7 +271,11 @@ class MimeNode {
259
271
 
260
272
  this.filename = parsedContentDisposition.params.filename || parsedContentType.params.name || false;
261
273
 
262
- if (parsedContentType.params.format && parsedContentType.params.format.toLowerCase().trim() === 'flowed') {
274
+ // format=flowed is defined for text/plain (RFC 3676), and a browser never unfolds an
275
+ // html part. Flagged on any text part, a text/html part with delsp=yes had its
276
+ // `<scri \r\npt>` joined into a script tag by the consumer that unfolded it, a tag
277
+ // neither Python nor a browser given the raw bytes sees
278
+ if (this.contentType === 'text/plain' && parsedContentType.params.format && parsedContentType.params.format.toLowerCase().trim() === 'flowed') {
263
279
  this.flowed = true;
264
280
  if (parsedContentType.params.delsp && parsedContentType.params.delsp.toLowerCase().trim() === 'yes') {
265
281
  this.delSp = true;
@@ -285,7 +301,16 @@ class MimeNode {
285
301
  this.contentType.substr(0, this.contentType.indexOf('/')) === 'multipart' &&
286
302
  this.contentType.substr(this.contentType.indexOf('/') + 1)) ||
287
303
  false;
288
- this._boundary = (parsedContentType.params.boundary && Buffer.from(parsedContentType.params.boundary)) || false;
304
+ // A boundary is ASCII and does not end in whitespace (RFC 2046 5.1.1, bchars), and
305
+ // Python matches no line against one that is not. One holding a non-ASCII character
306
+ // came out of a header read as latin1 or as UTF-8, and matched under `--ab\xC3\xA9cd`
307
+ // lines parts that a scanner built on Python never saw. Such a boundary counts as
308
+ // none, as a boundary parameter that is missing does. Trailing whitespace is dropped
309
+ // as Python drops it, a delimiter line may end in whitespace anyway
310
+ let boundary = (parsedContentType.params.boundary || '').trimEnd();
311
+ // a non-ASCII character takes more than one byte in UTF-8
312
+ let ascii = Buffer.byteLength(boundary) === boundary.length;
313
+ this._boundary = boundary && ascii ? Buffer.from(boundary) : false;
289
314
 
290
315
  this.rfc822 = this.contentType === 'message/rfc822';
291
316
 
package/lib/types.d.ts CHANGED
@@ -107,7 +107,7 @@ export interface MimeNode {
107
107
  /** Decoded filename from Content-Disposition or Content-Type parameters, or `false` when absent. */
108
108
  filename: string | false;
109
109
 
110
- /** Whether this node is `text/*` with `format=flowed`. */
110
+ /** Whether this node is `text/plain` with `format=flowed`. */
111
111
  flowed: boolean;
112
112
 
113
113
  /** Whether flowed text uses `delsp=yes`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zone-eu/mailsplit",
3
- "version": "5.4.27",
3
+ "version": "5.4.28",
4
4
  "description": "Split email messages into an object stream",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "license": "(MIT OR EUPL-1.1+)",
26
26
  "dependencies": {
27
27
  "libbase64": "1.4.1",
28
- "libmime": "5.5.2",
28
+ "libmime": "5.6.3",
29
29
  "libqp": "3.0.1"
30
30
  },
31
31
  "devDependencies": {