@zone-eu/mailsplit 5.4.14 → 5.4.15

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/lib/headers.js CHANGED
@@ -130,7 +130,8 @@ class Headers {
130
130
  }
131
131
 
132
132
  value = value.toString('binary');
133
- this.addFormatted(key, this.libmime.foldLines(key + ': ' + value.replace(/\r?\n/g, ''), 76, false), index);
133
+ // a header value may not contain line breaks of its own, folding is added by foldLines
134
+ this.addFormatted(key, this.libmime.foldLines(key + ': ' + value.replace(/[\r\n]/g, ''), 76, false), index);
134
135
  }
135
136
 
136
137
  /**
@@ -155,6 +156,13 @@ class Headers {
155
156
  line = line.toString('binary');
156
157
  }
157
158
 
159
+ // every header insertion runs through here, so this is where a value or a key
160
+ // built from untrusted input is stopped from injecting an extra header line
161
+ line = this._normalizeInsertedLine(line);
162
+ if (!line) {
163
+ return;
164
+ }
165
+
158
166
  let header = {
159
167
  key: this._normalizeHeader(key),
160
168
  line
@@ -227,7 +235,10 @@ class Headers {
227
235
  }
228
236
 
229
237
  /**
230
- * @param {string | false} [lineEnd]
238
+ * Serializes the headers. Unmodified headers are returned byte for byte as they
239
+ * were received, otherwise every line is rebuilt with `lineEnd` line endings.
240
+ *
241
+ * @param {string | false} [lineEnd] Line ending to use, defaults to CRLF.
231
242
  * @returns {Buffer}
232
243
  */
