emailengine-app 2.68.0 → 2.69.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.
Files changed (74) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +8 -0
  4. package/.github/workflows/release.yaml +4 -0
  5. package/.github/workflows/test.yml +3 -0
  6. package/CHANGELOG.md +49 -0
  7. package/SECURITY.md +80 -0
  8. package/SECURITY.txt +27 -0
  9. package/config/default.toml +2 -0
  10. package/data/google-crawlers.json +13 -1
  11. package/lib/account.js +62 -25
  12. package/lib/api-routes/account-routes.js +493 -75
  13. package/lib/api-routes/blocklist-routes.js +337 -0
  14. package/lib/api-routes/delivery-test-routes.js +321 -0
  15. package/lib/api-routes/export-routes.js +1 -12
  16. package/lib/api-routes/gateway-routes.js +376 -0
  17. package/lib/api-routes/license-routes.js +142 -0
  18. package/lib/api-routes/mailbox-routes.js +318 -0
  19. package/lib/api-routes/message-routes.js +21 -129
  20. package/lib/api-routes/oauth2-app-routes.js +631 -0
  21. package/lib/api-routes/outbox-routes.js +173 -0
  22. package/lib/api-routes/pubsub-routes.js +98 -0
  23. package/lib/api-routes/route-helpers.js +45 -0
  24. package/lib/api-routes/settings-routes.js +331 -0
  25. package/lib/api-routes/stats-routes.js +77 -0
  26. package/lib/api-routes/submit-routes.js +472 -0
  27. package/lib/api-routes/template-routes.js +7 -55
  28. package/lib/api-routes/token-routes.js +297 -0
  29. package/lib/api-routes/webhook-route-routes.js +152 -0
  30. package/lib/email-client/gmail-client.js +14 -0
  31. package/lib/email-client/imap/mailbox.js +34 -11
  32. package/lib/email-client/imap/subconnection.js +20 -12
  33. package/lib/email-client/imap/sync-operations.js +130 -2
  34. package/lib/email-client/imap-client.js +116 -58
  35. package/lib/email-client/outlook-client.js +85 -13
  36. package/lib/export.js +60 -19
  37. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  38. package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
  39. package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
  40. package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
  41. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  42. package/lib/imapproxy/imap-server.js +92 -29
  43. package/lib/message-port-stream.js +113 -16
  44. package/lib/reject-worker-calls.js +42 -0
  45. package/lib/routes-ui.js +37 -8778
  46. package/lib/schemas.js +26 -1
  47. package/lib/tools.js +73 -0
  48. package/lib/ui-routes/account-routes.js +40 -210
  49. package/lib/ui-routes/admin-config-routes.js +913 -487
  50. package/lib/ui-routes/admin-entities-routes.js +1 -0
  51. package/lib/ui-routes/auth-routes.js +1339 -0
  52. package/lib/ui-routes/dashboard-routes.js +188 -0
  53. package/lib/ui-routes/document-store-routes.js +800 -0
  54. package/lib/ui-routes/export-routes.js +217 -0
  55. package/lib/ui-routes/internals-routes.js +354 -0
  56. package/lib/ui-routes/network-config-routes.js +759 -0
  57. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  58. package/lib/ui-routes/route-helpers.js +316 -0
  59. package/lib/ui-routes/smtp-test-routes.js +236 -0
  60. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  61. package/lib/webhook-request.js +36 -0
  62. package/package.json +17 -17
  63. package/sbom.json +1 -1
  64. package/server.js +217 -19
  65. package/static/licenses.html +52 -182
  66. package/translations/messages.pot +131 -151
  67. package/views/dashboard.hbs +7 -26
  68. package/views/internals/index.hbs +15 -0
  69. package/views/tokens/index.hbs +9 -0
  70. package/workers/api.js +198 -4401
  71. package/workers/export.js +87 -54
  72. package/workers/imap.js +29 -13
  73. package/workers/submit.js +20 -11
  74. package/workers/webhooks.js +6 -20
package/lib/export.js CHANGED
@@ -114,6 +114,13 @@ async function getExportMaxAge() {
114
114
  return DEFAULT_EXPORT_MAX_AGE;
115
115
  }
