@zone-eu/mailsplit 5.4.17 → 5.4.19
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/flowed-decoder.js +1 -5
- package/lib/headers.js +35 -25
- package/lib/message-splitter.js +126 -79
- package/lib/mime-node.js +27 -5
- package/lib/node-rewriter.js +3 -2
- package/lib/types.d.ts +0 -3
- package/package.json +5 -5
package/lib/flowed-decoder.js
CHANGED
|
@@ -56,12 +56,8 @@ class FlowedDecoder extends Transform {
|
|
|
56
56
|
*/
|
|
57
57
|
_flush(callback) {
|
|
58
58
|
if (this.chunklen) {
|
|
59
|
+
// transfer decoding is done upstream by MimeNode.getDecoder()
|
|
59
60
|
let currentBody = Buffer.concat(this.chunks, this.chunklen);
|
|
60
|
-
|
|
61
|
-
if (this.config.encoding === 'base64') {
|
|
62
|
-
currentBody = Buffer.from(currentBody.toString('binary'), 'base64');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
61
|
let content = this.libmime.decodeFlowed(currentBody.toString('binary'), this.config.delSp);
|
|
66
62
|
this.push(Buffer.from(content, 'binary'));
|
|
67
63
|
}
|
package/lib/headers.js
CHANGED
|
@@ -365,39 +365,49 @@ class Headers {
|
|
|
365
365
|
return;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
|
|
369
|
-
let lines = this.headers
|
|
368
|
+
let rawLines = this.headers
|
|
370
369
|
.toString('binary')
|
|
371
370
|
.replace(/[\r\n]+$/, '')
|
|
372
371
|
.split(/\r?\n/);
|
|
373
372
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
373
|
+
// Single forward pass. Splicing folded lines out of the source array in place was
|
|
374
|
+
// quadratic on a header block with many continuation lines.
|
|
375
|
+
let i = 1;
|
|
376
|
+
|
|
377
|
+
// The first logical line may be an mbox or HTTP POST preamble instead of a header
|
|
378
|
+
let first = rawLines[0];
|
|
379
|
+
while (i < rawLines.length && this._isFoldingChar(rawLines[i].charAt(0))) {
|
|
380
|
+
first += '\r\n' + rawLines[i++];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** @type {HeaderLine[]} */
|
|
384
|
+
let lines = [];
|
|
385
|
+
if (/^From /i.test(first)) {
|
|
386
|
+
// mbox file
|
|
387
|
+
this.mbox = first;
|
|
388
|
+
} else if (/^POST /i.test(first)) {
|
|
389
|
+
// HTTP POST request
|
|
390
|
+
this.http = first;
|
|
391
|
+
} else {
|
|
392
|
+
lines.push({ key: this._normalizeHeader(first.substr(0, first.indexOf(':'))), line: first });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (; i < rawLines.length; i++) {
|
|
396
|
+
let currentLine = rawLines[i];
|
|
397
|
+
if (this._isFoldingChar(currentLine.charAt(0))) {
|
|
398
|
+
// the loop starts on a non-folded line, so there is always a last entry here
|
|
399
|
+
let entry = lines[lines.length - 1];
|
|
400
|
+
entry.line += '\r\n' + currentLine;
|
|
401
|
+
if (!entry.key) {
|
|
402
|
+
// the key comes from the first colon, which may only appear in a continuation
|
|
403
|
+
entry.key = this._normalizeHeader(entry.line.substr(0, entry.line.indexOf(':')));
|
|
391
404
|
}
|
|
392
|
-
|
|
393
|
-
lines
|
|
394
|
-
key,
|
|
395
|
-
line
|
|
396
|
-
};
|
|
405
|
+
} else {
|
|
406
|
+
lines.push({ key: this._normalizeHeader(currentLine.substr(0, currentLine.indexOf(':'))), line: currentLine });
|
|
397
407
|
}
|
|
398
408
|
}
|
|
399
409
|
|
|
400
|
-
this.lines =
|
|
410
|
+
this.lines = lines;
|
|
401
411
|
this.parsed = true;
|
|
402
412
|
}
|
|
403
413
|
|
package/lib/message-splitter.js
CHANGED
|
@@ -28,6 +28,10 @@ const BOUNDARY_LINE_SUFFIX = 2 /* "--" prefix */ + 2 /* "--" suffix */ + 2; /* t
|
|
|
28
28
|
// the longest a delimiter line can ever be once the boundary value is subtracted
|
|
29
29
|
const BOUNDARY_LINE_OVERHEAD = BOUNDARY_LINE_SUFFIX + 2; /* leading <CR><LF> */
|
|
30
30
|
|
|
31
|
+
// Bytes of input split in one go before yielding to the event loop. Bytes rather than lines
|
|
32
|
+
// bound the work per batch whatever the line length: 16 KiB is at most 16k (empty) lines.
|
|
33
|
+
const YIELD_BYTES = 16 * 1024;
|
|
34
|
+
|
|
31
35
|
const HEAD = 0x01;
|
|
32
36
|
const BODY = 0x02;
|
|
33
37
|
|
|
@@ -153,6 +157,12 @@ class MessageSplitter extends Transform {
|
|
|
153
157
|
};
|
|
154
158
|
let groupstart = this.lineLength ? -this.lineLength : 0;
|
|
155
159
|
let groupend = 0;
|
|
160
|
+
// input position where the current batch started
|
|
161
|
+
let yieldPos = 0;
|
|
162
|
+
// true while processLine() runs synchronously for the current line
|
|
163
|
+
let inLine = false;
|
|
164
|
+
// set when the current line finished synchronously and the batch may continue in place
|
|
165
|
+
let resumeInLine = false;
|
|
156
166
|
|
|
157
167
|
/**
|
|
158
168
|
* Removes a pending line break from body data that belongs to a following boundary.
|
|
@@ -187,96 +197,133 @@ class MessageSplitter extends Transform {
|
|
|
187
197
|
}
|
|
188
198
|
};
|
|
189
199
|
|
|
200
|
+
// assigned below, the line handlers and the batch loop call each other
|
|
201
|
+
/** @type {() => void} */
|
|
202
|
+
let iterateData;
|
|
203
|
+
|
|
190
204
|
/**
|
|
191
|
-
*
|
|
205
|
+
* Continues with the next line. Lines are processed in place and the event loop is
|
|
206
|
+
* only yielded to every YIELD_BYTES of input, a setImmediate per line made line-dense
|
|
207
|
+
* input very slow to split. A line that completed asynchronously always reschedules.
|
|
192
208
|
*
|
|
193
209
|
* @returns {void}
|
|
194
210
|
*/
|
|
195
|
-
let
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
let nextLine = () => {
|
|
212
|
+
if (inLine && i - yieldPos < YIELD_BYTES) {
|
|
213
|
+
resumeInLine = true;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
setImmediate(iterateData);
|
|
217
|
+
};
|
|
200
218
|
|
|
201
|
-
|
|
202
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Handles the result of processLine() for the line that ends at `i`. Defined once per
|
|
221
|
+
* write rather than once per line.
|
|
222
|
+
*
|
|
223
|
+
* @type {ProcessLineCallback}
|
|
224
|
+
*/
|
|
225
|
+
let onLine = (err, data, flush) => {
|
|
226
|
+
if (err) {
|
|
227
|
+
this.hasFailed = true;
|
|
228
|
+
return setImmediate(() => callback(err));
|
|
229
|
+
}
|
|
203
230
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return setImmediate(() => callback(err));
|
|
208
|
-
}
|
|
231
|
+
if (!data) {
|
|
232
|
+
return nextLine();
|
|
233
|
+
}
|
|
209
234
|
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
if (flush) {
|
|
236
|
+
if (group && group.type !== 'none') {
|
|
237
|
+
// do not include the last line ending for body
|
|
238
|
+
groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);
|
|
239
|
+
if (groupstart < groupend) {
|
|
240
|
+
// re-slice, the value the line was emitted with may
|
|
241
|
+
// still include the line ending we just trimmed
|
|
242
|
+
group.value = chunk.slice(groupstart, groupend);
|
|
243
|
+
if (groupend < i && 'value' in data) {
|
|
244
|
+
// the trimmed line ending belongs to the boundary line
|
|
245
|
+
data.value = chunk.slice(groupend, i);
|
|
212
246
|
}
|
|
247
|
+
}
|
|
248
|
+
// the group is pushed even when nothing is left of it, so that a
|
|
249
|
+
// part whose whole body is a line ending still reports a body
|
|
250
|
+
this.push(group);
|
|
251
|
+
group = {
|
|
252
|
+
type: 'none'
|
|
253
|
+
};
|
|
254
|
+
groupstart = groupend = i;
|
|
255
|
+
}
|
|
256
|
+
this.push(data);
|
|
257
|
+
groupend = i;
|
|
258
|
+
return nextLine();
|
|
259
|
+
}
|
|
213
260
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
group = {
|
|
231
|
-
type: 'none'
|
|
232
|
-
};
|
|
233
|
-
groupstart = groupend = i;
|
|
234
|
-
}
|
|
235
|
-
this.push(data);
|
|
236
|
-
groupend = i;
|
|
237
|
-
return setImmediate(iterateData);
|
|
261
|
+
if (data.type === group.type) {
|
|
262
|
+
// shift slice end position forward
|
|
263
|
+
groupend = i;
|
|
264
|
+
} else {
|
|
265
|
+
// do not include the last line ending for body
|
|
266
|
+
groupend = trimBodyLineEnd(group, chunk, groupstart, groupend);
|
|
267
|
+
|
|
268
|
+
if (group.type !== 'none' && group.type !== 'node') {
|
|
269
|
+
// we have a previous data/body chunk to output
|
|
270
|
+
if (groupstart !== groupend) {
|
|
271
|
+
group.value = chunk.slice(groupstart, groupend);
|
|
272
|
+
if (group.value && group.value.length) {
|
|
273
|
+
this.push(group);
|
|
274
|
+
group = {
|
|
275
|
+
type: 'none'
|
|
276
|
+
};
|
|
238
277
|
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
239
280
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
if (data.type === 'node') {
|
|
282
|
+
this.push(data);
|
|
283
|
+
groupstart = i;
|
|
284
|
+
groupend = i;
|
|
285
|
+
} else if (groupstart < 0) {
|
|
286
|
+
groupstart = i;
|
|
287
|
+
groupend = i;
|
|
288
|
+
checkTrailingLinebreak(data);
|
|
289
|
+
if (data.value && data.value.length) {
|
|
290
|
+
this.push(data);
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
// start new body/data chunk
|
|
294
|
+
group = data;
|
|
295
|
+
groupstart = groupend;
|
|
296
|
+
groupend = i;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return nextLine();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Iterates the current input chunk line by line and emits parsed groups.
|
|
304
|
+
*
|
|
305
|
+
* @returns {void}
|
|
306
|
+
*/
|
|
307
|
+
iterateData = () => {
|
|
308
|
+
yieldPos = i;
|
|
309
|
+
while (i < chunk.length) {
|
|
310
|
+
// find next <LF>
|
|
311
|
+
if (chunk[i] !== 0x0a) {
|
|
312
|
+
i++;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// line end
|
|
317
|
+
let start = Math.max(pos, 0);
|
|
318
|
+
pos = ++i;
|
|
319
|
+
|
|
320
|
+
inLine = true;
|
|
321
|
+
resumeInLine = false;
|
|
322
|
+
this.processLine(chunk.slice(start, i), false, onLine);
|
|
323
|
+
inLine = false;
|
|
324
|
+
if (!resumeInLine) {
|
|
325
|
+
// an error or a scheduled continuation takes over from here
|
|
326
|
+
return;
|
|
280
327
|
}
|
|
281
328
|
}
|
|
282
329
|
|
package/lib/mime-node.js
CHANGED
|
@@ -18,6 +18,32 @@ const pathlib = require('path');
|
|
|
18
18
|
|
|
19
19
|
const Libmime = /** @type {any} */ (libmime.Libmime);
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Removes everything from the first `(` to the last `)` of each line, the same result as
|
|
23
|
+
* `value.replace(/\(.*\)/g, '')`, but in linear time. The regex backtracks quadratically
|
|
24
|
+
* on a value with many `(` characters and no closing `)`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} value
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function stripComments(value) {
|
|
30
|
+
if (value.indexOf('(') < 0) {
|
|
31
|
+
// the common case, nothing to strip
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
return value
|
|
35
|
+
.split(/([\n\r\u2028\u2029])/)
|
|
36
|
+
.map(part => {
|
|
37
|
+
let start = part.indexOf('(');
|
|
38
|
+
if (start < 0) {
|
|
39
|
+
return part;
|
|
40
|
+
}
|
|
41
|
+
let end = part.lastIndexOf(')');
|
|
42
|
+
return end > start ? part.slice(0, start) + part.slice(end + 1) : part;
|
|
43
|
+
})
|
|
44
|
+
.join('');
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
/**
|
|
22
48
|
* Parsed MIME node with mutable headers and transfer-encoding helpers.
|
|
23
49
|
*/
|
|
@@ -142,11 +168,7 @@ class MimeNode {
|
|
|
142
168
|
this._parsedContentType = this.libmime.parseHeaderValue(contentHeader);
|
|
143
169
|
let parsedContentType = /** @type {StructuredHeader} */ (this._parsedContentType);
|
|
144
170
|
|
|
145
|
-
this.encoding = headers
|
|
146
|
-
.getFirst('Content-Transfer-Encoding')
|
|
147
|
-
.replace(/\(.*\)/g, '')
|
|
148
|
-
.toLowerCase()
|
|
149
|
-
.trim();
|
|
171
|
+
this.encoding = stripComments(headers.getFirst('Content-Transfer-Encoding')).toLowerCase().trim();
|
|
150
172
|
this.contentType = (parsedContentType.value || '').toLowerCase().trim() || false;
|
|
151
173
|
this.charset = parsedContentType.params.charset || false;
|
|
152
174
|
this.disposition = (parsedContentDisposition.value || '').toLowerCase().trim() || false;
|
package/lib/node-rewriter.js
CHANGED
|
@@ -203,9 +203,10 @@ class NodeRewriter extends Transform {
|
|
|
203
203
|
if (/^text\//.test(node.contentType || '') && node.flowed) {
|
|
204
204
|
// text/plain; format=flowed is a special case
|
|
205
205
|
let flowDecoder = decoder;
|
|
206
|
+
// the transfer encoding is already removed by node.getDecoder(), so the flowed
|
|
207
|
+
// decoder must not decode base64 a second time
|
|
206
208
|
decoder = new FlowedDecoder({
|
|
207
|
-
delSp: node.delSp
|
|
208
|
-
encoding: node.encoding || false
|
|
209
|
+
delSp: node.delSp
|
|
209
210
|
});
|
|
210
211
|
flowDecoder.on(
|
|
211
212
|
'error',
|
package/lib/types.d.ts
CHANGED
|
@@ -41,9 +41,6 @@ export interface ChunkedPassthroughOptions {
|
|
|
41
41
|
export interface FlowedDecoderOptions extends LibmimeOptions {
|
|
42
42
|
/** Whether format=flowed uses RFC 3676 `DelSp=yes` space deletion semantics. */
|
|
43
43
|
delSp?: boolean;
|
|
44
|
-
|
|
45
|
-
/** Source Content-Transfer-Encoding hint used during format=flowed handling. */
|
|
46
|
-
encoding?: string | false;
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
/** Parsed raw header line with a normalized lookup key. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zone-eu/mailsplit",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.19",
|
|
4
4
|
"description": "Split email messages into an object stream",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"author": "Andris Reinman",
|
|
16
16
|
"license": "(MIT OR EUPL-1.1+)",
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"libbase64": "1.3.
|
|
19
|
-
"libmime": "5.4.
|
|
20
|
-
"libqp": "2.1.
|
|
18
|
+
"libbase64": "1.3.1",
|
|
19
|
+
"libmime": "5.4.6",
|
|
20
|
+
"libqp": "2.1.2"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/eslint": "9.6.1",
|
|
@@ -25,7 +25,7 @@
|
|
|
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.6.3",
|
|
29
29
|
"eslint": "8.29.0",
|
|
30
30
|
"eslint-config-nodemailer": "1.2.0",
|
|
31
31
|
"eslint-config-prettier": "9.1.0",
|