emailengine-app 2.67.3 → 2.68.1

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 (44) 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 +6 -0
  4. package/.github/workflows/test.yml +6 -0
  5. package/CHANGELOG.md +36 -0
  6. package/SECURITY.md +80 -0
  7. package/SECURITY.txt +27 -0
  8. package/data/google-crawlers.json +7 -1
  9. package/lib/account.js +24 -1
  10. package/lib/api-routes/account-routes.js +12 -2
  11. package/lib/email-client/base-client.js +26 -20
  12. package/lib/email-client/gmail-client.js +14 -12
  13. package/lib/imapproxy/imap-core/lib/imap-command.js +1 -1
  14. package/lib/imapproxy/imap-core/lib/imap-connection.js +7 -0
  15. package/lib/imapproxy/imap-core/lib/imap-server.js +1 -1
  16. package/lib/imapproxy/imap-server.js +92 -29
  17. package/lib/oauth/external-account-config.js +132 -0
  18. package/lib/oauth/external-account-signer.js +256 -0
  19. package/lib/oauth/gmail.js +113 -14
  20. package/lib/oauth/verify-app.js +397 -0
  21. package/lib/oauth2-apps.js +51 -6
  22. package/lib/routes-ui.js +153 -1
  23. package/lib/schemas.js +80 -2
  24. package/lib/settings.js +1 -0
  25. package/lib/tools.js +15 -10
  26. package/package.json +28 -28
  27. package/sbom.json +1 -1
  28. package/server.js +3 -3
  29. package/static/js/ace/ace.js +1 -1
  30. package/static/js/ace/ext-searchbox.js +1 -1
  31. package/static/js/ace/mode-handlebars.js +1 -1
  32. package/static/js/ace/mode-html.js +1 -1
  33. package/static/js/ace/mode-javascript.js +1 -1
  34. package/static/js/ace/mode-markdown.js +1 -1
  35. package/static/js/ace/worker-html.js +1 -1
  36. package/static/js/ace/worker-javascript.js +1 -1
  37. package/static/js/ace/worker-json.js +1 -1
  38. package/static/licenses.html +145 -115
  39. package/translations/messages.pot +49 -49
  40. package/views/config/oauth/app.hbs +224 -0
  41. package/views/config/oauth/edit.hbs +69 -0
  42. package/views/config/oauth/new.hbs +69 -0
  43. package/views/partials/oauth_form.hbs +99 -32
  44. package/workers/api.js +91 -2
@@ -468,12 +468,16 @@ class OAuth2AppsHandler {
468
468
  }
469
469
 
