@zone-eu/mailsplit 5.4.14 → 5.4.16
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 +81 -10
- package/lib/message-splitter.js +233 -73
- package/lib/mime-node.d.ts +3 -0
- package/lib/mime-node.js +4 -0
- package/lib/types.d.ts +3 -0
- package/package.json +5 -5
package/lib/headers.js
CHANGED
|
@@ -130,7 +130,8 @@ class Headers {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
value = value.toString('binary');
|
|
133
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
254
|
+
const ending = lineEnd || '\r\n';
|
|
244
255
|
|
|
245
256
|
let headers = lines
|
|
246
|
-
.map(line => this.
|
|
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(
|
|
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(
|
|
270
|
+
headers.push(Buffer.from(ending + ending, 'binary'));
|
|
256
271
|
|
|
257
272
|
if (this.mbox) {
|
|
258
|
-
headers.unshift(Buffer.from(this.mbox +
|
|
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 +
|
|
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
|
-
|
|
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 {
|
package/lib/message-splitter.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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.
|
|
173
|
+
if (groupstart < 0 && !this.lineLength) {
|
|
82
174
|
// store only <CR> as <LF> should be on the positive side
|
|
83
|
-
this.
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 +
|
|
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 &&
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
package/lib/mime-node.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "5.4.16",
|
|
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.
|
|
19
|
+
"libmime": "5.4.3",
|
|
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.
|
|
28
|
+
"@types/node": "26.4.0",
|
|
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.
|
|
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": "
|
|
37
|
+
"typescript": "7.0.2"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"lib",
|