emailengine-app 2.63.4 → 2.65.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 (59) hide show
  1. package/.github/workflows/test.yml +4 -0
  2. package/CHANGELOG.md +70 -0
  3. package/copy-static-files.sh +1 -1
  4. package/data/google-crawlers.json +1 -1
  5. package/eslint.config.js +2 -0
  6. package/lib/account.js +13 -9
  7. package/lib/api-routes/account-routes.js +7 -1
  8. package/lib/consts.js +17 -1
  9. package/lib/email-client/gmail/gmail-api.js +1 -12
  10. package/lib/email-client/imap-client.js +5 -3
  11. package/lib/email-client/outlook/graph-api.js +9 -15
  12. package/lib/email-client/outlook-client.js +406 -177
  13. package/lib/export.js +17 -0
  14. package/lib/imapproxy/imap-server.js +3 -2
  15. package/lib/oauth/gmail.js +12 -1
  16. package/lib/oauth/outlook.js +99 -1
  17. package/lib/oauth/pubsub/google.js +253 -85
  18. package/lib/oauth2-apps.js +620 -389
  19. package/lib/outbox.js +1 -1
  20. package/lib/routes-ui.js +193 -238
  21. package/lib/schemas.js +189 -12
  22. package/lib/ui-routes/account-routes.js +7 -2
  23. package/lib/ui-routes/admin-entities-routes.js +3 -3
  24. package/lib/ui-routes/oauth-routes.js +27 -175
  25. package/package.json +21 -21
  26. package/sbom.json +1 -1
  27. package/server.js +54 -22
  28. package/static/licenses.html +30 -90
  29. package/translations/de.mo +0 -0
  30. package/translations/de.po +54 -42
  31. package/translations/en.mo +0 -0
  32. package/translations/en.po +55 -43
  33. package/translations/et.mo +0 -0
  34. package/translations/et.po +54 -42
  35. package/translations/fr.mo +0 -0
  36. package/translations/fr.po +54 -42
  37. package/translations/ja.mo +0 -0
  38. package/translations/ja.po +54 -42
  39. package/translations/messages.pot +93 -71
  40. package/translations/nl.mo +0 -0
  41. package/translations/nl.po +54 -42
  42. package/translations/pl.mo +0 -0
  43. package/translations/pl.po +54 -42
  44. package/views/config/oauth/app.hbs +12 -0
  45. package/views/config/oauth/edit.hbs +2 -0
  46. package/views/config/oauth/index.hbs +4 -1
  47. package/views/config/oauth/new.hbs +2 -0
  48. package/views/config/oauth/subscriptions.hbs +175 -0
  49. package/views/error.hbs +4 -4
  50. package/views/partials/oauth_form.hbs +179 -4
  51. package/views/partials/oauth_tabs.hbs +8 -0
  52. package/views/partials/scope_info.hbs +10 -0
  53. package/workers/api.js +174 -96
  54. package/workers/documents.js +1 -0
  55. package/workers/export.js +6 -2
  56. package/workers/imap.js +33 -49
  57. package/workers/smtp.js +1 -0
  58. package/workers/submit.js +1 -0
  59. package/workers/webhooks.js +42 -30
package/lib/export.js CHANGED
@@ -242,6 +242,7 @@ class Export {
242
242
  },
243
243
  created: toIsoDate(data.created),
244
244
  expiresAt: toIsoDate(data.expiresAt),
245
+ truncated: data.truncated === '1' || undefined,
245
246
  error: data.error || null
246
247
  };
247
248
 
@@ -322,6 +323,15 @@ class Export {
322
323
  }
323
324
  }
324
325
 
