@zone-eu/mailsplit 5.4.17 → 5.4.18

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.
@@ -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
- /** @type {Array<string | HeaderLine>} */
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
- for (let i = lines.length - 1; i >= 0; i--) {
375
- let currentLine = /** @type {string} */ (lines[i]);
376
- if (i && this._isFoldingChar(currentLine.charAt(0))) {
377
- lines[i - 1] = /** @type {string} */ (lines[i - 1]) + '\r\n' + currentLine;
378
- lines.splice(i, 1);
379
- } else {
380
- let line = currentLine;
381
- if (!i && /^From /i.test(line)) {
382
- // mbox file
383
- this.mbox = line;
384
- lines.splice(i, 1);
385
- continue;
386
- } else if (!i && /^POST /i.test(line)) {
387
- // HTTP POST request
388
- this.http = line;
389
- lines.splice(i, 1);
390
- continue;
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
- let key = this._normalizeHeader(line.substr(0, line.indexOf(':')));
393
- lines[i] = {
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 = /** @type {HeaderLine[]} */ (lines);
410
+ this.lines = lines;
401
411
  this.parsed = true;
402
412
  }
403
413
 
@@ -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
- * Iterates the current input chunk line by line and emits parsed groups.
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 iterateData = () => {
196
- for (let len = chunk.length; i < len; i++) {
197
- // find next <LF>
198
- if (chunk[i] === 0x0a) {
199
- // line end
211
+ let nextLine = () => {
212
+ if (inLine && i - yieldPos < YIELD_BYTES) {
213
+ resumeInLine = true;
214
+ return;
215
+ }
216
+ setImmediate(iterateData);
217
+ };
200
218
 
201
- let start = Math.max(pos, 0);
202
- pos = ++i;
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
- return this.processLine(chunk.slice(start, i), false, (err, data, flush) => {
205
- if (err) {
206
- this.hasFailed = true;
207
- return setImmediate(() => callback(err));
208
- }
231
+ if (!data) {
232
+ return nextLine();
233
+ }
209
234
 
210
- if (!data) {
211
- return setImmediate(iterateData);
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
- 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);
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
- 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
- });
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;
@@ -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.17",
3
+ "version": "5.4.18",
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.4",
19
+ "libmime": "5.4.5",
20
20
  "libqp": "2.1.1"
21
21
  },
22
22
  "devDependencies": {
@@ -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.5.1",
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",