emailengine-app 2.69.0 → 2.71.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 (97) hide show
  1. package/.github/workflows/deploy.yml +6 -3
  2. package/.github/workflows/release.yaml +2 -0
  3. package/.github/workflows/test.yml +73 -12
  4. package/.ncurc.js +3 -3
  5. package/CHANGELOG.md +37 -0
  6. package/Gruntfile.js +21 -23
  7. package/bin/emailengine.js +8 -1
  8. package/config/default.toml +5 -0
  9. package/config/test.toml +5 -0
  10. package/data/google-crawlers.json +1 -1
  11. package/getswagger.sh +44 -4
  12. package/gettext-extract.js +163 -0
  13. package/lib/account.js +104 -72
  14. package/lib/api-routes/account-routes.js +231 -71
  15. package/lib/api-routes/blocklist-routes.js +25 -18
  16. package/lib/api-routes/chat-routes.js +32 -14
  17. package/lib/api-routes/delivery-test-routes.js +30 -5
  18. package/lib/api-routes/export-routes.js +27 -2
  19. package/lib/api-routes/gateway-routes.js +63 -12
  20. package/lib/api-routes/license-routes.js +18 -4
  21. package/lib/api-routes/mailbox-routes.js +33 -7
  22. package/lib/api-routes/message-routes.js +291 -145
  23. package/lib/api-routes/oauth2-app-routes.js +90 -24
  24. package/lib/api-routes/outbox-routes.js +16 -4
  25. package/lib/api-routes/pubsub-routes.js +8 -4
  26. package/lib/api-routes/route-helpers.js +14 -1
  27. package/lib/api-routes/settings-routes.js +51 -25
  28. package/lib/api-routes/stats-routes.js +37 -3
  29. package/lib/api-routes/submit-routes.js +31 -42
  30. package/lib/api-routes/template-routes.js +54 -21
  31. package/lib/api-routes/token-routes.js +67 -67
  32. package/lib/api-routes/webhook-route-routes.js +37 -8
  33. package/lib/autodetect-imap-settings.js +0 -2
  34. package/lib/consts.js +5 -0
  35. package/lib/document-store.js +22 -1
  36. package/lib/email-client/base-client.js +31 -8
  37. package/lib/email-client/gmail-client.js +119 -112
  38. package/lib/email-client/imap/mailbox.js +2 -2
  39. package/lib/email-client/imap/subconnection.js +0 -1
  40. package/lib/email-client/imap/sync-operations.js +1 -1
  41. package/lib/email-client/imap-client.js +36 -17
  42. package/lib/email-client/notification-handler.js +3 -6
  43. package/lib/email-client/outlook-client.js +49 -62
  44. package/lib/export.js +49 -1
  45. package/lib/feature-flags.js +8 -2
  46. package/lib/gateway.js +4 -9
  47. package/lib/get-raw-email.js +5 -5
  48. package/lib/imapproxy/imap-core/lib/imap-connection.js +0 -1
  49. package/lib/license-beacon.js +367 -0
  50. package/lib/logger.js +35 -22
  51. package/lib/metrics-collector.js +0 -2
  52. package/lib/oauth2-apps.js +13 -4
  53. package/lib/outbox.js +24 -40
  54. package/lib/redis-operations.js +1 -1
  55. package/lib/routes-ui.js +2 -1
  56. package/lib/schemas.js +403 -83
  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 +28 -6
  63. package/lib/ui-routes/account-routes.js +7 -4
  64. package/lib/ui-routes/admin-config-routes.js +20 -6
  65. package/lib/ui-routes/document-store-routes.js +7 -1
  66. package/lib/ui-routes/oauth-config-routes.js +0 -2
  67. package/lib/ui-routes/route-helpers.js +0 -2
  68. package/lib/ui-routes/unsubscribe-routes.js +0 -2
  69. package/lib/webhooks.js +8 -4
  70. package/package.json +23 -19
  71. package/sbom.json +1 -1
  72. package/server.js +38 -31
  73. package/static/licenses.html +171 -391
  74. package/translations/de.mo +0 -0
  75. package/translations/de.po +154 -142
  76. package/translations/et.mo +0 -0
  77. package/translations/et.po +129 -131
  78. package/translations/fr.mo +0 -0
  79. package/translations/fr.po +133 -136
  80. package/translations/ja.mo +0 -0
  81. package/translations/ja.po +126 -129
  82. package/translations/messages.pot +107 -107
  83. package/translations/nl.mo +0 -0
  84. package/translations/nl.po +128 -130
  85. package/translations/pl.mo +0 -0
  86. package/translations/pl.po +125 -128
  87. package/update-info.sh +19 -1
  88. package/views/config/logging.hbs +48 -0
  89. package/views/dashboard.hbs +22 -0
  90. package/workers/api.js +33 -37
  91. package/workers/documents.js +2 -22
  92. package/workers/export.js +73 -92
  93. package/workers/imap-proxy.js +3 -23
  94. package/workers/imap.js +2 -22
  95. package/workers/smtp.js +2 -22
  96. package/workers/submit.js +6 -24
  97. package/workers/webhooks.js +2 -22