326
+ static async startProcessing(account, exportId) {
327
+ const exportKey = getExportKey(account, exportId);
328
+ const maxAge = await getExportMaxAge();
329
+ const ttl = Math.ceil(maxAge / 1000);
330
+ const newExpiresAt = Date.now() + maxAge;
331
+
332
+ await redis.multi().hmset(exportKey, { status: 'processing', phase: 'indexing', expiresAt: newExpiresAt }).expire(exportKey, ttl).exec();
333
+ }
334
+
325
335
  static async queueMessage(account, exportId, messageInfo) {
326
336
  const queueKey = getExportQueueKey(account, exportId);
327
337
  const exportKey = getExportKey(account, exportId);
@@ -415,6 +425,13 @@ class Export {
415
425
  logger.error({ msg: 'Export failed', account, exportId, error });
416
426
  }
417
427
 
428
+ static async deleteFully(account, exportId) {
429
+ const exportKey = getExportKey(account, exportId);
430
+ const queueKey = getExportQueueKey(account, exportId);
431
+
432
+ await redis.multi().del(exportKey).del(queueKey).srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`).exec();
433
+ }
434
+
418
435
  static async markInterruptedAsFailed() {
419
436
  const activeExports = await redis.smembers(ACTIVE_EXPORTS_KEY);
420
437
 
@@ -4,7 +4,7 @@ const { parentPort } = require('worker_threads');
4
4
 
5
5
  const config = require('@zone-eu/wild-config');
6
6
  const logger = require('../logger');
7
- const { oauth2Apps, oauth2ProviderData } = require('../oauth2-apps');
7
+ const { oauth2Apps, oauth2ProviderData, isApiBasedApp } = require('../oauth2-apps');
8
8
 
9
9
  const { getDuration, getBoolean, resolveCredentials, hasEnvValue, readEnvValue, emitChangeEvent, loadTlsConfig } = require('../tools');
10
10
  const { matchIp, getLocalAddress } = require('../utils/network');
@@ -48,6 +48,7 @@ async function call(message, transferList) {
48
48
  err.statusCode = 504;
49
49
  err.code = 'Timeout';
50
50
  err.ttl = ttl;
51
+ callQueue.delete(mid);
51
52
  reject(err);
52
53
  }, ttl);
53
54
 
@@ -213,7 +214,7 @@ async function onAuth(auth, session) {
213
214
  throw respErr;
214
215
  }
215
216
 
216
- if (accountData?._app?.baseScopes === 'api') {
217
+ if (isApiBasedApp(accountData?._app)) {
217
218
  let respErr = new Error('IMAP is not supported for API-based accounts');
218
219
  respErr.authenticationFailed = true;
219
220
  respErr.serverResponseCode = 'ACCOUNTDISABLED';
@@ -481,7 +481,8 @@ class GmailOauth {
481
481
  Authorization: `Bearer ${accessToken}`,
482
482
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
483
483
  },
484
- dispatcher: httpAgent.retry
484
+ dispatcher: httpAgent.retry,
485
+ signal: options.signal || undefined
485
486
  };
486
487
 
487
488
  if (payload && method !== 'get') {
@@ -535,6 +536,16 @@ class GmailOauth {
535
536
  reqTime
536
537
  };
537
538
 
539
+ // Capture Retry-After header for rate limiting (429) responses
540
+ if (res.status === 429) {
541
+ let retryAfterHeader = res.headers.get('retry-after');
542
+ if (retryAfterHeader) {
543
+ let parsed = parseInt(retryAfterHeader, 10);
544
+ err.retryAfter = isNaN(parsed) ? null : parsed;
545
+ err.oauthRequest.retryAfter = err.retryAfter;
546
+ }
547
+ }
548
+
538
549
  try {
539
550
  err.oauthRequest.response = await res.json();
540
551
 
@@ -174,7 +174,8 @@ class OutlookOauth {
174
174
  this.logRaw = opts.logRaw;
175
175
  this.logger = opts.logger;
176
176
 
177
- this.provider = 'outlook';
177
+ this.useClientCredentials = !!opts.useClientCredentials;
178
+ this.provider = opts.provider || 'outlook';
178
179
 
179
180
  this.setFlag = opts.setFlag;
180
181
 
@@ -199,9 +200,18 @@ class OutlookOauth {
199
200
  this.entraEndpoint = 'https://login.microsoftonline.com';
200
201
  this.apiBase = 'https://graph.microsoft.com';
201
202
  }
203
+
204
+ // Client credentials use a single .default scope; override whatever was set above
205
+ if (this.useClientCredentials) {
206
+ this.scopes = [`${this.apiBase}/.default`];
207
+ }
202
208
  }
203
209
 
204
210
  generateAuthUrl(opts) {
211
+ if (this.useClientCredentials) {
212
+ throw new Error('Client credentials flow does not support interactive authorization');
213
+ }
214
+
205
215
  opts = opts || {};
206
216
 
207
217
  const url = new URL(`${this.entraEndpoint}/${this.authority}/oauth2/v2.0/authorize`);
@@ -335,6 +345,10 @@ class OutlookOauth {
335
345
  }
336
346
 
337
347
  async refreshToken(opts) {
348
+ if (this.useClientCredentials) {
349
+ return this.getClientCredentialsToken();
350
+ }
351
+
338
352
  opts = opts || {};
339
353
  if (!opts.refreshToken) {
340
354
  throw new Error('Refresh token not provided');
@@ -435,6 +449,90 @@ class OutlookOauth {
435
449
  return responseJson;
436
450
  }
437
451
 
452
+ async getClientCredentialsToken() {
453
+ const url = new URL(`${this.entraEndpoint}/${this.authority}/oauth2/v2.0/token`);
454
+
455
+ url.searchParams.set('client_id', this.clientId);
456
+ url.searchParams.set('client_secret', this.clientSecret);
457
+ url.searchParams.set('scope', this.scopes.join(' '));
458
+ url.searchParams.set('grant_type', 'client_credentials');
459
+
460
+ let requestUrl = url.origin + url.pathname;
461
+ let method = 'post';
462
+
463
+ const bodyString = url.searchParams.toString();
464
+ let res = await fetchCmd(requestUrl, {
465
+ method,
466
+ headers: {
467
+ 'Content-Type': 'application/x-www-form-urlencoded',
468
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
469
+ },
470
+ body: bodyString,
471
+ dispatcher: httpAgent.retry
472
+ });
473
+
474
+ let responseJson;
475
+ try {
476
+ responseJson = await res.json();
477
+ } catch (err) {
478
+ if (this.logger) {
479
+ this.logger.error({ msg: 'Failed to retrieve JSON', err });
480
+ }
481
+ }
482
+
483
+ if (this.logger) {
484
+ this.logger.info({
485
+ msg: 'OAuth2 authentication request',
486
+ action: 'oauth2Fetch',
487
+ fn: 'getClientCredentialsToken',
488
+ method,
489
+ url: requestUrl,
490
+ success: !!res.ok,
491
+ status: res.status,
492
+ request: formatFetchBody(url.searchParams, this.logRaw),
493
+ response: formatFetchResponse(responseJson, this.logRaw)
494
+ });
495
+ }
496
+
497
+ if (!res.ok) {
498
+ let err = new Error('Token request failed');
499
+ err.code = 'ETokenRefresh';
500
+
501
+ err.statusCode = res.status;
502
+ err.tokenRequest = {
503
+ url: requestUrl,
504
+ method,
505
+ authority: this.authority,
506
+ grant: 'client_credentials',
507
+ provider: this.provider,
508
+ status: res.status,
509
+ clientId: this.clientId,
510
+ scopes: this.scopes
511
+ };
512
+ try {
513
+ err.tokenRequest.response = responseJson || { error: 'Failed to parse response' };
514
+
515
+ if (EXPOSE_PARTIAL_SECRET_KEY_REGEX.test(err.tokenRequest.response?.error_description)) {
516
+ err.tokenRequest.clientSecret = formatPartialSecretKey(this.clientSecret);
517
+ }
518
+
519
+ let flag = checkForFlags(err.tokenRequest.response);
520
+ if (flag) {
521
+ await this.setFlag(flag);
522
+ }
523
+ } catch (e) {
524
+ // ignore
525
+ }
526
+ err.message = formatTokenError(this.provider, err.tokenRequest);
527
+ throw err;
528
+ }
529
+
530
+ // clear potential auth flag
531
+ await this.setFlag();
532
+
533
+ return responseJson;
534
+ }
535
+
438
536
  async request(accessToken, url, method, payload, options) {
439
537
  options = options || {};
440
538