@depup/zone-eu__mailsplit 5.4.16-depup.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.
@@ -0,0 +1,641 @@
1
+ 'use strict';
2
+
3
+ const Transform = require('stream').Transform;
4
+ const MimeNode = require('./mime-node');
5
+
6
+ /** @typedef {import('..').MimeNode} MimeNodeType */
7
+ /** @typedef {import('..').MessageChunk} MessageChunk */
8
+ /** @typedef {import('..').SplitterChunk} SplitterChunk */
9
+ /** @typedef {import('..').SplitterGroup} SplitterGroup */
10
+ /** @typedef {import('..').SplitterOptions} SplitterOptions */
11
+ /** @typedef {(err?: (Error & {code?: string}) | null, data?: SplitterChunk | MessageChunk | false, flush?: boolean) => void} ProcessLineCallback */
12
+
13
+ const MAX_HEAD_SIZE = 1 * 1024 * 1024;
14
+ const MAX_CHILD_NODES = 1000;
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
+
31
+ const HEAD = 0x01;
32
+ const BODY = 0x02;
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
+
71
+ /**
72
+ * Transform stream that splits raw email bytes into MIME node and content chunks.
73
+ */
74
+ class MessageSplitter extends Transform {
75
+ /**
76
+ * @param {SplitterOptions} [config]
77
+ */
78
+ constructor(config) {
79
+ let options = {
80
+ readableObjectMode: true,
81
+ writableObjectMode: false
82
+ };
83
+ super(options);
84
+
85
+ this.config = config || {};
86
+ this.maxHeadSize = this.config.maxHeadSize || MAX_HEAD_SIZE;
87
+ this.maxChildNodes = this.config.maxChildNodes || MAX_CHILD_NODES;
88
+ this.nodeCounter = 0;
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;
93
+ this.newNode();
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;
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;
137
+ }
138
+
139
+ /**
140
+ * @param {Buffer} chunk
141
+ * @param {BufferEncoding} encoding
142
+ * @param {import('stream').TransformCallback} callback
143
+ * @returns {void}
144
+ */
145
+ _transform(chunk, encoding, callback) {
146
+ // process line by line
147
+ // find next line ending
148
+ let pos = 0;
149
+ let i = 0;
150
+ /** @type {SplitterGroup} */
151
+ let group = {
152
+ type: 'none'
153
+ };
154
+ let groupstart = this.lineLength ? -this.lineLength : 0;
155
+ let groupend = 0;
156
+
157
+ /**
158
+ * Removes a pending line break from body data that belongs to a following boundary.
159
+ *
160
+ * @param {MessageChunk} data Body chunk to adjust in place.
161
+ * @returns {void}
162
+ */
163
+ let checkTrailingLinebreak = data => {
164
+ if (data.type === 'body' && data.node.parentNode && data.value && data.value.length) {
165
+ if (data.value[data.value.length - 1] === 0x0a) {
166
+ groupstart--;
167
+ groupend--;
168
+ pos--;
169
+ if (data.value.length > 1 && data.value[data.value.length - 2] === 0x0d) {
170
+ groupstart--;
171
+ groupend--;
172
+ pos--;
173
+ if (groupstart < 0 && !this.lineLength) {
174
+ // store only <CR> as <LF> should be on the positive side
175
+ this.appendPendingLine(Buffer.from([0x0d]));
176
+ }
177
+ data.value = data.value.slice(0, data.value.length - 2);
178
+ } else {
179
+ data.value = data.value.slice(0, data.value.length - 1);
180
+ }
181
+ } else if (data.value[data.value.length - 1] === 0x0d) {
182
+ groupstart--;
183
+ groupend--;
184
+ pos--;
185
+ data.value = data.value.slice(0, data.value.length - 1);
186
+ }
187
+ }
188
+ };
189
+
190
+ /**
191
+ * Iterates the current input chunk line by line and emits parsed groups.
192
+ *
193
+ * @returns {void}
194
+ */
195
+ let iterateData = () => {
196
+ for (let len = chunk.length; i < len; i++) {
197
+ // find next <LF>
198
+ if (chunk[i] === 0x0a) {
199
+ // line end
200
+
201
+ let start = Math.max(pos, 0);
202
+ pos = ++i;
203
+
204
+ return this.processLine(chunk.slice(start, i), false, (err, data, flush) => {
205
+ if (err) {
206
+ this.hasFailed = true;
207
+ return setImmediate(() => callback(err));
208
+ }
209
+
210
+ if (!data) {
211
+ return setImmediate(iterateData);
212
+ }
213
+
214
+ if (flush) {
215
+ if (group && group.type !== 'none') {
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
221
+ group.value = chunk.slice(groupstart, groupend);
222
+ if (groupend < i && 'value' in data) {
223
+ // the trimmed line ending belongs to the boundary line
224
+ data.value = chunk.slice(groupend, i);
225
+ }
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
229
+ this.push(group);
230
+ group = {
231
+ type: 'none'
232
+ };
233
+ groupstart = groupend = i;
234
+ }
235
+ this.push(data);
236
+ groupend = i;
237
+ return setImmediate(iterateData);
238
+ }
239
+
240
+ if (data.type === group.type) {
241
+ // shift slice end position forward
242
+ groupend = i;
243
+ } else {
244
+ // do not include the last line ending for body
245
+ groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);
246
+
247
+ if (group.type !== 'none' && group.type !== 'node') {
248
+ // we have a previous data/body chunk to output
249
+ if (groupstart !== groupend) {
250
+ group.value = chunk.slice(groupstart, groupend);
251
+ if (group.value && group.value.length) {
252
+ this.push(group);
253
+ group = {
254
+ type: 'none'
255
+ };
256
+ }
257
+ }
258
+ }
259
+
260
+ if (data.type === 'node') {
261
+ this.push(data);
262
+ groupstart = i;
263
+ groupend = i;
264
+ } else if (groupstart < 0) {
265
+ groupstart = i;
266
+ groupend = i;
267
+ checkTrailingLinebreak(data);
268
+ if (data.value && data.value.length) {
269
+ this.push(data);
270
+ }
271
+ } else {
272
+ // start new body/data chunk
273
+ group = data;
274
+ groupstart = groupend;
275
+ groupend = i;
276
+ }
277
+ }
278
+ return setImmediate(iterateData);
279
+ });
280
+ }
281
+ }
282
+
283
+ // skip last linebreak for body
284
+ pos = trimBodyLineEnd(group, chunk, groupstart, pos);
285
+
286
+ if (group.type !== 'none' && group.type !== 'node' && pos > groupstart) {
287
+ // we have a leftover data/body chunk to push out
288
+ group.value = chunk.slice(groupstart, pos);
289
+
290
+ if (group.value && group.value.length) {
291
+ this.push(group);
292
+ group = {
293
+ type: 'none'
294
+ };
295
+ }
296
+ }
297
+
298
+ if (pos < chunk.length) {
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);
309
+ }
310
+ callback();
311
+ };
312
+
313
+ setImmediate(iterateData);
314
+ }
315
+
316
+ /**
317
+ * @param {import('stream').TransformCallback} callback
318
+ * @returns {void}
319
+ */
320
+ _flush(callback) {
321
+ if (this.hasFailed) {
322
+ return callback();
323
+ }
324
+ this.processLine(false, true, (err, data) => {
325
+ if (err) {
326
+ return setImmediate(() => callback(err));
327
+ }
328
+ if (data && (data.type === 'node' || (data.value && data.value.length))) {
329
+ this.push(data);
330
+ }
331
+ callback();
332
+ });
333
+ }
334
+
335
+ /**
336
+ * @param {Buffer} line
337
+ * @param {number} startpos
338
+ * @param {Buffer} boundary
339
+ * @returns {1 | 2 | false}
340
+ */
341
+ compareBoundary(line, startpos, boundary) {
342
+ // --{boundary}\r\n or --{boundary}--\r\n
343
+ if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + BOUNDARY_LINE_SUFFIX + startpos) {
344
+ return false;
345
+ }
346
+ for (let i = 0; i < boundary.length; i++) {
347
+ if (line[i + 2 + startpos] !== boundary[i]) {
348
+ return false;
349
+ }
350
+ }
351
+
352
+ let pos = 0;
353
+ for (let i = boundary.length + 2 + startpos; i < line.length; i++) {
354
+ let c = line[i];
355
+ if (pos === 0 && (c === 0x0d || c === 0x0a)) {
356
+ // 1: next node
357
+ return 1;
358
+ }
359
+ if (pos === 0 && c !== 0x2d) {
360
+ // expecting "-"
361
+ return false;
362
+ }
363
+ if (pos === 1 && c !== 0x2d) {
364
+ // expecting "-"
365
+ return false;
366
+ }
367
+ if (pos === 2 && c !== 0x0d && c !== 0x0a) {
368
+ // expecting line terminator, either <CR> or <LF>
369
+ return false;
370
+ }
371
+ if (pos === 3 && c !== 0x0a) {
372
+ // expecting line terminator <LF>
373
+ return false;
374
+ }
375
+ pos++;
376
+ }
377
+
378
+ // 2: multipart end
379
+ return 2;
380
+ }
381
+
382
+ /**
383
+ * @param {Buffer} line
384
+ * @returns {1 | 2 | 3 | 4 | false}
385
+ */
386
+ checkBoundary(line) {
387
+ let startpos = 0;
388
+ if (line.length >= 1 && (line[0] === 0x0d || line[0] === 0x0a)) {
389
+ startpos++;
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
392
+ startpos++;
393
+ }
394
+ }
395
+ if (line.length < 4 || line[startpos] !== 0x2d || line[startpos + 1] !== 0x2d) {
396
+ // defnitely not a boundary
397
+ return false;
398
+ }
399
+
400
+ /** @type {1 | 2 | false} */
401
+ let boundary;
402
+ if (!this.inEpilogue && this.node._boundary && (boundary = this.compareBoundary(line, startpos, this.node._boundary))) {
403
+ // 1: next child
404
+ // 2: multipart end
405
+ return boundary;
406
+ }
407
+
408
+ if (this.node._parentBoundary && (boundary = this.compareBoundary(line, startpos, this.node._parentBoundary))) {
409
+ // 3: next sibling
410
+ // 4: parent end
411
+ return /** @type {3 | 4} */ (boundary + 2);
412
+ }
413
+
414
+ return false;
415
+ }
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
+
486
+ /**
487
+ * @param {Buffer | false} line
488
+ * @param {boolean} final
489
+ * @param {ProcessLineCallback} next
490
+ * @returns {void}
491
+ */
492
+ processLine(line, final, next) {
493
+ let flush = false;
494
+
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;
502
+ }
503
+
504
+ if (!line) {
505
+ line = Buffer.alloc(0);
506
+ }
507
+
508
+ if (this.nodeCounter > this.maxChildNodes) {
509
+ return next(maxLenError('Max allowed child nodes exceeded'));
510
+ }
511
+
512
+ // we check boundary outside the HEAD/BODY scope as it may appear anywhere
513
+ // unless the line is the remainder of an already flushed overlong line
514
+ let boundary = truncatedLine ? false : this.checkBoundary(line);
515
+ if (boundary) {
516
+ // reached boundary, switch context
517
+ switch (boundary) {
518
+ case 1:
519
+ // next child
520
+ this.newNode(this.node);
521
+ flush = true;
522
+ break;
523
+ case 2:
524
+ // reached end of children, keep current node
525
+ break;
526
+ case 3: {
527
+ // next sibling
528
+ this.newNode(this.parentMultipartNode());
529
+ flush = true;
530
+ break;
531
+ }
532
+ case 4: {
533
+ // special case when boundary close a node with only header.
534
+ if (this.node && this.node._headerlen && !this.node.headers) {
535
+ this.node.parseHeaders();
536
+ this.push(this.node);
537
+ }
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;
546
+ }
547
+ this.state = BODY;
548
+ break;
549
+ }
550
+ }
551
+
552
+ return next(
553
+ null,
554
+ {
555
+ node: this.node,
556
+ type: 'data',
557
+ value: line
558
+ },
559
+ flush
560
+ );
561
+ }
562
+
563
+ switch (this.state) {
564
+ case HEAD: {
565
+ this.node.addHeaderChunk(line);
566
+ let headSizeError = this.checkHeadSize();
567
+ if (headSizeError) {
568
+ return next(headSizeError);
569
+ }
570
+ if (final || (line.length === 1 && line[0] === 0x0a) || (line.length === 2 && line[0] === 0x0d && line[1] === 0x0a)) {
571
+ let currentNode = this.node;
572
+
573
+ currentNode.parseHeaders();
574
+
575
+ // if the content is attached message then just continue
576
+ if (
577
+ currentNode.contentType === 'message/rfc822' &&
578
+ !this.config.ignoreEmbedded &&
579
+ (!currentNode.encoding || ['7bit', '8bit', 'binary'].includes(currentNode.encoding)) &&
580
+ (this.config.defaultInlineEmbedded ? currentNode.disposition !== 'attachment' : currentNode.disposition === 'inline')
581
+ ) {
582
+ currentNode.messageNode = true;
583
+ this.newNode(currentNode);
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
587
+ this.node._parentBoundary = currentNode.parentNode._boundary;
588
+ this.node._parentBoundaryOwner = currentNode.parentNode;
589
+ }
590
+ } else {
591
+ if (currentNode.contentType === 'message/rfc822') {
592
+ currentNode.messageNode = false;
593
+ }
594
+ this.state = BODY;
595
+ }
596
+
597
+ return next(null, currentNode, flush);
598
+ }
599
+
600
+ return next();
601
+ }
602
+ case BODY: {
603
+ return next(
604
+ null,
605
+ {
606
+ node: this.node,
607
+ type: this.node.multipart ? 'data' : 'body',
608
+ value: line
609
+ },
610
+ flush
611
+ );
612
+ }
613
+ }
614
+
615
+ next(null, false);
616
+ }
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
+
628
+ /**
629
+ * @param {MimeNodeType | false} [parent]
630
+ * @returns {void}
631
+ */
632
+ newNode(parent) {
633
+ this.node = /** @type {MimeNodeType} */ (new MimeNode(parent || false, this.config));
634
+ this.state = HEAD;
635
+ this.nodeCounter++;
636
+ // a fresh node starts before its own content, never in an epilogue
637
+ this.inEpilogue = false;
638
+ }
639
+ }
640
+
641
+ module.exports = MessageSplitter;