@@ -0,0 +1,367 @@
1
+ 'use strict';
2
+
3
+ // License-validation feature beacon.
4
+ //
5
+ // Collects a compact, anonymized snapshot of which features are enabled and exercised on this
6
+ // instance, to be piggybacked onto the existing daily license-validation POST. The intent is to
7
+ // learn whether deprecation-candidate features are still in use in the field.
8
+ //
9
+ // Privacy: the snapshot contains only enable-flags, provider type names, coarse magnitude tiers
10
+ // (NOT raw counts), exercised-usage booleans, and runtime context. It never includes account
11
+ // addresses, URLs, credentials, or any other PII/secrets.
12
+ //
13
+ // Reliability: collection is strictly best-effort. Every field is isolated so one failing Redis
14
+ // read degrades that field rather than the whole snapshot, and collectBeacon never throws - on a
15
+ // catastrophic failure it returns null and the license call proceeds with its original fields.
16
+ // The caller is expected to also time-box this with withTimeout().
17
+
18
+ const crypto = require('crypto');
19
+ const msgpack = require('msgpack5')();
20
+
21
+ const settings = require('./settings');
22
+ const { getCounterValues, hasEnvValue, readEnvValue, getBoolean } = require('./tools');
23
+ const { REDIS_PREFIX, EE_DOCKER_LEGACY } = require('./consts');
24
+ const { oauth2Apps } = require('./oauth2-apps');
25
+ const passkeys = require('./passkeys');
26
+ const featureFlags = require('./feature-flags');
27
+ const { documentStoreFeatureEnabled } = require('./document-store');
28
+
29
+ // Beacon schema version. Bump when the meaning of codes changes so the license server can adapt.
30
+ const SCHEMA_VERSION = 1;
31
+
32
+ // Window for "exercised recently" usage signals. A week smooths over quiet days so the digest
33
+ // (and therefore the full-payload sends) does not churn day to day.
34
+ const USE_WINDOW_SECONDS = 7 * 24 * 3600;
35
+
36
+ // Skip the per-route webhook content scan above this many routes (keeps the collector cheap).
37
+ const WH_SCAN_LIMIT = 250;
38
+
39
+ // Time-box for a single collection so a slow Redis can never delay license validation.
40
+ const COLLECT_TIMEOUT_MS = 2000;
41
+
42
+ // Resend the full snapshot at least this often even when its digest has not changed.
43
+ const FULL_RESEND_INTERVAL_MS = 30 * 24 * 3600 * 1000;
44
+
45
+ // Map a raw count to a coarse magnitude tier (powers of ten). Values are buckets, never counts.
46
+ function tier(n) {
47
+ n = Number(n) || 0;
48
+ if (n <= 0) return 0;
49
+ if (n === 1) return 1;
50
+ if (n < 10) return 2;
51
+ if (n < 100) return 3;
52
+ if (n < 1000) return 4;
53
+ if (n < 10000) return 5;
54
+ return 6;
55
+ }
56
+
57
+ // Truthiness for boolean-ish settings (schema booleans arrive as real booleans; legacy/raw values
58
+ // may be strings or arrays).
59
+ function truthy(value) {
60
+ if (value === true) {
61
+ return true;
62
+ }
63
+ if (typeof value === 'number') {
64
+ return value !== 0;
65
+ }
66
+ if (Array.isArray(value)) {
67
+ return value.length > 0;
68
+ }
69
+ if (typeof value === 'string') {
70
+ return /^(y|yes|true|t|1)$/i.test(value.trim());
71
+ }
72
+ return false;
73
+ }
74
+
75
+ // Non-empty check for string-valued settings (URLs, keys, scripts) without inspecting the value.
76
+ function nonEmpty(value) {
77
+ if (typeof value === 'string') {
78
+ return value.trim().length > 0;
79
+ }
80
+ return truthy(value);
81
+ }
82
+
83
+ // Deterministic serialization: object keys sorted recursively. Arrays are pre-sorted at build time.
84
+ // Produces a stable string so the digest only changes when the snapshot meaningfully changes.
85
+ function stableStringify(value) {
86
+ if (Array.isArray(value)) {
87
+ return '[' + value.map(stableStringify).join(',') + ']';
88
+ }
89
+ if (value && typeof value === 'object') {
90
+ return (
91
+ '{' +
92
+ Object.keys(value)
93
+ .sort()
94
+ .map(key => JSON.stringify(key) + ':' + stableStringify(value[key]))
95
+ .join(',') +
96
+ '}'
97
+ );
98
+ }
99
+ return JSON.stringify(value);
100
+ }
101
+
102
+ // Resolve the install channel, mirroring lib/ui-routes/dashboard-routes.js.
103
+ function installChannel() {
104
+ if (getBoolean(readEnvValue('EENGINE_DOCEAN'))) {
105
+ return 'docean';
106
+ }
107
+ if (typeof readEnvValue('RENDER_SERVICE_SLUG') === 'string' && readEnvValue('RENDER_SERVICE_SLUG')) {
108
+ return 'render';
109
+ }
110
+ if (getBoolean(readEnvValue('EENGINE_INSTALL_SCRIPT'))) {
111
+ return 'script';
112
+ }
113
+ if (EE_DOCKER_LEGACY) {
114
+ return 'docker-legacy';
115
+ }
116
+ return 'general';
117
+ }
118
+
119
+ // Race a promise against a timeout so a slow Redis can never delay license validation.
120
+ function withTimeout(promise, ms) {
121
+ return Promise.race([
122
+ promise,
123
+ new Promise((resolve, reject) => {
124
+ setTimeout(() => reject(new Error('Beacon collection timed out')), ms).unref();
125
+ })
126
+ ]);
127
+ }
128
+
129
+ // Build the diagnostic snapshot and its digest. Returns { fh, diag } or null on failure.
130
+ async function collectBeacon({ redis, logger }) {
131
+ // Isolate a single field: log and swallow so one failure does not abort the whole snapshot.
132
+ const safe = async fn => {
133
+ try {
134
+ return await fn();
135
+ } catch (err) {
136
+ if (logger) {
137
+ logger.error({ msg: 'Beacon field collection failed', err });
138
+ }
139
+ return undefined;
140
+ }
141
+ };
142
+
143
+ try {
144
+ const diag = { v: SCHEMA_VERSION };
145
+
146
+ const s =
147
+ (await safe(() =>
148
+ settings.getMulti(
149
+ 'smtpServerEnabled',
150
+ 'imapProxyServerEnabled',
151
+ 'enableApiProxy',
152
+ 'trackOpens',
153
+ 'trackClicks',
154
+ 'webhooksEnabled',
155
+ 'openAiAPIKey',
156
+ 'generateEmailSummary',
157
+ 'openAiGenerateEmbeddings',
158
+ 'openAiAPIUrl',
159
+ 'openAiPreProcessingFn',
160
+ 'proxyEnabled',
161
+ 'httpProxyEnabled',
162
+ 'localAddresses',
163
+ 'sentryEnabled',
164
+ 'authServer',
165
+ 'imapIndexer',
166
+ 'totpEnabled',
167
+ 'documentStoreEnabled',
168
+ 'documentStoreGenerateEmbeddings',
169
+ 'documentStorePreProcessingEnabled',
170
+ 'gmailEnabled',
171
+ 'outlookEnabled',
172
+ 'mailRuEnabled',
173
+ 'trackSentMessages'
174
+ )
175
+ )) || {};
176
+
177
+ const on = key => truthy(s[key]);
178
+
179
+ // Enabled-feature codes (presence = on; codes are omitted when off).
180
+ const feat = [];
181
+ if (on('smtpServerEnabled')) feat.push('smtp');
182
+ if (on('imapProxyServerEnabled')) feat.push('imapproxy');
183
+ if (on('enableApiProxy')) feat.push('apiproxy');
184
+ if (on('trackOpens')) feat.push('track_o');
185
+ if (on('trackClicks')) feat.push('track_c');
186
+ if (on('webhooksEnabled')) feat.push('webhooks');
187
+ if (nonEmpty(s.openAiAPIKey)) feat.push('ai');
188
+ if (on('generateEmailSummary')) feat.push('ai_sum');
189
+ if (on('openAiGenerateEmbeddings')) feat.push('ai_embed');
190
+ if (nonEmpty(s.openAiAPIUrl)) feat.push('ai_url');
191
+ if (nonEmpty(s.openAiPreProcessingFn)) feat.push('ai_prefn');
192
+ if (on('proxyEnabled')) feat.push('proxy');
193
+ if (on('httpProxyEnabled')) feat.push('httpproxy');
194
+ if (truthy(s.localAddresses)) feat.push('localaddr');
195
+ if (on('sentryEnabled')) feat.push('sentry');
196
+ if (nonEmpty(s.authServer)) feat.push('authsrv');
197
+ if (s.imapIndexer === 'fast') feat.push('idx_fast');
198
+ if (on('totpEnabled')) feat.push('totp');
199
+ if (hasEnvValue('OKTA_OAUTH2_ISSUER') && hasEnvValue('OKTA_OAUTH2_CLIENT_ID') && hasEnvValue('OKTA_OAUTH2_CLIENT_SECRET')) {
200
+ feat.push('okta');
201
+ }
202
+ if (await safe(() => passkeys.hasPasskeys())) {
203
+ feat.push('passkey');
204
+ }
205
+ diag.feat = feat.sort();
206
+
207
+ // Entity magnitude tiers (buckets, not counts).
208
+ const scard = key => safe(() => redis.scard(`${REDIS_PREFIX}${key}`));
209
+ const rawAccounts = Number(await scard('ia:accounts')) || 0;
210
+ diag.tiers = {
211
+ acct: tier(rawAccounts),
212
+ oapp: tier(await scard('oapp:i')),
213
+ gw: tier(await scard('gateways')),
214
+ wh: tier(await scard('wh:i')),
215
+ tpl: tier(await scard('tpl::i')),
216
+ bl: tier(await safe(() => redis.hlen(`${REDIS_PREFIX}lists:unsub:lists`)))
217
+ };
218
+
219
+ // Provider mix. `oapp` = provider types of configured OAuth apps; `prov` = provider types
220
+ // that actually have accounts (plus `imap` for any non-OAuth accounts). Only the app id and
221
+ // provider type are read from the sanitized app listing - no secrets are inspected.
222
+ await safe(async () => {
223
+ const res = await oauth2Apps.list(0, 100000);
224
+ const apps = (res && res.apps) || [];
225
+
226
+ const configured = new Set();
227
+ const appProviders = [];
228
+ for (const app of apps) {
229
+ if (app && app.provider) {
230
+ configured.add(app.provider);
231
+ appProviders.push([app.id, app.provider]);
232
+ }
233
+ }
234
+ diag.oapp = Array.from(configured).sort();
235
+
236
+ const inUse = new Set();
237
+ let oauthAccounts = 0;
238
+ if (appProviders.length) {
239
+ const multi = redis.multi();
240
+ for (const [id] of appProviders) {
241
+ multi.scard(`${REDIS_PREFIX}oapp:a:${id}`);
242
+ }
243
+ const counts = await multi.exec();
244
+ for (let i = 0; i < appProviders.length; i++) {
245
+ const entry = counts[i];
246
+ const count = (entry && !entry[0] && Number(entry[1])) || 0;
247
+ oauthAccounts += count;
248
+ if (count > 0) {
249
+ inUse.add(appProviders[i][1]);
250
+ }
251
+ }
252
+ }
253
+ if (rawAccounts > oauthAccounts) {
254
+ inUse.add('imap');
255
+ }
256
+ diag.prov = Array.from(inUse).sort();
257
+ });
258
+
259
+ // Exercised-usage signals from the existing event counters.
260
+ await safe(async () => {
261
+ const counters = (await getCounterValues(redis, USE_WINDOW_SECONDS)) || {};
262
+ const use = [];
263
+ if (counters['events:messageNew'] > 0) use.push('recv');
264
+ if (counters['submit:success'] > 0) use.push('send');
265
+ if (counters['webhooks:success'] > 0) use.push('wh');
266
+ if (counters['apiCall:success'] > 0) use.push('api');
267
+ diag.use = use.sort();
268
+ });
269
+
270
+ // Deprecation watchlist (presence of legacy/candidate-for-removal features).
271
+ const dep = [];
272
+ if (on('documentStoreEnabled')) dep.push('documentStore');
273
+ if (documentStoreFeatureEnabled) dep.push('documentStoreGate');
274
+ if (on('documentStoreGenerateEmbeddings')) dep.push('ds_embed');
275
+ if (on('documentStorePreProcessingEnabled')) dep.push('ds_preproc');
276
+ if (on('gmailEnabled') || on('outlookEnabled') || on('mailRuEnabled')) dep.push('legacyOauth');
277
+ if (on('trackSentMessages')) dep.push('trackSent');
278
+ if (EE_DOCKER_LEGACY) dep.push('dockerLegacy');
279
+ await safe(async () => {
280
+ const ids = await redis.smembers(`${REDIS_PREFIX}wh:i`);
281
+ if (ids && ids.length && ids.length <= WH_SCAN_LIMIT) {
282
+ const bufs = await redis.hmgetBuffer(
283
+ `${REDIS_PREFIX}wh:c`,
284
+ ids.map(id => `${id}:content`)
285
+ );
286
+ for (const buf of bufs || []) {
287
+ if (!buf || !buf.length) {
288
+ continue;
289
+ }
290
+ try {
291
+ const content = msgpack.decode(buf);
292
+ if (content && (content.fn || content.map)) {
293
+ dep.push('whSubscript');
294
+ break;
295
+ }
296
+ } catch (err) {
297
+ // undecodable entry, skip
298
+ }
299
+ }
300
+ }
301
+ });
302
+ diag.dep = dep.sort();
303
+
304
+ // Enabled EENGINE_FEATURE_* flags (already sorted).
305
+ diag.flags = (await safe(() => featureFlags.listEnabled())) || [];
306
+
307
+ // Runtime context.
308
+ diag.dist = installChannel();
309
+ diag.node = process.versions.node;
310
+ diag.arch = process.arch;
311
+
312
+ const fh = crypto.createHash('sha256').update(stableStringify(diag)).digest('hex').slice(0, 12);
313
+
314
+ return { fh, diag };
315
+ } catch (err) {
316
+ if (logger) {
317
+ logger.error({ msg: 'Beacon collection failed', err });
318
+ }
319
+ return null;
320
+ }
321
+ }
322
+
323
+ // Collect the snapshot (time-boxed) and decide what to attach to the license request body.
324
+ // Always attaches the digest `fh`; attaches the full `diag` only when the digest changed since the
325
+ // last accepted send or the 30-day heartbeat is due. Best-effort: never throws.
326
+ async function attachBeacon(body, { redis, logger, now }) {
327
+ try {
328
+ const beacon = await withTimeout(collectBeacon({ redis, logger }), COLLECT_TIMEOUT_MS);
329
+ if (!beacon || !beacon.fh) {
330
+ return;
331
+ }
332
+ body.fh = beacon.fh;
333
+
334
+ const [storedHash, bft] = await redis.hmget(`${REDIS_PREFIX}settings`, ['bfh', 'bft']);
335
+ const lastFull = parseInt(bft || '0', 16) || 0;
336
+ if (beacon.fh !== storedHash || now - lastFull > FULL_RESEND_INTERVAL_MS) {
337
+ body.diag = beacon.diag;
338
+ }
339
+ } catch (err) {
340
+ if (logger) {
341
+ logger.error({ msg: 'License beacon collection failed', err });
342
+ }
343
+ }
344
+ }
345
+
346
+ // Persist the send-on-change markers after a successful validation. `needFull` (from the server)
347
+ // forces a full resend on the next cycle when the server has the digest but not the snapshot.
348
+ // Best-effort: never throws.
349
+ async function persistBeaconMarkers({ redis, logger, body, now, needFull }) {
350
+ try {
351
+ if (body.fh) {
352
+ await redis.hset(`${REDIS_PREFIX}settings`, 'bfh', body.fh);
353
+ if (body.diag) {
354
+ await redis.hset(`${REDIS_PREFIX}settings`, 'bft', now.toString(16));
355
+ }
356
+ }
357
+ if (needFull) {
358
+ await redis.hdel(`${REDIS_PREFIX}settings`, 'bfh');
359
+ }
360
+ } catch (err) {
361
+ if (logger) {
362
+ logger.error({ msg: 'Failed to persist license beacon markers', err });
363
+ }
364
+ }
365
+ }
366
+
367
+ module.exports = { collectBeacon, attachBeacon, persistBeaconMarkers, withTimeout, tier, stableStringify };
package/lib/logger.js CHANGED
@@ -7,6 +7,7 @@ if (!process.env.EE_ENV_LOADED) {
7
7
 
8
8
  const config = require('@zone-eu/wild-config');
9
9
  const pino = require('pino');
10
+ const { TRANSIENT_NETWORK_CODES } = require('./consts');
10
11
 
11
12
  config.log = config.log || {
12
13
  level: 'trace'
@@ -14,27 +15,27 @@ config.log = config.log || {
14
15
 
15
16
  config.log.level = config.log.level || 'trace';
16
17
 
18
+ // undici raises every connection failure as a generic `TypeError` (e.g. "fetch failed"
19
+ // or "terminated") with the real DNS/socket error attached as err.cause. Those are
20
+ // transient, environmental blips - not code bugs - so they must not be forwarded to
21
+ // error tracking, where they pile up as useless "fetch failed" reports. Genuine
22
+ // TypeError/RangeError bugs have no network errno cause and still get reported.
23
+ function isTransientFetchError(err) {
24
+ return err && err.name === 'TypeError' && err.cause && TRANSIENT_NETWORK_CODES.has(err.cause.code);
25
+ }
26
+
17
27
  let logger = pino({
18
28
  formatters: {
19
29
  log(object) {
20
- if (object.err && ['TypeError', 'RangeError'].includes(object.err.name)) {
30
+ if (object.err && ['TypeError', 'RangeError'].includes(object.err.name) && !isTransientFetchError(object.err)) {
21
31
  if (logger.notifyError) {
22
- logger.notifyError(object.err, event => {
23
- if (object.account) {
24
- event.setUser(object.account);
25
- }
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
- }
32
+ let meta = {};
33
+ for (let key of ['msg', 'path', 'cid']) {
34
+ if (object[key]) {
35
+ meta[key] = object[key];
33
36
  }
34
- if (hasMeta) {
35
- event.addMetadata('ee', meta);
36
- }
37
- });
37
+ }
38
+ logger.notifyError(object.err, { user: object.account, meta });
38
39
  }
39
40
  }
40
41
  return object;
@@ -49,6 +50,22 @@ if (threadId) {
49
50
  logger = logger.child({ tid: threadId });
50
51
  }
51
52
 
53
+ // An error that reaches the global handlers leaves the process in an unknown state,
54
+ // so the process must always exit. If error tracking is enabled, report the error
55
+ // first and allow a short flush window for the delivery.
56
+ function fatalShutdown(code, err) {
57
+ if (logger.notifyError) {
58
+ logger.notifyError(err);
59
+ }
60
+
61
+ let exit = () => process.exit(code);
62
+ if (logger.flushNotifications) {
63
+ logger.flushNotifications().then(exit, exit);
64
+ } else {
65
+ setTimeout(exit, 10);
66
+ }
67
+ }
68
+
52
69
  process.on('uncaughtException', err => {
53
70
  logger.fatal({
54
71
  msg: 'uncaughtException',
@@ -56,9 +73,7 @@ process.on('uncaughtException', err => {
56
73
  err
57
74
  });
58
75
 
59
- if (!logger.notifyError) {
60
- setTimeout(() => process.exit(1), 10);
61
- }
76
+ fatalShutdown(1, err);
62
77
  });
63
78
 
64
79
  process.on('unhandledRejection', err => {
@@ -68,9 +83,7 @@ process.on('unhandledRejection', err => {
68
83
  err
69
84
  });
70
85
 
71
- if (!logger.notifyError) {
72
- setTimeout(() => process.exit(2), 10);
73
- }
86
+ fatalShutdown(2, err);
74
87
  });
75
88
 
76
89
  module.exports = logger;
@@ -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);
package/lib/outbox.js CHANGED
@@ -3,6 +3,28 @@
3
3
  const { submitQueue, redis } = require('./db');
4
4
  const { REDIS_PREFIX } = require('../lib/consts');
5
5
 
6
+ function formatQueueEntry(job) {
7
+ let scheduled = job.timestamp + (Number(job.opts.delay) || 0);
8
+
9
+ let backoffDelay = Number(job.opts.backoff && job.opts.backoff.delay) || 0;
10
+ // attemptsMade already includes the failed attempt, so the retry delay is 2^(attemptsMade-1) * base
11
+ let nextAttempt = job.attemptsMade ? Math.round(job.processedOn + Math.pow(2, job.attemptsMade - 1) * backoffDelay) : scheduled;
12
+
13
+ if (job.opts.attempts <= job.attemptsMade) {
14
+ nextAttempt = false;
15
+ }
16
+
17
+ return Object.assign(job.data, {
18
+ created: new Date(Number(job.data.created || job.timestamp)).toISOString(),
19
+ //status: job.name,
20
+ progress: job.progress,
21
+ attemptsMade: job.attemptsMade,
22
+ attempts: job.opts.attempts,
23
+ scheduled: new Date(scheduled).toISOString(),
24
+ nextAttempt: nextAttempt ? new Date(nextAttempt).toISOString() : false
25
+ });
26
+ }
27
+
6
28
  async function list(options) {
7
29
  options = options || {};
8
30
  let page = Number(options.page) || 0;
@@ -23,26 +45,7 @@ async function list(options) {
23
45
  try {
24
46
  let job = await submitQueue.getJob(jobId);
25
47
  if (job) {
26
- let scheduled = job.timestamp + (Number(job.opts.delay) || 0);
27
-
28
- let backoffDelay = Number(job.opts.backoff && job.opts.backoff.delay) || 0;
29
- let nextAttempt = job.attemptsMade ? Math.round(job.processedOn + Math.pow(2, job.attemptsMade) * backoffDelay) : scheduled;
30
-
31
- if (job.opts.attempts <= job.attemptsMade) {
32
- nextAttempt = false;
33
- }
34
-
35
- messages.push(
36
- Object.assign(job.data, {
37
- created: new Date(Number(job.created || job.timestamp)).toISOString(),
38
- //status: job.name,
39
- progress: job.progress,
40
- attemptsMade: job.attemptsMade,
41
- attempts: job.opts.attempts,
42
- scheduled: new Date(scheduled).toISOString(),
43
- nextAttempt: nextAttempt ? new Date(nextAttempt).toISOString() : false
44
- })
45
- );
48
+ messages.push(formatQueueEntry(job));
46
49
  }
47
50
  } catch (err) {
48
51
  logger.error({ msg: 'Failed to retrieve message info from outbox', jobId, err });
@@ -105,15 +108,6 @@ async function get(options) {
105
108
  return false;
106
109
  }
107
110
 
108
- let scheduled = job.timestamp + (Number(job.opts.delay) || 0);
109
-
110
- let backoffDelay = Number(job.opts.backoff && job.opts.backoff.delay) || 0;
111
- let nextAttempt = job.attemptsMade ? Math.round(job.processedOn + Math.pow(2, job.attemptsMade) * backoffDelay) : scheduled;
112
-
113
- if (job.opts.attempts <= job.attemptsMade) {
114
- nextAttempt = false;
115
- }
116
-
117
111
  try {
118
112
  let queueEntryBuf = await redis.hgetBuffer(`${REDIS_PREFIX}iaq:${job.data.account}`, job.data.queueId);
119
113
  if (!queueEntryBuf) {
@@ -124,17 +118,7 @@ async function get(options) {
124
118
  throw err;
125
119
  }
126
120
 
127
- let response = Object.assign(job.data, {
128
- created: new Date(Number(job.created || job.timestamp)).toISOString(),
129
- //status: job.name,
130
- progress: job.progress,
131
- attemptsMade: job.attemptsMade,
132
- attempts: job.opts.attempts,
133
- scheduled: new Date(scheduled).toISOString(),
134
- nextAttempt: nextAttempt ? new Date(nextAttempt).toISOString() : false
135
- });
136
-
137
- return response;
121
+ return formatQueueEntry(job);
138
122
  }
139
123
 
140
124
  module.exports = { list, del, get };
@@ -228,7 +228,7 @@ class RedisTransaction {
228
228
  * @returns {Promise<Array>} Array of values from successful commands
229
229
  */
230
230
  async execOrThrow() {
231
- const { results, values, error } = await this.exec();
231
+ const { values, error } = await this.exec();
232
232
 
233
233
  if (error) {
234
234
  throw error;
package/lib/routes-ui.js CHANGED
@@ -35,7 +35,8 @@ function applyRoutes(server, call) {
35
35
  // Network, SMTP server, IMAP proxy, and browser config routes
36
36
  networkConfigRoutes({ server, call });
37
37
 
38
- // Document Store (Elasticsearch) config routes
38
+ // Document Store (Elasticsearch) config routes (deprecated feature; the module self-gates
39
+ // and registers no routes unless the Document Store feature is enabled)
39
40
  documentStoreRoutes({ server });
40
41
 
41
42
  // Admin auth and user-profile routes (login, logout, TOTP, passkeys, password)