233
244
  build(lineEnd) {
@@ -240,26 +251,30 @@ class Headers {
240
251
  }
241
252
  let lines = this._getLines();
242
253
 
243
- lineEnd = lineEnd || '\r\n';
254
+ const ending = lineEnd || '\r\n';
244
255
 
245
256
  let headers = lines
246
- .map(line => this._buildHeaderLine(line.line.replace(/\r?\n/g, lineEnd)))
257
+ .map(line => this._normalizeLineBreaks(line.line, ending))
258
+ // an empty line would close the header block and demote every later header
259
+ // into the body, so a line left with nothing in it is dropped instead
260
+ .filter(line => line !== '')
261
+ .map(line => this._buildHeaderLine(line))
247
262
  .reduce((joined, line, idx) => {
248
263
  if (idx) {
249
- joined.push(Buffer.from(lineEnd, 'binary'));
264
+ joined.push(Buffer.from(ending, 'binary'));
250
265
  }
251
266
  joined.push(line);
252
267
  return joined;
253
268
  }, /** @type {Buffer[]} */ ([]));
254
269
 
255
- headers.push(Buffer.from(lineEnd + lineEnd, 'binary'));
270
+ headers.push(Buffer.from(ending + ending, 'binary'));
256
271
 
257
272
  if (this.mbox) {
258
- headers.unshift(Buffer.from(this.mbox + lineEnd, 'binary'));
273
+ headers.unshift(Buffer.from(this.mbox + ending, 'binary'));
259
274
  }
260
275
 
261
276
  if (this.http) {
262
- headers.unshift(Buffer.from(this.http + lineEnd, 'binary'));
277
+ headers.unshift(Buffer.from(this.http + ending, 'binary'));
263
278
  }
264
279
 
265
280
  return Buffer.concat(headers);
@@ -273,6 +288,63 @@ class Headers {
273
288
  return (key || '').toLowerCase().trim();
274
289
  }
275
290
 
291
+ /**
292
+ * Rewrites the line breaks of a header line so that the line can only ever parse
293
+ * back as the single header it was reported as. A line break followed by whitespace
294
+ * is folding and becomes `lineEnd`, every other line break would start a new header
295
+ * line and is dropped.
296
+ *
297
+ * A bare <CR> is never a line break for _parseHeaders, so promoting one here would
298
+ * emit a header line that was never reported as parsed.
299
+ *
300
+ * @param {string} line Header line to normalize.
301
+ * @param {string} lineEnd Line ending to fold with.
302
+ * @returns {string} Line with only folding line breaks left.
303
+ */
304
+ _normalizeLineBreaks(line, lineEnd) {
305
+ return (
306
+ line
307
+ // lines are joined with lineEnd, so a line that opens with a break of its own
308
+ // would close the header block. Dropping only the break leaves any whitespace
309
+ // behind it folding into the line before, which adds no header of its own.
310
+ .replace(/^[\r\n]+/, '')
311
+ .replace(/\r\n|\r|\n/g, (match, offset, source) => (match !== '\r' && this._isFoldingChar(source.charAt(offset + match.length)) ? lineEnd : ''))
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Prepares a caller supplied line for insertion. On top of the line break rules an
317
+ * inserted line has to stand on its own: a leading fold or indent would attach it to
318
+ * whichever header happens to precede it, and a leading line break would close the
319
+ * header block outright.
320
+ *
321
+ * Lines that were parsed out of a message keep their leading whitespace instead, so
322
+ * that rebuilding can never turn an indented continuation into a header of its own.
323
+ *
324
+ * An inserted line is normalized twice, here with CRLF and again in build() with the
325
+ * line ending the caller asked for. That is only sound because _normalizeLineBreaks is
326
+ * idempotent over its own output: the folds this pass emits are still recognized as
327
+ * folds by the next one. Any change to how a fold is represented has to keep that true.
328
+ *
329
+ * @param {string} line Formatted header line supplied by the caller.
330
+ * @returns {string} Line that inserts as exactly one header, or an empty string.
331
+ */
332
+ _normalizeInsertedLine(line) {
333
+ return this._normalizeLineBreaks(line.replace(/^[\r\n \t]+/, ''), '\r\n');
334
+ }
335
+
336
+ /**
337
+ * Tells whether a character continues the previous header line rather than
338
+ * starting a new one. Used by both the parser and the builder so that the two
339
+ * can not disagree on what folding is.
340
+ *
341
+ * @param {string} chr Character that follows a line break.
342
+ * @returns {boolean} True if the line break is folding.
343
+ */
344
+ _isFoldingChar(chr) {
345
+ return chr === ' ' || chr === '\t';
346
+ }
347
+
276
348
  /**
277
349
  * @returns {HeaderLine[]}
278
350
  */
@@ -301,8 +373,7 @@ class Headers {
301
373
 
302
374
  for (let i = lines.length - 1; i >= 0; i--) {
303
375
  let currentLine = /** @type {string} */ (lines[i]);
304
- let chr = currentLine.charAt(0);
305
- if (i && (chr === ' ' || chr === '\t')) {
376
+ if (i && this._isFoldingChar(currentLine.charAt(0))) {
306
377
  lines[i - 1] = /** @type {string} */ (lines[i - 1]) + '\r\n' + currentLine;
307
378
  lines.splice(i, 1);
308
379
  } else {
@@ -13,9 +13,61 @@ const MimeNode = require('./mime-node');
13
13
  const MAX_HEAD_SIZE = 1 * 1024 * 1024;
14
14
  const MAX_CHILD_NODES = 1000;
15
15
 
16
+ // how much of a body line without a line break is buffered before it is flushed
17
+ // out as regular content instead of being kept in memory
18
+ const MAX_PENDING_LINE_SIZE = 64 * 1024;
19
+
20
+ // how many separate writes the pending line may be kept in before it is compacted
21
+ const MAX_PENDING_LINE_CHUNKS = 1024;
22
+
23
+ // 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> */
26
+
27
+ // checkBoundary() additionally allows a line ending in front of the delimiter, so this is
28
+ // the longest a delimiter line can ever be once the boundary value is subtracted
29
+ const BOUNDARY_LINE_OVERHEAD = BOUNDARY_LINE_SUFFIX + 2; /* leading <CR><LF> */
30
+
16
31
  const HEAD = 0x01;
17
32
  const BODY = 0x02;
18
33
 
34
+ /**
35
+ * Creates the error used for all size limit violations.
36
+ *
37
+ * @param {string} message Human readable error message.
38
+ * @returns {Error & {code: string}} Error tagged with the EMAXLEN code.
39
+ */
40
+ function maxLenError(message) {
41
+ let err = /** @type {Error & {code: string}} */ (new Error(message));
42
+ err.code = 'EMAXLEN';
43
+ return err;
44
+ }
45
+
46
+ /**
47
+ * Moves an end offset back over the line ending that closes a body line, because the line
48
+ * ending in front of a boundary belongs to the delimiter and not to the part content. Only
49
+ * a group holding the body of a child node carries such a line ending, anything else is
50
+ * returned untouched.
51
+ *
52
+ * @param {SplitterGroup} group Group the offsets describe.
53
+ * @param {Buffer} chunk Chunk the offsets point into.
54
+ * @param {number} start Start offset of the body slice.
55
+ * @param {number} end End offset of the body slice.
56
+ * @returns {number} End offset with a trailing <CR><LF>, <LF> or nothing removed.
57
+ */
58
+ function trimBodyLineEnd(group, chunk, start, end) {
59
+ if (group.type !== 'body' || !group.node || !group.node.parentNode) {
60
+ return end;
61
+ }
62
+ if (end > start && chunk[end - 1] === 0x0a) {
63
+ end--;
64
+ if (end > start && chunk[end - 1] === 0x0d) {
65
+ end--;
66
+ }
67
+ }
68
+ return end;
69
+ }
70
+
19
71
  /**
20
72
  * Transform stream that splits raw email bytes into MIME node and content chunks.
21
73
  */
@@ -33,15 +85,55 @@ class MessageSplitter extends Transform {
33
85
  this.config = config || {};
34
86
  this.maxHeadSize = this.config.maxHeadSize || MAX_HEAD_SIZE;
35
87
  this.maxChildNodes = this.config.maxChildNodes || MAX_CHILD_NODES;
36
- /** @type {MimeNodeType[]} */
37
- this.tree = [];
38
88
  this.nodeCounter = 0;
39
89
  this.node = /** @type {MimeNodeType} */ (/** @type {unknown} */ (null));
90
+ // set once the closing delimiter of the current node's multipart has been seen, so
91
+ // that any later boundary line of that node counts as epilogue. Reset per node.
92
+ this.inEpilogue = false;
40
93
  this.newNode();
41
- this.tree.push(this.node);
42
- /** @type {Buffer | false} */
43
- this.line = false;
94
+ // incomplete trailing line of the previous chunk, kept as a list of chunks so
95
+ // that a long line without a line break is not copied over for every write
96
+ /** @type {Buffer[]} */
97
+ this.lineChunks = [];
98
+ this.lineLength = 0;
44
99
  this.hasFailed = false;
100
+ // set when the pending line was flushed as overlong content, the remainder
101
+ // of that same line can not be a boundary either
102
+ this.pendingLineTruncated = false;
103
+ }
104
+
105
+ /**
106
+ * Appends unterminated trailing data to the pending line.
107
+ *
108
+ * @param {Buffer} chunk Data that follows the last line break of a write.
109
+ * @returns {void}
110
+ */
111
+ appendPendingLine(chunk) {
112
+ if (!chunk.length) {
113
+ return;
114
+ }
115
+ this.lineChunks.push(chunk);
116
+ this.lineLength += chunk.length;
117
+ if (this.lineChunks.length >= MAX_PENDING_LINE_CHUNKS) {
118
+ // a line written one byte at a time would otherwise cost an array slot and a
119
+ // Buffer view per byte, which is far more memory than the data itself
120
+ this.lineChunks = [Buffer.concat(this.lineChunks, this.lineLength)];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Returns the pending line as a single buffer and clears the pending state.
126
+ *
127
+ * @returns {Buffer | false} Pending line contents or false if there was none.
128
+ */
129
+ takePendingLine() {
130
+ if (!this.lineLength) {
131
+ return false;
132
+ }
133
+ let line = this.lineChunks.length === 1 ? this.lineChunks[0] : Buffer.concat(this.lineChunks, this.lineLength);
134
+ this.lineChunks = [];
135
+ this.lineLength = 0;
136
+ return line;
45
137
  }
46
138
 
47
139
  /**
@@ -59,7 +151,7 @@ class MessageSplitter extends Transform {
59
151
  let group = {
60
152
  type: 'none'
61
153
  };
62
- let groupstart = this.line ? -this.line.length : 0;
154
+ let groupstart = this.lineLength ? -this.lineLength : 0;
63
155
  let groupend = 0;
64
156
 
65
157
  /**
@@ -78,10 +170,9 @@ class MessageSplitter extends Transform {
78
170
  groupstart--;
79
171
  groupend--;
80
172
  pos--;
81
- if (groupstart < 0 && !this.line) {
173
+ if (groupstart < 0 && !this.lineLength) {
82
174
  // store only <CR> as <LF> should be on the positive side
83
- this.line = Buffer.allocUnsafe(1);
84
- this.line[0] = 0x0d;
175
+ this.appendPendingLine(Buffer.from([0x0d]));
85
176
  }
86
177
  data.value = data.value.slice(0, data.value.length - 2);
87
178
  } else {
@@ -122,21 +213,19 @@ class MessageSplitter extends Transform {
122
213
 
123
214
  if (flush) {
124
215
  if (group && group.type !== 'none') {
125
- if (group.type === 'body' && groupend >= groupstart && group.node && group.node.parentNode) {
126
- // do not include the last line ending for body
127
- if (chunk[groupend - 1] === 0x0a) {
128
- groupend--;
129
- if (groupend >= groupstart && chunk[groupend - 1] === 0x0d) {
130
- groupend--;
131
- }
132
- }
133
- }
134
- if (groupstart !== groupend) {
216
+ // do not include the last line ending for body
217
+ groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);
218
+ if (groupstart < groupend) {
219
+ // re-slice, the value the line was emitted with may
220
+ // still include the line ending we just trimmed
135
221
  group.value = chunk.slice(groupstart, groupend);
136
222
  if (groupend < i && 'value' in data) {
223
+ // the trimmed line ending belongs to the boundary line
137
224
  data.value = chunk.slice(groupend, i);
138
225
  }
139
226
  }
227
+ // the group is pushed even when nothing is left of it, so that a
228
+ // part whose whole body is a line ending still reports a body
140
229
  this.push(group);
141
230
  group = {
142
231
  type: 'none'
@@ -152,15 +241,8 @@ class MessageSplitter extends Transform {
152
241
  // shift slice end position forward
153
242
  groupend = i;
154
243
  } else {
155
- if (group.type === 'body' && groupend >= groupstart && group.node && group.node.parentNode) {
156
- // do not include the last line ending for body
157
- if (chunk[groupend - 1] === 0x0a) {
158
- groupend--;
159
- if (groupend >= groupstart && chunk[groupend - 1] === 0x0d) {
160
- groupend--;
161
- }
162
- }
163
- }
244
+ // do not include the last line ending for body
245
+ groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);
164
246
 
165
247
  if (group.type !== 'none' && group.type !== 'node') {
166
248
  // we have a previous data/body chunk to output
@@ -199,15 +281,7 @@ class MessageSplitter extends Transform {
199
281
  }
200
282
 
201
283
  // skip last linebreak for body
202
- if (pos >= groupstart + 1 && group.type === 'body' && group.node && group.node.parentNode) {
203
- // do not include the last line ending for body
204
- if (chunk[pos - 1] === 0x0a) {
205
- pos--;
206
- if (pos >= groupstart && chunk[pos - 1] === 0x0d) {
207
- pos--;
208
- }
209
- }
210
- }
284
+ pos = trimBodyLineEnd(group, chunk, groupstart, pos);
211
285
 
212
286
  if (group.type !== 'none' && group.type !== 'node' && pos > groupstart) {
213
287
  // we have a leftover data/body chunk to push out
@@ -222,11 +296,16 @@ class MessageSplitter extends Transform {
222
296
  }
223
297
 
224
298
  if (pos < chunk.length) {
225
- if (this.line) {
226
- this.line = Buffer.concat([this.line, chunk.slice(pos)]);
227
- } else {
228
- this.line = chunk.slice(pos);
229
- }
299
+ // checkTrailingLinebreak can push pos before the start of this write when a
300
+ // line ending straddles it. A negative start would make slice() count from
301
+ // the END of the buffer and hand over the wrong bytes entirely.
302
+ this.appendPendingLine(chunk.slice(Math.max(pos, 0)));
303
+ }
304
+
305
+ let pendingLineError = this.enforcePendingLineLimit();
306
+ if (pendingLineError) {
307
+ this.hasFailed = true;
308
+ return callback(pendingLineError);
230
309
  }
231
310
  callback();
232
311
  };
@@ -261,7 +340,7 @@ class MessageSplitter extends Transform {
261
340
  */
262
341
  compareBoundary(line, startpos, boundary) {
263
342
  // --{boundary}\r\n or --{boundary}--\r\n
264
- if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + 6 + startpos) {
343
+ if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + BOUNDARY_LINE_SUFFIX + startpos) {
265
344
  return false;
266
345
  }
267
346
  for (let i = 0; i < boundary.length; i++) {
@@ -308,7 +387,8 @@ class MessageSplitter extends Transform {
308
387
  let startpos = 0;
309
388
  if (line.length >= 1 && (line[0] === 0x0d || line[0] === 0x0a)) {
310
389
  startpos++;
311
- if (line.length >= 2 && (line[0] === 0x0d || line[1] === 0x0a)) {
390
+ if (line.length >= 2 && line[0] === 0x0d && line[1] === 0x0a) {
391
+ // only <CR><LF> is two bytes, a lone <CR> in front of a delimiter is one
312
392
  startpos++;
313
393
  }
314
394
  }
@@ -319,7 +399,7 @@ class MessageSplitter extends Transform {
319
399
 
320
400
  /** @type {1 | 2 | false} */
321
401
  let boundary;
322
- if (this.node._boundary && (boundary = this.compareBoundary(line, startpos, this.node._boundary))) {
402
+ if (!this.inEpilogue && this.node._boundary && (boundary = this.compareBoundary(line, startpos, this.node._boundary))) {
323
403
  // 1: next child
324
404
  // 2: multipart end
325
405
  return boundary;
@@ -334,6 +414,75 @@ class MessageSplitter extends Transform {
334
414
  return false;
335
415
  }
336
416
 
417
+ /**
418
+ * Checks the header bytes collected for the current node against maxHeadSize.
419
+ *
420
+ * @param {number} [extra] Bytes that belong to the header block but are not stored yet.
421
+ * @returns {(Error & {code?: string}) | null} Error object if the limit was exceeded.
422
+ */
423
+ checkHeadSize(extra) {
424
+ if (this.node._headerlen + (extra || 0) > this.maxHeadSize) {
425
+ return maxLenError('Max header size for a MIME node exceeded');
426
+ }
427
+ return null;
428
+ }
429
+
430
+ /**
431
+ * Enforces the limits on the pending line so that it can not grow without bound.
432
+ * A line that is still short enough to become a boundary delimiter is always kept.
433
+ * Past that length it is a header line and counts against maxHeadSize, or it is
434
+ * body content, in which case it is pushed out rather than held in memory. Flushing
435
+ * marks the pending line truncated, so the tail of it is not tested as a delimiter.
436
+ *
437
+ * @returns {(Error & {code?: string}) | null} Error object if a limit was exceeded.
438
+ */
439
+ enforcePendingLineLimit() {
440
+ if (!this.lineLength) {
441
+ return null;
442
+ }
443
+
444
+ let maxBoundaryLength = Math.max(
445
+ this.node._boundary ? this.node._boundary.length : 0,
446
+ this.node._parentBoundary ? this.node._parentBoundary.length : 0
447
+ );
448
+
449
+ if (this.lineLength <= maxBoundaryLength + BOUNDARY_LINE_OVERHEAD) {
450
+ // might still turn out to be a boundary delimiter line
451
+ return null;
452
+ }
453
+
454
+ if (this.state === HEAD) {
455
+ // not a boundary line, so it is a header line and counts against the
456
+ // header size limit even though it has not been stored on the node yet
457
+ return this.checkHeadSize(this.lineLength);
458
+ }
459
+
460
+ if (this.lineLength < MAX_PENDING_LINE_SIZE) {
461
+ return null;
462
+ }
463
+
464
+ let value = /** @type {Buffer} */ (this.takePendingLine());
465
+ if (value[value.length - 1] === 0x0d) {
466
+ // a trailing <CR> may still turn out to be the first half of the line ending
467
+ // that closes this line, and a line ending in front of a boundary belongs to
468
+ // the delimiter. Keep it pending so the normal trimming can decide. Copy it
469
+ // rather than slicing, a view would pin the whole flushed buffer.
470
+ this.appendPendingLine(Buffer.from([0x0d]));
471
+ value = value.slice(0, value.length - 1);
472
+ }
473
+
474
+ this.push({
475
+ node: this.node,
476
+ type: this.node.multipart ? 'data' : 'body',
477
+ value
478
+ });
479
+ // whatever follows continues an overlong line, so the tail of it
480
+ // can not be a boundary line either
481
+ this.pendingLineTruncated = true;
482
+
483
+ return null;
484
+ }
485
+
337
486
  /**
338
487
  * @param {Buffer | false} line
339
488
  * @param {boolean} final
@@ -343,12 +492,13 @@ class MessageSplitter extends Transform {
343
492
  processLine(line, final, next) {
344
493
  let flush = false;
345
494
 
346
- if (this.line && line) {
347
- line = Buffer.concat([this.line, line]);
348
- this.line = false;
349
- } else if (this.line && !line) {
350
- line = this.line;
351
- this.line = false;
495
+ // consumed here so that no later branch can leak it into the next line
496
+ let truncatedLine = this.pendingLineTruncated;
497
+ this.pendingLineTruncated = false;
498
+
499
+ let pending = this.takePendingLine();
500
+ if (pending) {
501
+ line = line ? Buffer.concat([pending, line]) : pending;
352
502
  }
353
503
 
354
504
  if (!line) {
@@ -356,13 +506,12 @@ class MessageSplitter extends Transform {
356
506
  }
357
507
 
358
508
  if (this.nodeCounter > this.maxChildNodes) {
359
- let err = /** @type {Error & {code?: string}} */ (new Error('Max allowed child nodes exceeded'));
360
- err.code = 'EMAXLEN';
361
- return next(err);
509
+ return next(maxLenError('Max allowed child nodes exceeded'));
362
510
  }
363
511
 
364
512
  // we check boundary outside the HEAD/BODY scope as it may appear anywhere
365
- let boundary = this.checkBoundary(line);
513
+ // unless the line is the remainder of an already flushed overlong line
514
+ let boundary = truncatedLine ? false : this.checkBoundary(line);
366
515
  if (boundary) {
367
516
  // reached boundary, switch context
368
517
  switch (boundary) {
@@ -376,28 +525,28 @@ class MessageSplitter extends Transform {
376
525
  break;
377
526
  case 3: {
378
527
  // next sibling
379
- let parentNode = this.node.parentNode;
380
- if (parentNode && parentNode.contentType === 'message/rfc822') {
381
- // special case where immediate parent is an inline message block
382
- // move up another step
383
- parentNode = parentNode.parentNode;
384
- }
385
- this.newNode(parentNode);
528
+ this.newNode(this.parentMultipartNode());
386
529
  flush = true;
387
530
  break;
388
531
  }
389
- case 4:
532
+ case 4: {
390
533
  // special case when boundary close a node with only header.
391
534
  if (this.node && this.node._headerlen && !this.node.headers) {
392
535
  this.node.parseHeaders();
393
536
  this.push(this.node);
394
537
  }
395
- // move up
396
- if (this.tree.length) {
397
- this.node = /** @type {MimeNodeType} */ (this.tree.pop());
538
+ // move up to the multipart node this closing delimiter belongs to
539
+ let parentNode = this.parentMultipartNode();
540
+ if (parentNode) {
541
+ this.node = parentNode;
542
+ // the closing delimiter of this multipart was just processed, so any
543
+ // later boundary line of this node belongs to the epilogue. A closing
544
+ // delimiter seen in the preamble (case 2) deliberately does not count.
545
+ this.inEpilogue = true;
398
546
  }
399
547
  this.state = BODY;
400
548
  break;
549
+ }
401
550
  }
402
551
 
403
552
  return next(
@@ -414,10 +563,9 @@ class MessageSplitter extends Transform {
414
563
  switch (this.state) {
415
564
  case HEAD: {
416
565
  this.node.addHeaderChunk(line);
417
- if (this.node._headerlen > this.maxHeadSize) {
418
- let err = /** @type {Error & {code?: string}} */ (new Error('Max header size for a MIME node exceeded'));
419
- err.code = 'EMAXLEN';
420
- return next(err);
566
+ let headSizeError = this.checkHeadSize();
567
+ if (headSizeError) {
568
+ return next(headSizeError);
421
569
  }
422
570
  if (final || (line.length === 1 && line[0] === 0x0a) || (line.length === 2 && line[0] === 0x0d && line[1] === 0x0a)) {
423
571
  let currentNode = this.node;
@@ -434,16 +582,16 @@ class MessageSplitter extends Transform {
434
582
  currentNode.messageNode = true;
435
583
  this.newNode(currentNode);
436
584
  if (currentNode.parentNode) {
585
+ // the embedded message continues inside its container, so a
586
+ // delimiter of the container's own parent still applies here
437
587
  this.node._parentBoundary = currentNode.parentNode._boundary;
588
+ this.node._parentBoundaryOwner = currentNode.parentNode;
438
589
  }
439
590
  } else {
440
591
  if (currentNode.contentType === 'message/rfc822') {
441
592
  currentNode.messageNode = false;
442
593
  }
443
594
  this.state = BODY;
444
- if (currentNode.multipart && currentNode._boundary) {
445
- this.tree.push(currentNode);
446
- }
447
595
  }
448
596
 
449
597
  return next(null, currentNode, flush);
@@ -467,6 +615,16 @@ class MessageSplitter extends Transform {
467
615
  next(null, false);
468
616
  }
469
617
 
618
+ /**
619
+ * Resolves the multipart node that owns the boundary of the current node, ie. the
620
+ * node a sibling delimiter or a closing delimiter of _parentBoundary refers to.
621
+ *
622
+ * @returns {MimeNodeType | false} Owner of _parentBoundary or false for the root node.
623
+ */
624
+ parentMultipartNode() {
625
+ return this.node._parentBoundaryOwner || false;
626
+ }
627
+
470
628
  /**
471
629
  * @param {MimeNodeType | false} [parent]
472
630
  * @returns {void}
@@ -475,6 +633,8 @@ class MessageSplitter extends Transform {
475
633
  this.node = /** @type {MimeNodeType} */ (new MimeNode(parent || false, this.config));
476
634
  this.state = HEAD;
477
635
  this.nodeCounter++;
636
+ // a fresh node starts before its own content, never in an epilogue
637
+ this.inEpilogue = false;
478
638
  }
479
639
  }
480
640
 
@@ -18,6 +18,9 @@ declare class MimeNode implements MimeNodeShape {
18
18
  /** Boundary inherited from the parent multipart node, or `false` when absent. */
19
19
  _parentBoundary: Buffer | false;
20
20
 
21
+ /** Node whose boundary `_parentBoundary` was copied from. */
22
+ _parentBoundaryOwner: MimeNode | false;
23
+
21
24
  /** Length, in bytes, of the raw header block collected for this node. */
22
25
  _headerlen: number;
23
26
 
package/lib/mime-node.js CHANGED
@@ -35,6 +35,10 @@ class MimeNode {
35
35
 
36
36
  /** @type {Buffer | false} */
37
37
  this._parentBoundary = this.parentNode && this.parentNode._boundary;
38
+ // the node whose boundary _parentBoundary was copied from, recorded here so that
39
+ // a delimiter for it never has to be matched back to an owner by walking the tree
40
+ /** @type {MimeNodeType | false} */
41
+ this._parentBoundaryOwner = this.parentNode || false;
38
42
  /** @type {Buffer[]} */
39
43
  this._headersLines = [];
40
44
  this._headerlen = 0;
package/lib/types.d.ts CHANGED
@@ -81,6 +81,9 @@ export interface MimeNode {
81
81
  /** Boundary inherited from the parent multipart node, or `false` when absent. */
82
82
  _parentBoundary: Buffer | false;
83
83
 
84
+ /** Node whose boundary `_parentBoundary` was copied from. */
85
+ _parentBoundaryOwner: MimeNode | false;
86
+
84
87
  /** Length, in bytes, of the raw header block collected for this node. */
85
88
  _headerlen: number;
86
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zone-eu/mailsplit",
3
- "version": "5.4.14",
3
+ "version": "5.4.15",
4
4
  "description": "Split email messages into an object stream",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -16,7 +16,7 @@
16
16
  "license": "(MIT OR EUPL-1.1+)",
17
17
  "dependencies": {
18
18
  "libbase64": "1.3.0",
19
- "libmime": "5.4.1",
19
+ "libmime": "5.4.2",
20
20
  "libqp": "2.1.1"
21
21
  },
22
22
  "devDependencies": {
@@ -25,16 +25,16 @@
25
25
  "@types/grunt": "0.4.32",
26
26
  "@types/libmime": "5.3.0",
27
27
  "@types/libqp": "1.1.3",
28
- "@types/node": "26.1.0",
28
+ "@types/node": "26.1.2",
29
29
  "eslint": "8.29.0",
30
30
  "eslint-config-nodemailer": "1.2.0",
31
31
  "eslint-config-prettier": "9.1.0",
32
- "grunt": "1.6.2",
32
+ "grunt": "1.6.3",
33
33
  "grunt-cli": "1.5.0",
34
34
  "grunt-contrib-nodeunit": "5.0.0",
35
35
  "grunt-eslint": "24.0.1",
36
36
  "random-message": "1.1.0",
37
- "typescript": "6.0.3"
37
+ "typescript": "7.0.2"
38
38
  },
39
39
  "files": [
40
40
  "lib",