emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
@@ -369,9 +369,14 @@ class IMAPCommand {
369
369
  countBadResponses() {
370
370
  this.connection._badCount++;
371
371
  if (this.connection._badCount > MAX_BAD_COMMANDS) {
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();
372
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,8 +220,29 @@ 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);
228
+ }
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
+ });
211
246
  }
212
247
 
213
248
  // After handoff to proxy mode the connection's own close/end handlers are gone,
@@ -281,42 +316,53 @@ class IMAPConnection extends EventEmitter {
281
316
  /**
282
317
  * Close socket
283
318
  */
284
- close(force) {
319
+ close() {
285
320
  if (this._closed || this._closing) {
286
321
  return;
287
322
  }
288
323
 
289
324
  if (!this._socket.destroyed && this._socket.writable) {
290
- this._socket[!force ? 'end' : 'destroy']();
325
+ this._socket.end();
291
326
  }
292
327
 
293
328
  this._server.connections.delete(this);
294
329
 
295
- if (!force) {
296
- // allow socket to close in 1500ms or force it to close
297
- this._closingTimeout = setTimeout(() => {
298
- if (this._closed) {
299
- return;
300
- }
330
+ // allow socket to close gracefully in 1500ms or force it to close
331
+ this._closingTimeout = setTimeout(() => {
332
+ if (this._closed) {
333
+ return;
334
+ }
301
335
 
302
- try {
303
- this._socket.destroy();
304
- } catch (err) {
305
- // ignore
306
- }
336
+ try {
337
+ this._socket.destroy();
338
+ } catch (err) {
339
+ // ignore
340
+ }
307
341
 
308
- setImmediate(() => this._onClose());
309
- }, 1500);
310
- }
342
+ setImmediate(() => this._onClose());
343
+ }, 1500);
311
344
 
312
345
  this._closing = true;
313
- if (force) {
314
- setImmediate(() => this._onClose());
315
- }
316
346
  }
317
347
 
318
348
  // PRIVATE METHODS
319
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
+
320
366
  /**
321
367
  * Setup socket event handlers
322
368
  */
@@ -334,6 +380,34 @@ class IMAPConnection extends EventEmitter {
334
380
  this._socket.pipe(this._parser);
335
381
  }
336
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
+
337
411
  /**
338
412
  * Fired when the socket is closed
339
413
  * @event
@@ -489,8 +563,12 @@ class IMAPConnection extends EventEmitter {
489
563
  return;
490
564
  }
491
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();
492
570
  this.send('* BYE Idle timeout, closing connection');
493
- setImmediate(() => this.close(true));
571
+ setImmediate(() => this.close());
494
572
  }
495
573
 
496
574
  /**
@@ -504,6 +582,11 @@ class IMAPConnection extends EventEmitter {
504
582
 
505
583
  callback = callback || (() => false);
506
584
 
585
+ if (this._closing || this._closed) {
586
+ // connection is tearing down - do not dispatch further commands
587
+ return callback();
588
+ }
589
+
507
590
  if (this._upgrading) {
508
591
  // ignore any commands before TLS upgrade is finished
509
592
  return callback();
@@ -777,7 +860,6 @@ class IMAPConnection extends EventEmitter {
777
860
  if (existsResponse && !changed) {
778
861
  // send cached EXISTS response
779
862
  this.writeStream.write(existsResponse);
780
- existsResponse = false;
781
863
  }
782
864
 
783
865
  if (changed) {
@@ -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
  *
@@ -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
 
package/lib/logger.js CHANGED
@@ -19,22 +19,13 @@ let logger = pino({
19
19
  log(object) {
20
20
  if (object.err && ['TypeError', 'RangeError'].includes(object.err.name)) {
21
21
  if (logger.notifyError) {
22
- logger.notifyError(object.err, event => {
23
- if (object.account) {
24
- event.setUser(object.account);
22
+ let meta = {};
23
+ for (let key of ['msg', 'path', 'cid']) {
24
+ if (object[key]) {
25
+ meta[key] = object[key];
25
26
  }
26
- let meta = {};
27
- let hasMeta = false;
28
- for (let key of ['msg', 'path', 'cid']) {
29
- if (object[key]) {
30
- meta[key] = object[key];
31
- hasMeta = true;
32
- }
33
- }
34
- if (hasMeta) {
35
- event.addMetadata('ee', meta);
36
- }
37
- });
27
+ }
28
+ logger.notifyError(object.err, { user: object.account, meta });
38
29
  }
39
30
  }
40
31
  return object;
@@ -49,6 +40,22 @@ if (threadId) {
49
40
  logger = logger.child({ tid: threadId });
50
41
  }
51
42
 
43
+ // An error that reaches the global handlers leaves the process in an unknown state,
44
+ // so the process must always exit. If error tracking is enabled, report the error
45
+ // first and allow a short flush window for the delivery.
46
+ function fatalShutdown(code, err) {
47
+ if (logger.notifyError) {
48
+ logger.notifyError(err);
49
+ }
50
+
51
+ let exit = () => process.exit(code);
52
+ if (logger.flushNotifications) {
53
+ logger.flushNotifications().then(exit, exit);
54
+ } else {
55
+ setTimeout(exit, 10);
56
+ }
57
+ }
58
+
52
59
  process.on('uncaughtException', err => {
53
60
  logger.fatal({
54
61
  msg: 'uncaughtException',
@@ -56,9 +63,7 @@ process.on('uncaughtException', err => {
56
63
  err
57
64
  });
58
65
 
59
- if (!logger.notifyError) {
60
- setTimeout(() => process.exit(1), 10);
61
- }
66
+ fatalShutdown(1, err);
62
67
  });
63
68
 
64
69
  process.on('unhandledRejection', err => {
@@ -68,9 +73,7 @@ process.on('unhandledRejection', err => {
68
73
  err
69
74
  });
70
75
 
71
- if (!logger.notifyError) {
72
- setTimeout(() => process.exit(2), 10);
73
- }
76
+ fatalShutdown(2, err);
74
77
  });
75
78
 
76
79
  module.exports = logger;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { Writable, Readable } = require('stream');
3
+ const { Writable, Readable, pipeline } = require('stream');
4
4
 
5
5
  // const { MessageChannel } = require('worker_threads');
6
6
  // const { port1, port2 } = new MessageChannel();
@@ -9,6 +9,41 @@ class MessagePortWritable extends Writable {
9
9
  constructor(messagePort) {
10
10
  super();
11
11
  this.messagePort = messagePort;
12
+ this.portClosed = false;
13
+
14
+ // The reader side posts { cancel: true } when it is torn down (e.g. the
15
+ // HTTP client aborted the download). Stop the transfer so the upstream
16
+ // source - and the IMAP lock it holds - is released.
17
+ this.onPortMessage = message => {
18
+ if (message && message.cancel) {
19
+ this.destroy();
20
+ }
21
+ };
22
+ this.messagePort.on('message', this.onPortMessage);
23
+ }
24
+
25
+ postToPort(message) {
26
+ if (this.portClosed) {
27
+ return;
28
+ }
29
+ try {
30
+ this.messagePort.postMessage(message);
31
+ } catch (err) {
32
+ // ignore - the channel may already be torn down
33
+ }
34
+ }
35
+
36
+ closePort() {
37
+ if (this.portClosed) {
38
+ return;
39
+ }
40
+ this.portClosed = true;
41
+ this.messagePort.removeListener('message', this.onPortMessage);
42
+ try {
43
+ this.messagePort.close();
44
+ } catch (err) {
45
+ // ignore
46
+ }
12
47
  }
13
48
 
14
49
  _write(chunk, encoding, done) {
@@ -20,24 +55,25 @@ class MessagePortWritable extends Writable {
20
55
  chunk = Buffer.from(chunk, encoding);
21
56
  }
22
57
 
23
- this.messagePort.postMessage({
24
- value: chunk,
25
- done: false
26
- });
58
+ this.postToPort({ value: chunk, done: false });
27
59
  done();
28
60
  }
29
61
 
30
62
  _final(done) {
31
- this.messagePort.postMessage({
32
- done: true
33
- });
34
- try {
35
- this.messagePort.close();
36
- } catch (err) {
37
- //ignore
38
- }
63
+ this.postToPort({ done: true });
64
+ this.closePort();
39
65
  done();
40
66
  }
67
+
68
+ _destroy(err, done) {
69
+ // Abnormal termination: tell the reader so a truncated transfer is not
70
+ // mistaken for a complete message, then release the port.
71
+ if (err) {
72
+ this.postToPort({ error: err.message || 'Stream error' });
73
+ }
74
+ this.closePort();
75
+ done(err);
76
+ }
41
77
  }
42
78
 
43
79
  class MessagePortReadable extends Readable {
@@ -46,17 +82,28 @@ class MessagePortReadable extends Readable {
46
82
  this.messagePort = messagePort;
47
83
 
48
84
  this.canRead = false;
85
+ this.portClosed = false;
49
86
 
50
87
  this.readableQueue = [];
51
- this.messagePort.on('message', message => {
52
- if (message && (message.done || message.value)) {
88
+ this.onPortMessage = message => {
89
+ if (!message) {
90
+ return;
91
+ }
92
+ if (message.error) {
93
+ // The producer failed mid-transfer - surface it as a stream error
94
+ // rather than letting the consumer believe it received everything.
95
+ this.destroy(new Error(message.error));
96
+ return;
97
+ }
98
+ if (message.done || message.value) {
53
99
  this.readableQueue.push(message);
54
100
 
55
101
  if (this.canRead && this.readableQueue.length === 1) {
56
102
  this._processReading();
57
103
  }
58
104
  }
59
- });
105
+ };
106
+ this.messagePort.on('message', this.onPortMessage);
60
107
  }
61
108
 
62
109
  _processReading() {
@@ -73,7 +120,57 @@ class MessagePortReadable extends Readable {
73
120
  this.canRead = true;
74
121
  this._processReading();
75
122
  }
123
+
124
+ _destroy(err, done) {
125
+ // Consumer is gone (client aborted, error, or clean end): tell the producer
126
+ // to stop and release the port + listener so nothing leaks across threads.
127
+ if (!this.portClosed) {
128
+ this.portClosed = true;
129
+ try {
130
+ this.messagePort.postMessage({ cancel: true });
131
+ } catch (cancelErr) {
132
+ // ignore - the peer may already be closed
133
+ }
134
+ this.messagePort.removeListener('message', this.onPortMessage);
135
+ try {
136
+ this.messagePort.close();
137
+ } catch (closeErr) {
138
+ // ignore
139
+ }
140
+ }
141
+ done(err);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Pipes a readable source into a MessagePortWritable so that an error on either
147
+ * side tears the transfer down instead of surfacing as an unhandled 'error'
148
+ * event. A mid-download failure on an IMAP source stream would otherwise crash
149
+ * the worker (and every account assigned to it).
150
+ *
151
+ * @param {Readable} source - Source stream (e.g. an IMAP download stream)
152
+ * @param {MessagePortWritable} writable - Destination bound to a MessagePort
153
+ * @param {Object} [logger] - Optional logger used to report transfer failures
154
+ */
155
+ function pipeToMessagePort(source, writable, logger) {
156
+ pipeline(source, writable, err => {
157
+ if (!err || !logger) {
158
+ return;
159
+ }
160
+ // A consumer that aborts mid-download closes the destination early; that
161
+ // is expected (not a failure) so it should not be logged at error level.
162
+ if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
163
+ if (typeof logger.debug === 'function') {
164
+ logger.debug({ msg: 'Message stream transfer aborted by consumer', err });
165
+ }
166
+ return;
167
+ }
168
+ if (typeof logger.error === 'function') {
169
+ logger.error({ msg: 'Message stream transfer failed', err });
170
+ }
171
+ });
76
172
  }
77
173
 
78
174
  module.exports.MessagePortWritable = MessagePortWritable;
79
175
  module.exports.MessagePortReadable = MessagePortReadable;
176
+ module.exports.pipeToMessagePort = pipeToMessagePort;
@@ -91,8 +91,6 @@ class MetricsCollector {
91
91
  * Collect metrics in background (sequential to avoid CPU spikes)
92
92
  */
93
93
  async collectInBackground() {
94
- const startTime = Date.now();
95
-
96
94
  // Start with main thread info
97
95
  let threadsInfo = [
98
96
  Object.assign(
@@ -339,10 +339,6 @@ class OAuth2AppsHandler {
339
339
  apps: []
340
340
  };
341
341
 
342
- if (idList.length <= startPos) {
343
- return response;
344
- }
345
-
346
342
  //let keys = idList.slice(startPos, startPos + pageSize);
347
343
  let keys = idList;
348
344
 
@@ -406,6 +402,8 @@ class OAuth2AppsHandler {
406
402
  });
407
403
  }
408
404
 
405
+ // Recount after undecodable entries were skipped and query filters were applied
406
+ response.total = response.apps.length;
409
407
  response.pages = Math.ceil(response.apps.length / pageSize);
410
408
  response.apps = response.apps.slice(startPos, startPos + pageSize);
411
409
 
@@ -747,6 +745,17 @@ class OAuth2AppsHandler {
747
745
 
748
746
  async update(id, data, opts) {
749
747
  opts = opts || {};
748
+
749
+ // `tenant` is a UI-style alias for an Outlook directory tenant ID. Only honor it when
750
+ // the caller explicitly selected it via authority='tenant' (the UI form convention) -
751
+ // a stray tenant value alone must not overwrite the stored authority.
752
+ if (data && typeof data === 'object' && 'tenant' in data) {
753
+ if (data.tenant && data.authority === 'tenant') {
754
+ data.authority = data.tenant;
755
+ }
756
+ delete data.tenant;
757
+ }
758
+
750
759
  if (LEGACY_KEYS.includes(id)) {
751
760
  // legacy
752
761
  return await this.updateLegacyApp(id, data);