470
470
  case 'gmailService': {
471
+ let storedAuthMethod = await settings.get(`${id}AuthMethod`);
472
+ let authMethod = storedAuthMethod === 'externalAccount' ? 'externalAccount' : 'serviceKey';
471
473
  let appData = {
472
474
  id: 'gmailService',
473
475
  provider: 'gmailService',
474
476
  legacy: true,
477
+ authMethod,
475
478
  serviceClient: await settings.get(`${id}Client`),
476
479
  serviceKey: await settings.get(`${id}Key`),
480
+ externalAccount: await settings.get(`${id}ExternalAccount`),
477
481
  extraScopes,
478
482
  skipScopes,
479
483
 
@@ -553,8 +557,17 @@ class OAuth2AppsHandler {
553
557
  }
554
558
  break;
555
559
 
556
- case 'gmailService':
557
- for (let key of ['serviceClient', 'serviceKey', 'extraScopes', 'skipScopes']) {
560
+ case 'gmailService': {
561
+ // The authentication method is fixed once a credential has been
562
+ // stored - never allow switching between serviceKey and externalAccount.
563
+ if (typeof updates.authMethod !== 'undefined') {
564
+ let hasStoredCredential = (await settings.get(`${id}Key`)) || (await settings.get(`${id}ExternalAccount`));
565
+ if (hasStoredCredential) {
566
+ let storedAuthMethod = await settings.get(`${id}AuthMethod`);
567
+ updates = Object.assign({}, updates, { authMethod: storedAuthMethod === 'externalAccount' ? 'externalAccount' : 'serviceKey' });
568
+ }
569
+ }
570
+ for (let key of ['serviceClient', 'serviceKey', 'externalAccount', 'authMethod', 'extraScopes', 'skipScopes']) {
558
571
  if (typeof updates[key] !== 'undefined') {
559
572
  let dataKey;
560
573
  switch (key) {
@@ -564,6 +577,12 @@ class OAuth2AppsHandler {
564
577
  case 'skipScopes':
565
578
  dataKey = 'gmailServiceSkipScopes';
566
579
  break;
580
+ case 'externalAccount':
581
+ dataKey = 'gmailServiceExternalAccount';
582
+ break;
583
+ case 'authMethod':
584
+ dataKey = 'gmailServiceAuthMethod';
585
+ break;
567
586
  default:
568
587
  dataKey = `gmail${key.replace(/^./, c => c.toUpperCase())}`;
569
588
  }
@@ -571,6 +590,7 @@ class OAuth2AppsHandler {
571
590
  }
572
591
  }
573
592
  break;
593
+ }
574
594
 
575
595
  case 'outlook':
576
596
  for (let key of ['clientId', 'clientSecret', 'redirectUrl', 'authority', 'extraScopes', 'skipScopes']) {
@@ -607,7 +627,20 @@ class OAuth2AppsHandler {
607
627
 
608
628
  async delLegacyApp(id) {
609
629
  let pipeline = redis.multi();
610
- for (let key of ['Enabled', 'RedirectUrl', 'Client', 'ClientId', 'ClientSecret', 'Authority', 'ExtraScopes', 'SkipScopes', 'Key', 'AuthFlag']) {
630
+ for (let key of [
631
+ 'Enabled',
632
+ 'RedirectUrl',
633
+ 'Client',
634
+ 'ClientId',
635
+ 'ClientSecret',
636
+ 'Authority',
637
+ 'ExtraScopes',
638
+ 'SkipScopes',
639
+ 'Key',
640
+ 'AuthFlag',
641
+ 'AuthMethod',
642
+ 'ExternalAccount'
643
+ ]) {
611
644
  pipeline = pipeline.hdel(`${REDIS_PREFIX}settings`, `${id}${key}`);
612
645
  }
613
646
  await pipeline.exec();
@@ -669,7 +702,7 @@ class OAuth2AppsHandler {
669
702
  const id = await this.generateId();
670
703
 
671
704
  let encryptedValues = {};
672
- for (let key of ['clientSecret', 'serviceKey', 'accessToken']) {
705
+ for (let key of ['clientSecret', 'serviceKey', 'accessToken', 'externalAccount']) {
673
706
  if (data[key]) {
674
707
  encryptedValues[key] = await this.encrypt(data[key]);
675
708
  }
@@ -729,10 +762,17 @@ class OAuth2AppsHandler {
729
762
 
730
763
  let existingData = msgpack.decode(existingDataBuf);
731
764
 
765
+ // The authentication method is fixed once an app has been configured with
766
+ // a credential - never allow switching between serviceKey and externalAccount
767
+ // (WIF). Pin the stored method, ignoring any incoming change.
768
+ if (existingData.provider === 'gmailService' && (existingData.serviceKey || existingData.externalAccount)) {
769
+ data = Object.assign({}, data || {}, { authMethod: existingData.authMethod === 'externalAccount' ? 'externalAccount' : 'serviceKey' });
770
+ }
771
+
732
772
  let oldPubSubApp = existingData.pubSubApp || null;
733
773
 
734
774
  let encryptedValues = {};
735
- for (let key of ['clientSecret', 'serviceKey', 'accessToken']) {
775
+ for (let key of ['clientSecret', 'serviceKey', 'accessToken', 'externalAccount']) {
736
776
  if (data[key]) {
737
777
  encryptedValues[key] = await this.encrypt(data[key]);
738
778
  }
@@ -1425,14 +1465,17 @@ class OAuth2AppsHandler {
1425
1465
  let serviceClient = appData.serviceClient;
1426
1466
 
1427
1467
  let serviceClientEmail = appData.serviceClientEmail;
1468
+ let authMethod = appData.authMethod === 'externalAccount' ? 'externalAccount' : 'serviceKey';
1428
1469
  let serviceKey = appData.serviceKey ? await this.decrypt(appData.serviceKey) : null;
1470
+ let externalAccount = appData.externalAccount ? await this.decrypt(appData.externalAccount) : null;
1429
1471
 
1430
1472
  let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
1431
1473
 
1432
1474
  let googleProjectId = appData.googleProjectId;
1433
1475
  let workspaceAccounts = appData.googleWorkspaceAccounts;
1434
1476
 
1435
- if (!serviceClient || !serviceKey) {
1477
+ let credentialsReady = serviceClient && (authMethod === 'externalAccount' ? !!externalAccount : !!serviceKey);
1478
+ if (!credentialsReady) {
1436
1479
  let error = Boom.boomify(new Error('OAuth2 credentials not set up for Gmail'), { statusCode: 400 });
1437
1480
  throw error;
1438
1481
  }
@@ -1441,7 +1484,9 @@ class OAuth2AppsHandler {
1441
1484
  Object.assign(
1442
1485
  {
1443
1486
  serviceClient,
1487
+ authMethod,
1444
1488
  serviceKey,
1489
+ externalAccount,
1445
1490
  googleProjectId,
1446
1491
  serviceClientEmail,
1447
1492
  scopes,
package/lib/routes-ui.js CHANGED
@@ -38,6 +38,7 @@ const { Gateway } = require('./gateway');
38
38
  const { redis, submitQueue, notifyQueue, documentsQueue } = require('./db');
39
39
  const psl = require('psl');
40
40
  const { oauth2Apps, OAUTH_PROVIDERS, oauth2ProviderData, SERVICE_ACCOUNT_PROVIDERS } = require('./oauth2-apps');
41
+ const { verifyOAuth2App } = require('./oauth/verify-app');
41
42
  const { autodetectImapSettings } = require('./autodetect-imap-settings');
42
43
  const getSecret = require('./get-secret');
43
44
  const os = require('os');
@@ -719,6 +720,17 @@ function applyRoutes(server, call) {
719
720
  throw error;
720
721
  }
721
722
 
723
+ // Render-context booleans for the gmailService authMethod tab selector.
724
+ // When `locked` is set the selector is shown but cannot be switched - the
725
+ // authentication method is fixed once an app has been created.
726
+ function authMethodContext(authMethod, locked) {
727
+ return {
728
+ authMethodIsServiceKey: !authMethod || authMethod === 'serviceKey',
729
+ authMethodIsExternalAccount: authMethod === 'externalAccount',
730
+ authMethodLocked: !!locked
731
+ };
732
+ }
733
+
722
734
  /**
723
735
  * Fetch the list of Pub/Sub apps and mark the one matching selectedId as selected.
724
736
  * Returns the apps array ready for template rendering.
@@ -2675,6 +2687,11 @@ return true;`
2675
2687
  app.cloudData = AZURE_CLOUDS.find(entry => entry.id === app.cloud);
2676
2688
  }
2677
2689
 
2690
+ // Service account apps scoped for email access (IMAP/SMTP or Gmail/Graph API) can register
2691
+ // accounts directly without an interactive consent flow. Pub/Sub-scoped service apps are for
2692
+ // webhook notifications only, so they must not offer the direct add-account shortcut.
2693
+ let canAddServiceAccount = SERVICE_ACCOUNT_PROVIDERS.has(app.provider) && app.enabled && app.baseScopes !== 'pubsub';
2694
+
2678
2695
  return h.view(
2679
2696
  'config/oauth/app',
2680
2697
  {
@@ -2690,6 +2707,11 @@ return true;`
2690
2707
  baseScopesImap: app.baseScopes === 'imap' || !app.baseScopes,
2691
2708
  baseScopesPubsub: app.baseScopes === 'pubsub',
2692
2709
 
2710
+ appShowAuthMethod: app.provider === 'gmailService',
2711
+ authMethodIsExternalAccount: app.authMethod === 'externalAccount',
2712
+
2713
+ canAddServiceAccount,
2714
+
2693
2715
  disabledScopes,
2694
2716
  isSendOnlyGmail,
2695
2717
 
@@ -2764,6 +2786,119 @@ return true;`
2764
2786
  }
2765
2787
  });
2766
2788
 
2789
+ server.route({
2790
+ method: 'POST',
2791
+ path: '/admin/config/oauth/app/{app}/add-account',
2792
+ async handler(request, h) {
2793
+ const appId = request.params.app;
2794
+ try {
2795
+ const app = await oauth2Apps.get(appId);
2796
+ if (!app) {
2797
+ let error = Boom.boomify(new Error('Application was not found.'), { statusCode: 404 });
2798
+ throw error;
2799
+ }
2800
+
2801
+ // Direct account registration is only valid for email-scoped service account apps. Interactive
2802
+ // providers must use the hosted consent flow, and Pub/Sub-scoped apps grant no mailbox access.
2803
+ if (!SERVICE_ACCOUNT_PROVIDERS.has(app.provider) || !app.enabled || app.baseScopes === 'pubsub') {
2804
+ let error = Boom.boomify(new Error('This application can not register accounts directly.'), { statusCode: 400 });
2805
+ throw error;
2806
+ }
2807
+
2808
+ let accountData = {
2809
+ account: request.payload.account || null,
2810
+ email: request.payload.email,
2811
+ oauth2: {
2812
+ provider: app.id,
2813
+ auth: {
2814
+ user: request.payload.email
2815
+ }
2816
+ }
2817
+ };
2818
+
2819
+ if (request.payload.name) {
2820
+ accountData.name = request.payload.name;
2821
+ }
2822
+
2823
+ const accountObject = new Account({ redis, call, secret: await getSecret() });
2824
+ const result = await accountObject.create(accountData);
2825
+
2826
+ await request.flash({ type: 'info', message: `Account ${result.state === 'existing' ? 'updated' : 'added'}` });
2827
+
2828
+ return h.redirect(`/admin/accounts/${result.account}`);
2829
+ } catch (err) {
2830
+ request.logger.error({ msg: 'Failed to register service account', err, app: appId, remoteAddress: request.app.ip });
2831
+ await request.flash({ type: 'danger', message: `Failed to add account${err.message ? `: ${err.message}` : ''}` });
2832
+ return h.redirect(`/admin/config/oauth/app/${appId}`);
2833
+ }
2834
+ },
2835
+ options: {
2836
+ validate: {
2837
+ options: {
2838
+ stripUnknown: true,
2839
+ abortEarly: false,
2840
+ convert: true
2841
+ },
2842
+
2843
+ async failAction(request, h, err) {
2844
+ request.logger.error({ msg: 'Failed to register service account', err, app: request.params.app });
2845
+ await request.flash({ type: 'danger', message: `Failed to add account. Provide a valid email address.` });
2846
+ return h.redirect(`/admin/config/oauth/app/${request.params.app}`).takeover();
2847
+ },
2848
+
2849
+ params: Joi.object({
2850
+ app: Joi.string().empty('').max(255).example('gmail').label('Provider').required()
2851
+ }),
2852
+
2853
+ payload: Joi.object({
2854
+ account: accountIdSchema.default(null),
2855
+ name: Joi.string().empty('').max(256).example('John Smith').description('Account Name'),
2856
+ email: Joi.string().email().required().example('user@example.com').label('Email').description('Mailbox email address')
2857
+ })
2858
+ }
2859
+ }
2860
+ });
2861
+
2862
+ server.route({
2863
+ method: 'POST',
2864
+ path: '/admin/config/oauth/verify/{app}',
2865
+ async handler(request, h) {
2866
+ try {
2867
+ return await verifyOAuth2App(request.params.app, {
2868
+ account: request.payload.account,
2869
+ testConnection: request.payload.testConnection
2870
+ });
2871
+ } catch (err) {
2872
+ request.logger.error({ msg: 'Failed to verify OAuth2 application', err, app: request.params.app });
2873
+ return h.response({ error: err.message, code: err.code || null }).code(err.statusCode || 500);
2874
+ }
2875
+ },
2876
+ options: {
2877
+ validate: {
2878
+ options: {
2879
+ stripUnknown: true,
2880
+ abortEarly: false,
2881
+ convert: true
2882
+ },
2883
+
2884
+ async failAction(request, h, err) {
2885
+ request.logger.error({ msg: 'Invalid verify request', err, app: request.params.app });
2886
+ return h.response({ error: 'Invalid request' }).code(400).takeover();
2887
+ },
2888
+
2889
+ params: Joi.object({
2890
+ app: Joi.string().empty('').max(255).required().label('Provider')
2891
+ }),
2892
+
2893
+ payload: Joi.object({
2894
+ crumb: Joi.string().optional(),
2895
+ account: Joi.string().trim().empty('').max(256).optional(),
2896
+ testConnection: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(true)
2897
+ })
2898
+ }
2899
+ }
2900
+ });
2901
+
2767
2902
  server.route({
2768
2903
  method: 'GET',
2769
2904
  path: '/admin/config/oauth/new',
@@ -2794,6 +2929,8 @@ return true;`
2794
2929
  baseScopesApi: false,
2795
2930
  baseScopesPubsub: false,
2796
2931
 
2932
+ ...authMethodContext('serviceKey'),
2933
+
2797
2934
  pubSubApps: await getPubSubAppsForSelect(null),
2798
2935
 
2799
2936
  azureClouds: structuredClone(AZURE_CLOUDS).map(entry => {
@@ -2805,7 +2942,8 @@ return true;`
2805
2942
 
2806
2943
  values: {
2807
2944
  provider,
2808
- redirectUrl: defaultRedirectUrl
2945
+ redirectUrl: defaultRedirectUrl,
2946
+ authMethod: 'serviceKey'
2809
2947
  },
2810
2948
 
2811
2949
  authorityCommon: true
@@ -2907,6 +3045,8 @@ return true;`
2907
3045
  baseScopesImap: baseScopes === 'imap' || !baseScopes,
2908
3046
  baseScopesPubsub: baseScopes === 'pubsub',
2909
3047
 
3048
+ ...authMethodContext(request.payload.authMethod),
3049
+
2910
3050
  azureClouds: structuredClone(AZURE_CLOUDS).map(entry => {
2911
3051
  entry.selected = request.payload.cloud === entry.id;
2912
3052
  return entry;
@@ -2978,6 +3118,8 @@ return true;`
2978
3118
  baseScopesImap: baseScopes === 'imap' || !baseScopes,
2979
3119
  baseScopesPubsub: baseScopes === 'pubsub',
2980
3120
 
3121
+ ...authMethodContext(request.payload.authMethod),
3122
+
2981
3123
  azureClouds: structuredClone(AZURE_CLOUDS).map(entry => {
2982
3124
  entry.selected = request.payload.cloud === entry.id;
2983
3125
  return entry;
@@ -3022,6 +3164,7 @@ return true;`
3022
3164
  let values = Object.assign({}, appData, {
3023
3165
  clientSecret: '',
3024
3166
  serviceKey: '',
3167
+ externalAccount: '',
3025
3168
  extraScopes: [].concat(appData.extraScopes || []).join('\n'),
3026
3169
  skipScopes: [].concat(appData.skipScopes || []).join('\n'),
3027
3170
 
@@ -3043,6 +3186,9 @@ return true;`
3043
3186
 
3044
3187
  hasClientSecret: !!appData.clientSecret,
3045
3188
  hasServiceKey: !!appData.serviceKey,
3189
+ hasExternalAccount: !!appData.externalAccount,
3190
+
3191
+ ...authMethodContext(appData.authMethod, !!appData.serviceKey || !!appData.externalAccount),
3046
3192
 
3047
3193
  pubSubApps: await getPubSubAppsForSelect(values.pubSubApp),
3048
3194
 
@@ -3151,6 +3297,9 @@ return true;`
3151
3297
 
3152
3298
  hasClientSecret: !!appData.clientSecret,
3153
3299
  hasServiceKey: !!appData.serviceKey,
3300
+ hasExternalAccount: !!appData.externalAccount,
3301
+
3302
+ ...authMethodContext(appData.authMethod, !!appData.serviceKey || !!appData.externalAccount),
3154
3303
 
3155
3304
  pubSubApps: await getPubSubAppsForSelect(request.payload.pubSubApp),
3156
3305
 
@@ -3232,6 +3381,9 @@ return true;`
3232
3381
 
3233
3382
  hasClientSecret: !!appData.clientSecret,
3234
3383
  hasServiceKey: !!appData.serviceKey,
3384
+ hasExternalAccount: !!appData.externalAccount,
3385
+
3386
+ ...authMethodContext(request.payload.authMethod),
3235
3387
 
3236
3388
  pubSubApps: await getPubSubAppsForSelect(request.payload.pubSubApp),
3237
3389
 
package/lib/schemas.js CHANGED
@@ -5,6 +5,28 @@ const config = require('@zone-eu/wild-config');
5
5
  const { getByteSize } = require('./tools');
6
6
  const { locales } = require('./translations');
7
7
  const { LEGACY_KEYS, OAUTH_PROVIDERS } = require('./oauth2-apps');
8
+ const { validateConfig: validateExternalAccountConfig } = require('./oauth/external-account-config');
9
+
10
+ // Shared Joi custom validator for the gmailService external_account credential
11
+ // JSON. Rejects malformed configurations at save time (with an inline field
12
+ // error) instead of letting them fail opaquely on the first token refresh.
13
+ const externalAccountCustomValidator = (value, helpers) => {
14
+ if (!value) {
15
+ return value;
16
+ }
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(value);
20
+ } catch (err) {
21
+ return helpers.message('externalAccount is not valid JSON: ' + err.message);
22
+ }
23
+ try {
24
+ validateExternalAccountConfig(parsed);
25
+ } catch (err) {
26
+ return helpers.message(err.message);
27
+ }
28
+ return value;
29
+ };
8
30
 
9
31
  const RESYNC_DELAY = 15 * 60;
10
32
 
@@ -1512,12 +1534,43 @@ const oauthCreateSchema = {
1512
1534
  .max(100 * 1024)
1513
1535
  .when('provider', {
1514
1536
  is: 'gmailService',
1515
- then: Joi.required(),
1537
+ then: Joi.when('authMethod', {
1538
+ is: 'externalAccount',
1539
+ then: Joi.optional().allow(''),
1540
+ otherwise: Joi.required()
1541
+ }),
1516
1542
  otherwise: Joi.optional().valid(false, null)
1517
1543
  })
1518
1544
  .example('-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgk...')
1519
1545
  .description('Service account private key in PEM format'),
1520
1546
 
1547
+ authMethod: Joi.string()
1548
+ .valid('serviceKey', 'externalAccount')
1549
+ .when('provider', {
1550
+ is: 'gmailService',
1551
+ then: Joi.optional().default('serviceKey'),
1552
+ otherwise: Joi.optional().valid(false, null)
1553
+ })
1554
+ .example('externalAccount')
1555
+ .description('Authentication method for Gmail service account ("serviceKey" or "externalAccount")'),
1556
+
1557
+ externalAccount: Joi.string()
1558
+ .trim()
1559
+ .empty('')
1560
+ .max(64 * 1024)
1561
+ .when('provider', {
1562
+ is: 'gmailService',
1563
+ then: Joi.when('authMethod', {
1564
+ is: 'externalAccount',
1565
+ then: Joi.required(),
1566
+ otherwise: Joi.optional().allow('')
1567
+ }),
1568
+ otherwise: Joi.optional().valid(false, null)
1569
+ })
1570
+ .custom(externalAccountCustomValidator, 'external account JSON shape')
1571
+ .example('{"type":"external_account","audience":"//iam.googleapis.com/projects/.../providers/...",...}')
1572
+ .description('Google external_account credential JSON for Workload Identity Federation'),
1573
+
1521
1574
  authority: Joi.string()
1522
1575
  .trim()
1523
1576
  .empty('')
@@ -1689,6 +1742,27 @@ const oauthUpdateSchema = {
1689
1742
  })
1690
1743
  .description('OAuth2 Secret Service Key'),
1691
1744
 
1745
+ authMethod: Joi.string()
1746
+ .valid('serviceKey', 'externalAccount')
1747
+ .when('provider', {
1748
+ is: 'gmailService',
1749
+ then: Joi.optional().default('serviceKey'),
1750
+ otherwise: Joi.forbidden()
1751
+ })
1752
+ .description('Authentication method for Gmail service account ("serviceKey" or "externalAccount")'),
1753
+
1754
+ externalAccount: Joi.string()
1755
+ .trim()
1756
+ .empty('', false, null)
1757
+ .max(64 * 1024)
1758
+ .when('provider', {
1759
+ is: 'gmailService',
1760
+ then: Joi.optional(),
1761
+ otherwise: Joi.forbidden()
1762
+ })
1763
+ .custom(externalAccountCustomValidator, 'external account JSON shape')
1764
+ .description('Google external_account credential JSON for Workload Identity Federation'),
1765
+
1692
1766
  authority: Joi.string()
1693
1767
  .trim()
1694
1768
  .empty('')
@@ -1851,7 +1925,6 @@ const messageReferenceSchema = Joi.object({
1851
1925
  message: Joi.string()
1852
1926
  .base64({ paddingRequired: false, urlSafe: true })
1853
1927
  .max(256)
1854
- .required()
1855
1928
  .example('AAAAAQAACnA')
1856
1929
  .description('EmailEngine message ID to reply to or forward'),
1857
1930
 
@@ -1894,8 +1967,13 @@ const messageReferenceSchema = Joi.object({
1894
1967
  .example('<test123@example.com>')
1895
1968
  .description('Verify the Message-ID of the referenced email matches this value before proceeding'),
1896
1969
 
1970
+ threadId: threadIdSchema.description(
1971
+ 'Gmail thread ID to attach the outgoing message to. Used only by Gmail-API accounts; ignored for IMAP and Microsoft Graph backends, which derive threading from RFC In-Reply-To/References headers. When both "message" and "threadId" are provided, the caller-supplied "threadId" takes precedence over the value derived from the referenced message.'
1972
+ ),
1973
+
1897
1974
  documentStore: documentStoreSchema.default(false).meta({ swaggerHidden: true })
1898
1975
  })
1976
+ .or('message', 'threadId')
1899
1977
  .description('Configuration for replying to or forwarding an existing message')
1900
1978
  .label('MessageReference');
1901
1979
 
package/lib/settings.js CHANGED
@@ -17,6 +17,7 @@ const ENCRYPTED_KEYS = [
17
17
  'smtpServerPassword',
18
18
  'serviceSecret',
19
19
  'gmailServiceKey',
20
+ 'gmailServiceExternalAccount',
20
21
  'documentStorePassword',
21
22
  'openAiAPIKey',
22
23
  'totpSeed'
package/lib/tools.js CHANGED
@@ -1571,6 +1571,11 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV3QUiYsp13nD9suD1/ZkEXnuMoSg
1571
1571
 
1572
1572
  mergeObjects(destination, source) {
1573
1573
  for (let propKey of Object.keys(source)) {
1574
+ // Guard against prototype pollution from crafted keys in parsed JSON
1575
+ if (propKey === '__proto__' || propKey === 'constructor' || propKey === 'prototype') {
1576
+ continue;
1577
+ }
1578
+
1574
1579
  let sourceVal = source[propKey];
1575
1580
 
1576
1581
  if (typeof destination[propKey] === 'undefined') {
@@ -1999,6 +2004,10 @@ s7YWddnO3q0D3Kqc
1999
2004
  BZuj6C0wk6AflB86
2000
2005
  Oa4rS6X5XHZenDBh
2001
2006
  K48CLaXb35Wyf8ud
2007
+ vlPaOaFr8rI0WLUc
2008
+ PeOFrimigEH1HdYk
2009
+ mQN7zFRsKgMoCQ5S
2010
+ pwJ9dhcxKxo7LWtz
2002
2011
  6JVHv+g7kUXqgLct
2003
2012
  fbc/2bFTnDtwN5Kc
2004
2013
  vVTQRmss6oa+o3Bj
@@ -2040,6 +2049,7 @@ Wp6z04Yho9uWIObC
2040
2049
  aPkKbjEuTbUCplG7
2041
2050
  8ORR3ntJhKaosv0/
2042
2051
  IbZu0b/MWBxfqGte
2052
+ MtZY/1P31tzM1aEv
2043
2053
  TYJ9FfWyzUlZbQaB
2044
2054
  bg5xk8Y5UaJ5MiNo
2045
2055
  V+7QViQcrIxNEjZO
@@ -2063,13 +2073,7 @@ GInvRxkFTnPN97g2
2063
2073
  zGGN/NlynQ4GvZ3e
2064
2074
  MQ87qSEBNQJip6GS
2065
2075
  Vqm95KM44kL4+7qd
2066
- DBAhSvOXy5FuI/il
2067
- C6il2vOLXSqXKzNL
2068
- RHH2IL/f3lipzTFk
2069
- 5q3TdjzMplq0+yI7
2070
- nL6uRkZxgug/JFyl
2071
2076
  uSY0M+yfff4Op/HP
2072
- tpADMZ5Ir6bmwexH
2073
2077
  h7Squx/sKZNkDat0
2074
2078
  elb9jWFuO3UHnphx
2075
2079
  //m57pmYUuiv84pZ
@@ -2103,10 +2107,6 @@ r/CVn8qB2xdyVXCg
2103
2107
  iSeRSmpWBZGpkTZi
2104
2108
  ZZkXc6t+ZDyuhEoO
2105
2109
  vPHQ3BYeEr+9DSlW
2106
- 5ChBAv/YX3PWk/Wz
2107
- YjuOzxqQEGdKLC0Q
2108
- nmu7gC+IxPl4AgjV
2109
- 43Du3vhZaEGambkT
2110
2110
  vIwyKmyMuykMUPeA
2111
2111
  RTIy3POEF2gZADpU
2112
2112
  pqvSQnQ/jx8sYGi4
@@ -2129,7 +2129,9 @@ RSvpJCGXq7lbwx3S
2129
2129
  h/TUqvLGXjB8N8xu
2130
2130
  u61uYKx5DLO9eNR7
2131
2131
  vWuuhT9ely8AUX2F
2132
+ mF3GOI+Ev7mJODtG
2132
2133
  nQNwPlZ+tyx24XVo
2134
+ olObiSmdzVaMp7lH
2133
2135
  cyp1GKV4Cl+eC8G/
2134
2136
  Q1y/TzbtUQCqnotR
2135
2137
  59Q2qOkuyTLzQDhd
@@ -2153,6 +2155,9 @@ EsbWnuADOq9qe/EZ
2153
2155
  YfUSxQtq1+kpwW/W
2154
2156
  QodzxvoJ6NPGhCgW
2155
2157
  5/VK+O950efBio0t
2158
+ RA/lrkwxcTX80nxX
2159
+ b85BMAFRHn10uX8y
2160
+ vV2RgfFH2YFTYsly
2156
2161
  `
2157
2162
  .split(/\r?\n/)
2158
2163
  .map(l => l.trim())