116
116
 
117
+ // Resolve the retention window into the Redis `expire` TTL (seconds) and an absolute `expiresAt`
118
+ // timestamp (ms), used wherever an export key's expiry is (re)set.
119
+ async function getExportExpiry() {
120
+ const maxAge = await getExportMaxAge();
121
+ return { ttl: Math.ceil(maxAge / 1000), expiresAt: Date.now() + maxAge };
122
+ }
123
+
117
124
  function toTimestamp(date) {
118
125
  const ts = new Date(date).getTime();
119
126
  if (isNaN(ts)) {
@@ -168,7 +175,8 @@ class Export {
168
175
  startDate,
169
176
  endDate,
170
177
  textType: options.textType || '*',
171
- maxBytes: options.maxBytes || 5 * 1024 * 1024,
178
+ // Preserve an explicit 0 ("unlimited" per the API contract); only fall back when unset.
179
+ maxBytes: Number.isInteger(options.maxBytes) ? options.maxBytes : 5 * 1024 * 1024,
172
180
  includeAttachments: options.includeAttachments ? '1' : '0',
173
181
  isEncrypted: isEncrypted ? '1' : '0',
174
182
  foldersScanned: 0,
@@ -178,7 +186,6 @@ class Export {
178
186
  messagesSkipped: 0,
179
187
  bytesWritten: 0,
180
188
  filePath,
181
- lastProcessedScore: 0,
182
189
  created: now,
183
190
  expiresAt,
184
191
  error: ''
@@ -325,11 +332,39 @@ class Export {
325
332
 
326
333
  static async startProcessing(account, exportId) {
327
334
  const exportKey = getExportKey(account, exportId);
328
- const maxAge = await getExportMaxAge();
329
- const ttl = Math.ceil(maxAge / 1000);
330
- const newExpiresAt = Date.now() + maxAge;
335
+ const queueKey = getExportQueueKey(account, exportId);
336
+ const { ttl, expiresAt } = await getExportExpiry();
331
337
 
332
- await redis.multi().hmset(exportKey, { status: 'processing', phase: 'indexing', expiresAt: newExpiresAt }).expire(exportKey, ttl).exec();
338
+ // Reset progress and clear any previously indexed queue so that each (re)run -- including a
339
+ // BullMQ stalled-job reprocess -- rebuilds the queue and rewrites the (truncated) output file
340
+ // from scratch, keeping counters and file content consistent.
341
+ await redis
342
+ .multi()
343
+ .hmset(exportKey, {
344
+ status: 'processing',
345
+ phase: 'indexing',
346
+ expiresAt,
347
+ foldersScanned: 0,
348
+ foldersTotal: 0,
349
+ messagesQueued: 0,
350
+ messagesExported: 0,
351
+ messagesSkipped: 0,
352
+ bytesWritten: 0,
353
+ truncated: '0'
354
+ })
355
+ .del(queueKey)
356
+ .expire(exportKey, ttl)
357
+ .exec();
358
+ }
359
+
360
+ static async extendExpiry(account, exportId) {
361
+ const exportKey = getExportKey(account, exportId);
362
+ const queueKey = getExportQueueKey(account, exportId);
363
+ const { ttl, expiresAt } = await getExportExpiry();
364
+
365
+ // Keep both the export hash and the pending-message queue alive for the full retention window
366
+ // while a long export is still running, and surface the refreshed expiry to status readers.
367
+ await redis.multi().hset(exportKey, 'expiresAt', expiresAt).expire(exportKey, ttl).expire(queueKey, ttl).exec();
333
368
  }
334
369
 
335
370
  static async queueMessage(account, exportId, messageInfo) {
@@ -355,19 +390,23 @@ class Export {
355
390
  await multi.exec();
356
391
  }
357
392
 
358
- static async getNextBatch(account, exportId, lastScore, limit) {
393
+ static async getNextBatch(account, exportId, limit) {
359
394
  const queueKey = getExportQueueKey(account, exportId);
360
- // Use exclusive lower bound to avoid re-processing messages at batch boundaries
361
- const minScore = lastScore > 0 ? '(' + lastScore : lastScore;
362
- const results = await redis.zrangebyscore(queueKey, minScore, '+inf', 'WITHSCORES', 'LIMIT', 0, limit);
395
+ // Atomically pop the lowest-scored (oldest) messages so each is processed exactly once.
396
+ // This avoids cursor-based reprocessing and the boundary drops that an exclusive score bound
397
+ // caused when two messages shared the same score.
398
+ const results = await redis.zpopmin(queueKey, limit);
363
399
 
400
+ // ZPOPMIN returns [member, score, member, score, ...]; only the decoded member is needed.
364
401
  const messages = [];
365
402
  for (let i = 0; i < results.length; i += 2) {
366
403
  try {
367
- const info = msgpack.decode(Buffer.from(results[i], 'base64url'));
368
- messages.push({ ...info, score: Number(results[i + 1]) });
404
+ messages.push(msgpack.decode(Buffer.from(results[i], 'base64url')));
369
405
  } catch (err) {
370
406
  logger.error({ msg: 'Failed to decode message info', account, exportId, err });
407
+ // ZPOPMIN already removed the entry, so it will never be exported;
408
+ // count it as skipped to keep messagesQueued === exported + skipped
409
+ await Export.incrementSkipped(account, exportId);
371
410
  }
372
411
  }
373
412
 
@@ -383,19 +422,20 @@ class Export {
383
422
  await redis.hincrby(getExportKey(account, exportId), 'messagesSkipped', 1);
384
423
  }
385
424
 
386
- static async updateLastProcessedScore(account, exportId, score) {
387
- await redis.hset(getExportKey(account, exportId), 'lastProcessedScore', score);
388
- }
389
-
390
425
  static async complete(account, exportId) {
391
426
  const exportKey = getExportKey(account, exportId);
392
427
  const queueKey = getExportQueueKey(account, exportId);
393
428
 
429
+ // Start the retention window at completion time so a long-running export stays downloadable for
430
+ // the full retention period rather than the time left over from when processing started.
431
+ const { ttl, expiresAt } = await getExportExpiry();
432
+
394
433
  await redis
395
434
  .multi()
396
- .hmset(exportKey, { status: 'completed', phase: 'complete' })
435
+ .hmset(exportKey, { status: 'completed', phase: 'complete', expiresAt })
397
436
  .del(queueKey)
398
437
  .srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`)
438
+ .expire(exportKey, ttl)
399
439
  .exec();
400
440
 
401
441
  logger.info({ msg: 'Export completed', account, exportId });
@@ -437,8 +477,9 @@ class Export {
437
477
 
438
478
  for (const entry of activeExports) {
439
479
  try {
440
- // Find ':exp_' as separator since account IDs may contain colons
441
- const separatorIndex = entry.indexOf(':exp_');
480
+ // Find ':exp_' as separator since account IDs may contain colons. The export id is
481
+ // always the trailing ':exp_<hex>' segment, so match the last occurrence.
482
+ const separatorIndex = entry.lastIndexOf(':exp_');
442
483
  if (separatorIndex === -1) continue;
443
484
  const account = entry.substring(0, separatorIndex);
444
485
  const exportId = entry.substring(separatorIndex + 1);
@@ -15,6 +15,20 @@ module.exports = {
15
15
  });
16
16
  }
17
17
 
18
+ if (this._server.options.disableSTARTTLS) {
19
+ // STARTTLS is not advertised in this mode (e.g. the EmailEngine proxy);
20
+ // refuse it instead of upgrading against the built-in fallback cert.
21
+ return callback(null, {
22
+ response: 'NO',
23
+ message: 'STARTTLS not available'
24
+ });
25
+ }
26
+
27
+ // Block any further (possibly pipelined / injected) commands until the TLS
28
+ // handshake completes. _onCommand ignores commands while _upgrading is set,
29
+ // so a command sent in the same chunk as STARTTLS cannot be executed.
30
+ this._upgrading = true;
31
+
18
32
  setImmediate(upgrade.bind(null, this));
19
33
 
20
34
  callback(null, {
@@ -94,6 +108,10 @@ function upgrade(connection) {
94
108
  (cipher && cipher.name) || 'N/A'
95
109
  );
96
110
 
111
+ // Discard any plaintext the parser buffered before the handshake so it
112
+ // cannot be injected into the now-encrypted session.
113
+ connection._parser.reset();
114
+
97
115
  connection._socket.pipe(connection._parser);
98
116
  connection.writeStream.pipe(connection._socket);
99
117
  });
@@ -369,9 +369,14 @@ class IMAPCommand {
369
369
  countBadResponses() {
370
370
  this.connection._badCount++;
371
371
  if (this.connection._badCount > MAX_BAD_COMMANDS) {
372
- this.clearNotificationListener();
372
+ // Stop reading first so a command pipelined right after the offending input is not
373
+ // dispatched during the graceful-close window.
374
+ this.connection._stopReading();
375
+ this.connection.clearNotificationListener();
373
376
  this.connection.send('* BYE Too many protocol errors');
374
- setImmediate(() => this.connection.close(true));
377
+ // Graceful close so the BYE is flushed to the client before teardown (close() ends
378
+ // the socket and force-destroys via its own failsafe timer).
379
+ setImmediate(() => this.connection.close());
375
380
  return false;
376
381
  }
377
382
  return true;
@@ -14,6 +14,12 @@ const packageInfo = require('../../../../package');
14
14
 
15
15
  const SOCKET_TIMEOUT = 5 * 60 * 1000;
16
16
 
17
+ // Idle timeout applied to a proxied connection after auth handoff. Generous enough
18
+ // to not interrupt IMAP IDLE (which can sit idle for ~29 minutes) while still
19
+ // preventing a connection from being held open forever. Overridable via the
20
+ // proxyTimeout server option; 0 disables it (legacy behaviour).
21
+ const PROXY_SOCKET_TIMEOUT = 30 * 60 * 1000;
22
+
17
23
  /**
18
24
  * Creates a handler for new socket
19
25
  *
@@ -52,11 +58,15 @@ class IMAPConnection extends EventEmitter {
52
58
  this._upgrading = false;
53
59
 
54
60
  // Parser instance for the incoming stream
55
- this._parser = new IMAPStream();
61
+ this._parser = new IMAPStream({ maxLineLength: this._server.options.maxLineLength });
56
62
 
57
63
  // Set handler for incoming commands
58
64
  this._parser.oncommand = this._onCommand.bind(this);
59
65
 
66
+ // Close the connection if the parser rejects the input (e.g. an over-long
67
+ // command line) instead of letting the error reach the global handler.
68
+ this._parser.on('error', err => this._onParserError(err));
69
+
60
70
  // Manage multi part command
61
71
  this._currentCommand = false;
62
72
 
@@ -194,8 +204,12 @@ class IMAPConnection extends EventEmitter {
194
204
  }
195
205
 
196
206
  unbind() {
207
+ // Cancel any armed graceful-close failsafe so it cannot destroy the socket after we
208
+ // have handed it off to the proxy backend.
209
+ clearTimeout(this._closingTimeout);
210
+
197
211
  this.writeStream.unpipe(this._socket);
198
- this._socket.unpipe(this._parser);
212
+ this._stopReading();
199
213
 
200
214
  if (this._onCloseHandler) {
201
215
  this._socket.removeListener('close', this._onCloseHandler);
@@ -206,10 +220,38 @@ class IMAPConnection extends EventEmitter {
206
220
  if (this._onErrorHandler) {
207
221
  this._socket.removeListener('error', this._onErrorHandler);
208
222
  }
223
+ // Drop the auth-phase protocol timeout handler - after handoff it must not
224
+ // run, it would write IMAP responses into the proxied byte stream.
225
+ this._socket.setTimeout(0);
209
226
  if (this._onTimeoutHandler) {
210
- this._socket.setTimeout(0, this._onTimeoutHandler);
227
+ this._socket.removeListener('timeout', this._onTimeoutHandler);
211
228
  }
212
229
 
230
+ // Re-arm a generous idle timeout so the proxied connection cannot be held
231
+ // open indefinitely (fd/connection exhaustion). Use a plain socket destroy
232
+ // rather than the protocol-level handler. proxyTimeout: 0 disables it.
233
+ let proxyTimeout = this._server.options.proxyTimeout;
234
+ if (proxyTimeout === undefined || proxyTimeout === null) {
235
+ proxyTimeout = PROXY_SOCKET_TIMEOUT;
236
+ }
237
+ if (proxyTimeout) {
238
+ this._socket.setTimeout(proxyTimeout);
239
+ this._socket.once('timeout', () => {
240
+ try {
241
+ this._socket.destroy();
242
+ } catch (err) {
243
+ // ignore
244
+ }
245
+ });
246
+ }
247
+
248
+ // After handoff to proxy mode the connection's own close/end handlers are gone,
249
+ // so _onClose can never run to remove this connection from the server set.
250
+ // Drop it from the set when the handed-off socket finally closes to avoid a leak.
251
+ this._socket.once('close', () => {
252
+ this._server.connections.delete(this);
253
+ });
254
+
213
255
  return { socket: this._socket };
214
256
  }
215
257
 
@@ -274,42 +316,53 @@ class IMAPConnection extends EventEmitter {
274
316
  /**
275
317
  * Close socket
276
318
  */
277
- close(force) {
319
+ close() {
278
320
  if (this._closed || this._closing) {
279
321
  return;
280
322
  }
281
323
 
282
324
  if (!this._socket.destroyed && this._socket.writable) {
283
- this._socket[!force ? 'end' : 'destroy']();
325
+ this._socket.end();
284
326
  }
285
327
 
286
328
  this._server.connections.delete(this);
287
329
 
288
- if (!force) {
289
- // allow socket to close in 1500ms or force it to close
290
- this._closingTimeout = setTimeout(() => {
291
- if (this._closed) {
292
- return;
293
- }
330
+ // allow socket to close gracefully in 1500ms or force it to close
331
+ this._closingTimeout = setTimeout(() => {
332
+ if (this._closed) {
333
+ return;
334
+ }
294
335
 
295
- try {
296
- this._socket.destroy();
297
- } catch (err) {
298
- // ignore
299
- }
336
+ try {
337
+ this._socket.destroy();
338
+ } catch (err) {
339
+ // ignore
340
+ }
300
341
 
301
- setImmediate(() => this._onClose());
302
- }, 1500);
303
- }
342
+ setImmediate(() => this._onClose());
343
+ }, 1500);
304
344
 
305
345
  this._closing = true;
306
- if (force) {
307
- setImmediate(() => this._onClose());
308
- }
309
346
  }
310
347
 
311
348
  // PRIVATE METHODS
312
349
 
350
+ /**
351
+ * Stop feeding the socket into the command parser. Called before teardown so a client
352
+ * cannot pipeline another command (e.g. LOGIN) after we have decided to close - which
353
+ * would otherwise be dispatched during the graceful-close window. Safe to call more than
354
+ * once; a no-op once the parser has been torn down in _onClose().
355
+ */
356
+ _stopReading() {
357
+ try {
358
+ if (this._parser) {
359
+ this._socket.unpipe(this._parser);
360
+ }
361
+ } catch (err) {
362
+ // ignore
363
+ }
364
+ }
365
+
313
366
  /**
314
367
  * Setup socket event handlers
315
368
  */
@@ -327,6 +380,34 @@ class IMAPConnection extends EventEmitter {
327
380
  this._socket.pipe(this._parser);
328
381
  }
329
382
 
383
+ /**
384
+ * Fired when the incoming-stream parser rejects the input (e.g. an over-long
385
+ * command line). Stops feeding the parser, tells the client, and closes.
386
+ * @param {Error} err - Parser error
387
+ */
388
+ _onParserError(err) {
389
+ this._stopReading();
390
+
391
+ this.logger.info(
392
+ {
393
+ tnx: 'parser',
394
+ cid: this.id
395
+ },
396
+ 'Closing connection: %s',
397
+ err && err.message
398
+ );
399
+
400
+ try {
401
+ this.send('* BYE ' + ((err && err.message) || 'Protocol error'));
402
+ } catch (E) {
403
+ // ignore
404
+ }
405
+
406
+ // Graceful close so the BYE is flushed before teardown; close() force-destroys via
407
+ // its own failsafe timer if the socket does not close on its own.
408
+ setImmediate(() => this.close());
409
+ }
410
+
330
411
  /**
331
412
  * Fired when the socket is closed
332
413
  * @event
@@ -482,8 +563,12 @@ class IMAPConnection extends EventEmitter {
482
563
  return;
483
564
  }
484
565
 
566
+ // Stop reading first so a command pipelined right after the timeout is not dispatched
567
+ // during the graceful-close window. Graceful close still flushes the BYE before teardown
568
+ // (close() has a failsafe timer).
569
+ this._stopReading();
485
570
  this.send('* BYE Idle timeout, closing connection');
486
- setImmediate(() => this.close(true));
571
+ setImmediate(() => this.close());
487
572
  }
488
573
 
489
574
  /**
@@ -497,6 +582,11 @@ class IMAPConnection extends EventEmitter {
497
582
 
498
583
  callback = callback || (() => false);
499
584
 
585
+ if (this._closing || this._closed) {
586
+ // connection is tearing down - do not dispatch further commands
587
+ return callback();
588
+ }
589
+
500
590
  if (this._upgrading) {
501
591
  // ignore any commands before TLS upgrade is finished
502
592
  return callback();
@@ -12,6 +12,10 @@ const base32 = require('base32.js');
12
12
 
13
13
  const CLOSE_TIMEOUT = 1 * 1000; // how much to wait until pending connections are terminated
14
14
 
15
+ // A PROXY protocol v1 header is at most ~107 bytes. Bound the buffer so a trusted
16
+ // proxy source that never sends a newline cannot grow memory without limit.
17
+ const MAX_PROXY_HEADER_SIZE = 256;
18
+
15
19
  /**
16
20
  * Creates a IMAP server instance.
17
21
  *
@@ -258,7 +262,7 @@ class IMAPServer extends EventEmitter {
258
262
  },
259
263
  'PROXY from %s through %s (%s)',
260
264
  params[1].trim().toLowerCase(),
261
- params[2].trim().toLowerCase(),
265
+ (params[2] || '').trim().toLowerCase(),
262
266
  JSON.stringify(params)
263
267
  );
264
268
  }
@@ -273,6 +277,26 @@ class IMAPServer extends EventEmitter {
273
277
  }
274
278
  chunks.push(chunk);
275
279
  chunklen += chunk.length;
280
+
281
+ if (chunklen > MAX_PROXY_HEADER_SIZE) {
282
+ socket.removeListener('readable', socketReader);
283
+ try {
284
+ socket.end('* BAD Invalid PROXY header\r\n');
285
+ } catch (E) {
286
+ // ignore
287
+ }
288
+ // end() only half-closes the socket; arm a short timeout so the fd is
289
+ // released even if the peer never closes its side (fd exhaustion).
290
+ socket.setTimeout(CLOSE_TIMEOUT);
291
+ socket.once('timeout', () => {
292
+ try {
293
+ socket.destroy();
294
+ } catch (E) {
295
+ // ignore
296
+ }
297
+ });
298
+ return;
299
+ }
276
300
  }
277
301
  };
278
302
  socket.on('readable', socketReader);
@@ -4,6 +4,11 @@ const stream = require('stream');
4
4
  const Writable = stream.Writable;
5
5
  const PassThrough = stream.PassThrough;
6
6
 
7
+ // Upper bound for a single command line (no CRLF yet). Generous - real IMAP command
8
+ // lines are short and bulk data is sent as size-bounded literals - but it prevents a
9
+ // client that never sends a newline from growing memory without limit (OOM).
10
+ const MAX_LINE_LENGTH = 1 * 1024 * 1024;
11
+
7
12
  /**
8
13
  * Incoming IMAP stream parser. Detects and emits command payloads.
9
14
  * If literal values are encountered the command payload is split into parts
@@ -21,6 +26,9 @@ class IMAPStream extends Writable {
21
26
  this.options = options || {};
22
27
  Writable.call(this, this.options);
23
28
 
29
+ // largest single command line we will buffer before giving up
30
+ this._maxLineLength = Number(this.options.maxLineLength) || MAX_LINE_LENGTH;
31
+
24
32
  // unprocessed chars from the last parsing iteration
25
33
  this._remainder = '';
26
34
  this._literal = false;
@@ -40,6 +48,18 @@ class IMAPStream extends Writable {
40
48
  throw new Error('Command handler is not set');
41
49
  }
42
50
 
51
+ /**
52
+ * Discards any buffered or partial parser state. Used on TLS upgrade so that
53
+ * plaintext pipelined before STARTTLS cannot be parsed as commands inside the
54
+ * encrypted session (STARTTLS command injection).
55
+ */
56
+ reset() {
57
+ this._remainder = '';
58
+ this._literal = false;
59
+ this._literalReady = false;
60
+ this._expecting = 0;
61
+ }
62
+
43
63
  // PRIVATE METHODS
44
64
 
45
65
  /**
@@ -107,6 +127,12 @@ class IMAPStream extends Writable {
107
127
  pos += line.length + match[0].length;
108
128
  } else {
109
129
  this._remainder = pos < data.length ? data.substr(pos) : '';
130
+ if (this._remainder.length > this._maxLineLength) {
131
+ // Drop the buffered data and fail the stream - the connection layer
132
+ // closes the socket on a parser error.
133
+ this._remainder = '';
134
+ return done(new Error('Command line too long'));
135
+ }
110
136
  return done();
111
137
  }
112
138
 
@@ -71,6 +71,30 @@ async function call(message, transferList) {
71
71
  });
72
72
  }
73
73
 
74
+ // Resolve pending call() promises when the main thread answers. Without this any
75
+ // RPC issued from this worker (via the `call` passed into Account) would never settle
76
+ // and would reject on timeout. Mirrors the handlers in workers/imap.js and smtp.js.
77
+ parentPort.on('message', message => {
78
+ if (message && message.cmd === 'resp' && message.mid && callQueue.has(message.mid)) {
79
+ let { resolve, reject, timer } = callQueue.get(message.mid);
80
+ clearTimeout(timer);
81
+ callQueue.delete(message.mid);
82
+
83
+ if (message.error) {
84
+ let err = new Error(message.error);
85
+ if (message.code) {
86
+ err.code = message.code;
87
+ }
88
+ if (message.statusCode) {
89
+ err.statusCode = message.statusCode;
90
+ }
91
+ return reject(err);
92
+ }
93
+
94
+ return resolve(message.response);
95
+ }
96
+ });
97
+
74
98
  async function metrics(logger, key, method, ...args) {
75
99
  try {
76
100
  parentPort.postMessage({
@@ -86,7 +110,7 @@ async function metrics(logger, key, method, ...args) {
86
110
 
87
111
  const { ImapFlow } = require('imapflow');
88
112
  const { IMAPServer, imapHandler } = require('./imap-core/index.js');
89
- const { PassThrough } = require('./imap-core/lib/length-limiter.js');
113
+ const { PassThrough } = require('stream');
90
114
 
91
115
  const packageInfo = require('../../package.json');
92
116
  const util = require('util');
@@ -113,7 +137,7 @@ class PassThroughLogger extends PassThrough {
113
137
  data: chunk.toString('base64'),
114
138
  compress: !!this.imapClient._deflate,
115
139
  secure: !!this.imapClient.secureConnection,
116
- cid: this.id
140
+ cid: this.cid
117
141
  });
118
142
  }
119
143
 
@@ -361,7 +385,7 @@ const createServer = function (options = {}) {
361
385
  message = util.format(message, ...args);
362
386
  }
363
387
  data.msg = message;
364
- if (typeof logger[level] === 'function') {
388
+ if (typeof serverLogger[level] === 'function') {
365
389
  serverLogger[level](data);
366
390
  } else {
367
391
  serverLogger.debug(data);
@@ -408,39 +432,78 @@ const createServer = function (options = {}) {
408
432
  logger: proxyLogger.child({ src: 'C' })
409
433
  });
410
434
 
435
+ // Idempotent teardown for both legs of the proxy. ImapFlow.close() is
436
+ // safe after unbind(): it sends no LOGOUT, clears timers (including
437
+ // autoidle), removes the deflate/writeSocket error forwarders and
438
+ // destroys the upstream socket. Without it the upstream connection and
439
+ // its idle timer would leak (most visibly with COMPRESS enabled).
440
+ let proxyClosed = false;
441
+ const closeProxy = () => {
442
+ if (proxyClosed) {
443
+ return;
444
+ }
445
+ proxyClosed = true;
446
+
447
+ try {
448
+ downstream.imapClient.close();
449
+ } catch (err) {
450
+ proxyLogger.error({ msg: 'Failed to close upstream connection', err });
451
+ }
452
+
453
+ try {
454
+ if (upstream.socket && !upstream.socket.destroyed) {
455
+ upstream.socket.end();
456
+ }
457
+ } catch (err) {
458
+ // ignore
459
+ }
460
+ };
461
+
462
+ // Every terminal event from either leg funnels into the idempotent
463
+ // closeProxy(). The helper keeps that wiring declarative; pass a message
464
+ // to log (level defaults to 'error', use 'info' for graceful closes).
465
+ const teardownOn = (emitter, event, msg, level = 'error') => {
466
+ emitter.on(event, err => {
467
+ if (msg) {
468
+ let entry = { msg };
469
+ if (err) {
470
+ entry.err = err;
471
+ }
472
+ proxyLogger[level](entry);
473
+ }
474
+ closeProxy();
475
+ });
476
+ };
477
+
411
478
  downstream.readSocket.pipe(upstreamLogger).pipe(upstream.socket);
412
479
  upstream.socket.pipe(downstreamLogger).pipe(downstream.writeSocket);
413
480
 
414
- upstreamLogger.on('error', err => {
415
- proxyLogger.error({ msg: 'Client error', err });
416
- upstream.socket.end();
417
- });
418
-
419
- downstreamLogger.on('error', err => {
420
- proxyLogger.error({ msg: 'Server error', err });
421
- downstream.writeSocket.end();
422
- downstream.readSocket.end();
423
- });
481
+ teardownOn(upstreamLogger, 'error', 'Proxy stream error (to client)');
482
+ teardownOn(downstreamLogger, 'error', 'Proxy stream error (to upstream)');
483
+ teardownOn(upstream.socket, 'error', 'Client socket error');
484
+ teardownOn(upstream.socket, 'end', 'Client connection closed', 'info');
485
+ teardownOn(upstream.socket, 'close');
486
+ teardownOn(downstream.readSocket, 'end', 'Upstream connection closed', 'info');
424
487
 
425
488
  downstream.readSocket.on('error', err => {
426
- proxyLogger.error({ msg: 'Client error', err });
427
- upstreamLogger.end('* BYE Upstream connection error\r\n');
428
- });
429
-
430
- upstream.socket.on('error', err => {
431
- proxyLogger.error({ msg: 'Upstream error', err });
432
- downstreamLogger.end();
489
+ proxyLogger.error({ msg: 'Upstream read error', err });
490
+ try {
491
+ // best-effort notice to the client before tearing down
492
+ upstream.socket.write('* BYE Upstream connection error\r\n');
493
+ } catch (e) {
494
+ // ignore
495
+ }
496
+ closeProxy();
433
497
  });
434
498
 
435
- downstream.readSocket.on('end', () => {
436
- proxyLogger.info({ msg: 'Client connection closed' });
437
- upstreamLogger.end();
438
- });
439
-
440
- upstream.socket.on('end', () => {
441
- proxyLogger.info({ msg: 'Server connection closed' });
442
- downstreamLogger.end();
443
- });
499
+ // With COMPRESS enabled, readSocket/writeSocket are the inflate/deflate
500
+ // streams, not the raw upstream socket, and unbind() removed ImapFlow's
501
+ // own listeners from that socket. An upstream reset would then emit an
502
+ // 'error' with no listener and crash the worker - guard it explicitly.
503
+ if (downstream.imapClient.socket && downstream.imapClient.socket !== downstream.readSocket) {
504
+ teardownOn(downstream.imapClient.socket, 'error', 'Upstream socket error');
505
+ teardownOn(downstream.imapClient.socket, 'close');
506
+ }
444
507
 
445
508
  proxyLogger.info({ msg: 'Proxy mode enabled' });
446
509
  };