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
@@ -3,7 +3,7 @@
3
3
  const { redis } = require('./db');
4
4
  const msgpack = require('msgpack5')();
5
5
  const logger = require('./logger');
6
- const { REDIS_PREFIX } = require('./consts');
6
+ const { REDIS_PREFIX, TRANSIENT_NETWORK_CODES, GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL, GMAIL_PUBSUB_ACK_DEADLINE_SECONDS } = require('./consts');
7
7
  const { encrypt, decrypt } = require('./encrypt');
8
8
  const Boom = require('@hapi/boom');
9
9
  const settings = require('./settings');
@@ -11,17 +11,6 @@ const Lock = require('ioredfour');
11
11
  const getSecret = require('./get-secret');
12
12
  const { parentPort } = require('worker_threads');
13
13
 
14
- const TRANSIENT_NETWORK_CODES = new Set([
15
- 'ENOTFOUND',
16
- 'EAI_AGAIN',
17
- 'ETIMEDOUT',
18
- 'ECONNRESET',
19
- 'ECONNREFUSED',
20
- 'UND_ERR_SOCKET',
21
- 'UND_ERR_CONNECT_TIMEOUT',
22
- 'UND_ERR_HEADERS_TIMEOUT'
23
- ]);
24
-
25
14
  /**
26
15
  * Record metrics for OAuth2 token operations
27
16
  * Works in both main thread and worker threads
@@ -54,13 +43,20 @@ const { MailRuOauth, MAIL_RU_SCOPES } = require('./oauth/mail-ru');
54
43
  const LEGACY_KEYS = ['gmail', 'gmailService', 'outlook', 'mailRu'];
55
44
  const LEGACY_KEYS_REV = JSON.parse(JSON.stringify(LEGACY_KEYS)).reverse();
56
45
 
46
+ const PUBSUB_PERM_MANAGE = 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.';
47
+ const PUBSUB_PERM_VIEW = 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.';
48
+
57
49
  const OAUTH_PROVIDERS = {
58
50
  gmail: 'Gmail',
59
51
  gmailService: 'Gmail Service Accounts',
60
- outlook: 'Outlook',
52
+ outlook: 'Outlook (delegated)',
53
+ outlookService: 'Outlook (application)',
61
54
  mailRu: 'Mail.ru'
62
55
  };
63
56
 
57
+ // Providers that use app-only credentials (no interactive user login)
58
+ const SERVICE_ACCOUNT_PROVIDERS = new Set(['gmailService', 'outlookService']);
59
+
64
60
  const lock = new Lock({
65
61
  redis,
66
62
  namespace: 'ee'
@@ -108,6 +104,7 @@ function oauth2ProviderData(provider, selector) {
108
104
  };
109
105
  break;
110
106
 
107
+ case 'outlookService':
111
108
  case 'outlook':
112
109
  {
113
110
  let imapHost = 'outlook.office365.com';
@@ -129,8 +126,12 @@ function oauth2ProviderData(provider, selector) {
129
126
  }
130
127
 
131
128
  providerData.icon = 'fab fa-microsoft';
132
- providerData.tutorialUrl = 'https://emailengine.app/outlook-and-ms-365';
133
- providerData.linkImage = '/static/providers/ms_light.svg';
129
+ if (provider === 'outlook') {
130
+ providerData.tutorialUrl = 'https://emailengine.app/outlook-and-ms-365';
131
+ providerData.linkImage = '/static/providers/ms_light.svg';
132
+ } else {
133
+ providerData.tutorialUrl = 'https://learn.emailengine.app/docs/accounts/outlook-client-credentials';
134
+ }
134
135
  providerData.imap = {
135
136
  host: imapHost,
136
137
  port: 993,
@@ -176,11 +177,13 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
176
177
  defaultScopes = (baseScopes && defaultScopesList[baseScopes]) || defaultScopesList.imap;
177
178
  }
178
179
 
179
- let extras = [];
180
180
  if (!extraScopes && !skipScopes.length) {
181
181
  return defaultScopes;
182
182
  }
183
183
 
184
+ extraScopes = extraScopes || [];
185
+
186
+ let extras = [];
184
187
  for (let extraScope of extraScopes) {
185
188
  if (defaultScopes.includes(extraScope) || (scopePrefix && defaultScopes.includes(`${scopePrefix}/${extraScope}`))) {
186
189
  // skip existing
@@ -192,19 +195,16 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
192
195
  let result = extras.length ? extras.concat(defaultScopes) : defaultScopes;
193
196
 
194
197
  if (skipScopes.length) {
195
- result = result.filter(scope => {
196
- for (let skipScope of skipScopes) {
197
- if (
198
- scope === skipScope ||
199
- scope === `https://outlook.office.com/${skipScope}` ||
200
- scope === `https://graph.microsoft.com/${skipScope}` ||
201
- scope === `https://www.googleapis.com/auth/${skipScope}`
202
- ) {
203
- return false;
204
- }
205
- }
206
- return true;
207
- });
198
+ result = result.filter(
199
+ scope =>
200
+ !skipScopes.some(
201
+ skipScope =>
202
+ scope === skipScope ||
203
+ scope === `https://outlook.office.com/${skipScope}` ||
204
+ scope === `https://graph.microsoft.com/${skipScope}` ||
205
+ scope === `https://www.googleapis.com/auth/${skipScope}`
206
+ )
207
+ );
208
208
  }
209
209
 
210
210
  return result;
@@ -216,6 +216,9 @@ class OAuth2AppsHandler {
216
216
  this.redis = this.options.redis;
217
217
 
218
218
  this.secret = null;
219
+
220
+ this._pubSubBackfillDone = false;
221
+ this._pubSubBackfillPromise = null;
219
222
  }
220
223
 
221
224
  async encrypt(value) {
@@ -252,6 +255,56 @@ class OAuth2AppsHandler {
252
255
  return `${REDIS_PREFIX}settings`;
253
256
  }
254
257
 
258
+ async backfillPubSubApps() {
259
+ if (this._pubSubBackfillDone) {
260
+ return await this.redis.smembers(this.getSubscribersKey());
261
+ }
262
+
263
+ if (this._pubSubBackfillPromise) {
264
+ return await this._pubSubBackfillPromise;
265
+ }
266
+
267
+ this._pubSubBackfillPromise = this._doBackfillPubSubApps();
268
+ try {
269
+ return await this._pubSubBackfillPromise;
270
+ } finally {
271
+ this._pubSubBackfillPromise = null;
272
+ }
273
+ }
274
+
275
+ async _doBackfillPubSubApps() {
276
+ let [subscriberIds, allIds] = await Promise.all([this.redis.smembers(this.getSubscribersKey()), this.redis.smembers(this.getIndexKey())]);
277
+ let subscriberSet = new Set(subscriberIds);
278
+ let missingIds = allIds.filter(id => !subscriberSet.has(id));
279
+ if (!missingIds.length) {
280
+ this._pubSubBackfillDone = true;
281
+ return subscriberIds;
282
+ }
283
+ let bufKeys = missingIds.map(id => `${id}:data`);
284
+ let entries = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
285
+ let toAdd = [];
286
+ for (let i = 0; i < entries.length; i++) {
287
+ if (!entries[i]) {
288
+ continue;
289
+ }
290
+ try {
291
+ let data = msgpack.decode(entries[i]);
292
+ if (data.baseScopes === 'pubsub') {
293
+ toAdd.push(missingIds[i]);
294
+ logger.info({ msg: 'Backfilled pubsub app to subscribers set', app: missingIds[i] });
295
+ }
296
+ } catch (err) {
297
+ logger.warn({ msg: 'Failed to decode app data during backfill', app: missingIds[i], err });
298
+ }
299
+ }
300
+ if (toAdd.length) {
301
+ await this.redis.sadd(this.getSubscribersKey(), ...toAdd);
302
+ subscriberIds.push(...toAdd);
303
+ }
304
+ this._pubSubBackfillDone = true;
305
+ return subscriberIds;
306
+ }
307
+
255
308
  async list(page, pageSize, opts) {
256
309
  opts = opts || {};
257
310
  page = Math.max(Number(page) || 0, 0);
@@ -259,14 +312,12 @@ class OAuth2AppsHandler {
259
312
 
260
313
  let startPos = page * pageSize;
261
314
 
262
- let keyFunc;
315
+ let idList;
263
316
  if (opts.pubsub) {
264
- keyFunc = 'getSubscribersKey';
317
+ idList = await this.backfillPubSubApps();
265
318
  } else {
266
- keyFunc = 'getIndexKey';
319
+ idList = await this.redis.smembers(this.getIndexKey());
267
320
  }
268
-
269
- let idList = await this.redis.smembers(this[keyFunc]());
270
321
  idList = [].concat(idList || []).sort((a, b) => -a.localeCompare(b));
271
322
 
272
323
  if (!opts.pubsub) {
@@ -303,7 +354,7 @@ class OAuth2AppsHandler {
303
354
  let data = await this.get(legacyKey);
304
355
  response.apps.push(data);
305
356
  } catch (err) {
306
- logger.error({ msg: 'Failed to process legacy app', legacyKey });
357
+ logger.error({ msg: 'Failed to process legacy app', legacyKey, err });
307
358
  continue;
308
359
  }
309
360
  }
@@ -311,18 +362,21 @@ class OAuth2AppsHandler {
311
362
  if (keys.length) {
312
363
  let bufKeys = keys.flatMap(id => [`${id}:data`, `${id}:meta`]);
313
364
  let list = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
314
- for (let i = 0; i < list.length; i++) {
315
- let entry = list[i];
365
+ for (let i = 0; i < list.length; i += 2) {
366
+ let dataEntry = list[i];
367
+ let metaEntry = list[i + 1];
316
368
  try {
317
- if (i % 2 === 0) {
318
- let data = msgpack.decode(entry);
319
- response.apps.push(data);
320
- } else if (entry) {
321
- response.apps[response.apps.length - 1].meta = msgpack.decode(entry);
369
+ let data = msgpack.decode(dataEntry);
370
+ if (metaEntry) {
371
+ try {
372
+ data.meta = msgpack.decode(metaEntry);
373
+ } catch (metaErr) {
374
+ logger.error({ msg: 'Failed to process app meta', entryLength: metaEntry?.length || 0 });
375
+ }
322
376
  }
377
+ response.apps.push(data);
323
378
  } catch (err) {
324
- logger.error({ msg: 'Failed to process app', entry: entry.toString('base64') });
325
- continue;
379
+ logger.error({ msg: 'Failed to process app', entryLength: dataEntry?.length || 0 });
326
380
  }
327
381
  }
328
382
  }
@@ -357,7 +411,7 @@ class OAuth2AppsHandler {
357
411
 
358
412
  response.apps.forEach(app => {
359
413
  app.includeInListing = !!app.enabled;
360
- if (['gmailService'].includes(app.provider)) {
414
+ if (SERVICE_ACCOUNT_PROVIDERS.has(app.provider)) {
361
415
  // service accounts are always enabled
362
416
  app.enabled = true;
363
417
  app.includeInListing = false;
@@ -370,13 +424,11 @@ class OAuth2AppsHandler {
370
424
  async generateId() {
371
425
  let idNum = await this.redis.hincrby(this.getSettingsKey(), 'idcount', 1);
372
426
 
373
- let idBuf = Buffer.alloc(8 + 4);
427
+ let idBuf = Buffer.alloc(12);
374
428
  idBuf.writeBigUInt64BE(BigInt(Date.now()), 0);
375
429
  idBuf.writeUInt32BE(idNum, 8);
376
430
 
377
- const id = idBuf.toString('base64url');
378
-
379
- return id;
431
+ return idBuf.toString('base64url');
380
432
  }
381
433
 
382
434
  async getLegacyApp(id) {
@@ -450,7 +502,7 @@ class OAuth2AppsHandler {
450
502
  extraScopes,
451
503
  skipScopes,
452
504
 
453
- name: 'Outlook',
505
+ name: 'Outlook (delegated)',
454
506
  description: 'Legacy OAuth2 app',
455
507
 
456
508
  meta: {
@@ -572,7 +624,7 @@ class OAuth2AppsHandler {
572
624
  // legacy
573
625
  let data = await this.getLegacyApp(id);
574
626
  data.includeInListing = !!data.enabled;
575
- if (['gmailService'].includes(data.provider)) {
627
+ if (SERVICE_ACCOUNT_PROVIDERS.has(data.provider)) {
576
628
  // service account are always enabled
577
629
  data.enabled = true;
578
630
  data.includeInListing = false;
@@ -589,20 +641,20 @@ class OAuth2AppsHandler {
589
641
  try {
590
642
  data = msgpack.decode(getDataBuf);
591
643
  } catch (err) {
592
- logger.error({ msg: 'Failed to process app', app: id, entry: getDataBuf.toString('base64') });
644
+ logger.error({ msg: 'Failed to process app', app: id, entryLength: getDataBuf?.length || 0 });
593
645
  throw err;
594
646
  }
595
647
 
596
648
  if (getMetaBuf) {
597
649
  try {
598
- data = Object.assign(data, { meta: msgpack.decode(getMetaBuf) });
650
+ data.meta = msgpack.decode(getMetaBuf);
599
651
  } catch (err) {
600
- logger.error({ msg: 'Failed to process app', app: id, entry: getMetaBuf.toString('base64') });
652
+ logger.error({ msg: 'Failed to process app meta', app: id, entryLength: getMetaBuf?.length || 0 });
601
653
  }
602
654
  }
603
655
 
604
656
  data.includeInListing = !!data.enabled;
605
- if (['gmailService'].includes(data.provider)) {
657
+ if (SERVICE_ACCOUNT_PROVIDERS.has(data.provider)) {
606
658
  // service account are always enabled
607
659
  data.enabled = true;
608
660
  data.includeInListing = false;
@@ -635,7 +687,7 @@ class OAuth2AppsHandler {
635
687
  [`${id}:data`]: msgpack.encode(entry)
636
688
  });
637
689
 
638
- if (data.pubSubSubscription) {
690
+ if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
639
691
  insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
640
692
  }
641
693
 
@@ -645,9 +697,9 @@ class OAuth2AppsHandler {
645
697
 
646
698
  let insertResult = await insertResultReq.exec();
647
699
 
648
- let hasError = (insertResult[0] && insertResult[0][0]) || (insertResult[1] && insertResult[1][0]);
649
- if (hasError) {
650
- throw hasError;
700
+ let errorEntry = insertResult.find(entry => entry && entry[0]);
701
+ if (errorEntry) {
702
+ throw errorEntry[0];
651
703
  }
652
704
 
653
705
  const result = {
@@ -655,17 +707,7 @@ class OAuth2AppsHandler {
655
707
  created: true
656
708
  };
657
709
 
658
- try {
659
- let appData = await this.get(id);
660
- if (appData.baseScopes === 'pubsub') {
661
- let pubsubUpdates = await this.ensurePubsub(appData);
662
- if (Object.keys(pubsubUpdates || {})) {
663
- result.pubsubUpdates = pubsubUpdates;
664
- }
665
- }
666
- } catch (err) {
667
- logger.error({ msg: 'Failed to set up pubsub', app: id, err });
668
- }
710
+ await this.tryEnsurePubsub(id, result);
669
711
 
670
712
  return result;
671
713
  }
@@ -687,6 +729,8 @@ class OAuth2AppsHandler {
687
729
 
688
730
  let existingData = msgpack.decode(existingDataBuf);
689
731
 
732
+ let oldPubSubApp = existingData.pubSubApp || null;
733
+
690
734
  let encryptedValues = {};
691
735
  for (let key of ['clientSecret', 'serviceKey', 'accessToken']) {
692
736
  if (data[key]) {
@@ -706,22 +750,25 @@ class OAuth2AppsHandler {
706
750
 
707
751
  let insertResultReq = this.redis.multi().sadd(this.getIndexKey(), id).hmset(this.getDataKey(), updates);
708
752
 
709
- if (data.pubSubSubscription) {
753
+ if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
710
754
  insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
711
755
  }
712
756
 
713
757
  if (data.pubSubApp) {
714
- if (existingData.pubSubApp && existingData.pubSubApp !== data.pubSubApp) {
715
- insertResultReq = insertResultReq.srem(this.getPubsubAppKey(existingData.pubSubApp), id);
758
+ if (oldPubSubApp && oldPubSubApp !== data.pubSubApp) {
759
+ insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
716
760
  }
717
761
  insertResultReq = insertResultReq.sadd(this.getPubsubAppKey(data.pubSubApp), id);
762
+ } else if (oldPubSubApp && 'pubSubApp' in data) {
763
+ // pubSubApp was explicitly cleared
764
+ insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
718
765
  }
719
766
 
720
767
  let insertResult = await insertResultReq.exec();
721
768
 
722
- let hasError = (insertResult[0] && insertResult[0][0]) || (insertResult[1] && insertResult[1][0]);
723
- if (hasError) {
724
- throw hasError;
769
+ let errorEntry = insertResult.find(entry => entry && entry[0]);
770
+ if (errorEntry) {
771
+ throw errorEntry[0];
725
772
  }
726
773
 
727
774
  if (opts.partial) {
@@ -739,17 +786,7 @@ class OAuth2AppsHandler {
739
786
  updated: true
740
787
  };
741
788
 
742
- try {
743
- let appData = await this.get(id);
744
- if (appData.baseScopes === 'pubsub') {
745
- let pubsubUpdates = await this.ensurePubsub(appData);
746
- if (Object.keys(pubsubUpdates || {})) {
747
- result.pubsubUpdates = pubsubUpdates;
748
- }
749
- }
750
- } catch (err) {
751
- logger.error({ msg: 'Failed to set up pubsub', app: id, err });
752
- }
789
+ await this.tryEnsurePubsub(id, result);
753
790
 
754
791
  return result;
755
792
  }
@@ -762,148 +799,224 @@ class OAuth2AppsHandler {
762
799
 
763
800
  let appData = await this.get(id);
764
801
 
765
- if (appData.pubSubTopic) {
766
- // try to delete topic
802
+ if (!appData) {
803
+ return { id, deleted: false, accounts: 0 };
804
+ }
805
+
806
+ // Acquire the same lock used by ensurePubsub to prevent racing with recovery
807
+ let needsPubSubLock = appData.pubSubTopic || appData.baseScopes === 'pubsub';
808
+ let delLock;
809
+
810
+ if (needsPubSubLock) {
811
+ let lockKey = ['oauth', 'pubsub', appData.id].join(':');
767
812
  try {
768
- await this.deleteTopic(appData);
813
+ delLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
814
+ if (!delLock.success) {
815
+ throw new Error('Failed to get lock for pubsub app deletion');
816
+ }
769
817
  } catch (err) {
770
- logger.error({ msg: 'Failed to delete existing pubsub topic', app: appData.id, topic: appData.pubSubTopic, err });
818
+ logger.error({ msg: 'Failed to acquire lock for pubsub app deletion', lockKey, err });
819
+ throw err;
771
820
  }
772
821
  }
773
822
 
774
- let pipeline = this.redis
775
- .multi()
776
- .srem(this.getIndexKey(), id)
777
- .hdel(this.getDataKey(), [`${id}:data`, `${id}:meta`])
778
- .scard(`${REDIS_PREFIX}oapp:a:${id}`)
779
- .del(`${REDIS_PREFIX}oapp:a:${id}`)
780
- .del(`${REDIS_PREFIX}oapp:h:${id}`)
781
- .srem(this.getSubscribersKey(), id);
782
-
783
- if (appData.pubsubApp) {
784
- pipeline = pipeline.srem(this.getPubsubAppKey(appData.pubSubApp), id);
785
- }
823
+ try {
824
+ if (appData.pubSubTopic) {
825
+ // try to delete topic
826
+ try {
827
+ await this.deleteTopic(appData);
828
+ } catch (err) {
829
+ logger.error({
830
+ msg: 'Failed to delete existing pubsub topic',
831
+ app: appData.id,
832
+ topic: appData.pubSubTopic,
833
+ subscription: appData.pubSubSubscription,
834
+ err
835
+ });
836
+ }
837
+ }
786
838
 
787
- let deleteResult = await pipeline.exec();
839
+ let pipeline = this.redis
840
+ .multi()
841
+ .srem(this.getIndexKey(), id)
842
+ .hdel(this.getDataKey(), [`${id}:data`, `${id}:meta`])
843
+ .scard(`${REDIS_PREFIX}oapp:a:${id}`)
844
+ .del(`${REDIS_PREFIX}oapp:a:${id}`)
845
+ .del(`${REDIS_PREFIX}oapp:h:${id}`)
846
+ .srem(this.getSubscribersKey(), id);
847
+
848
+ if (appData.pubSubApp) {
849
+ pipeline = pipeline.srem(this.getPubsubAppKey(appData.pubSubApp), id);
850
+ }
788
851
 
789
- let hasError = (deleteResult[0] && deleteResult[0][0]) || (deleteResult[1] && deleteResult[1][0]);
790
- if (hasError) {
791
- throw hasError;
792
- }
852
+ if (appData.baseScopes === 'pubsub') {
853
+ pipeline = pipeline.del(this.getPubsubAppKey(id));
854
+ }
793
855
 
794
- let deletedDocs = ((deleteResult[0] && deleteResult[0][1]) || 0) + ((deleteResult[1] && deleteResult[1][1]) || 0);
856
+ let deleteResult = await pipeline.exec();
795
857
 
796
- return {
797
- id,
798
- deleted: deletedDocs >= 2,
799
- accounts: Number(deleteResult[2] && deleteResult[2][1]) || 0
800
- };
858
+ let errorEntry = deleteResult.find(entry => entry && entry[0]);
859
+ if (errorEntry) {
860
+ throw errorEntry[0];
861
+ }
862
+
863
+ let deletedDocs = ((deleteResult[0] && deleteResult[0][1]) || 0) + ((deleteResult[1] && deleteResult[1][1]) || 0);
864
+
865
+ return {
866
+ id,
867
+ deleted: deletedDocs >= 2,
868
+ accounts: Number(deleteResult[2] && deleteResult[2][1]) || 0
869
+ };
870
+ } finally {
871
+ if (delLock?.success) {
872
+ await lock.releaseLock(delLock);
873
+ }
874
+ }
801
875
  }
802
876
 
877
+ // Note: not atomic (read-modify-write). Concurrent callers use last-write-wins,
878
+ // which is acceptable for self-correcting informational metadata.
803
879
  async setMeta(id, meta) {
804
- let existingMeta;
805
880
  let existingMetaBuf = await this.redis.hgetBuffer(this.getDataKey(), `${id}:meta`);
806
- if (!existingMetaBuf) {
807
- existingMeta = {};
808
- } else {
809
- existingMeta = msgpack.decode(existingMetaBuf);
810
- }
881
+ let existingMeta = existingMetaBuf ? msgpack.decode(existingMetaBuf) : {};
811
882
 
812
883
  let entry = Object.assign(existingMeta, meta || {});
813
884
 
814
- let updates = {
885
+ await this.redis.hmset(this.getDataKey(), {
815
886
  [`${id}:meta`]: msgpack.encode(entry)
816
- };
817
-
818
- await this.redis.hmset(this.getDataKey(), updates);
887
+ });
819
888
 
820
- return {
821
- id,
822
- updated: true
823
- };
889
+ return { id, updated: true };
824
890
  }
825
891
 
826
- async deleteTopic(appData) {
827
- let topicName = appData.pubSubTopic;
828
- let topicUrl = `https://pubsub.googleapis.com/v1/${topicName}`;
829
-
830
- let subscriptionName = appData.pubSubSubscription;
831
- let subscriptionUrl = `https://pubsub.googleapis.com/v1/${subscriptionName}`;
832
-
833
- let client = await this.getClient(appData.id);
834
-
835
- // Step 1. Get access token for service client
836
- let accessToken = await this.getServiceAccessToken(appData, client);
837
- if (!accessToken) {
838
- throw new Error('Failed to get access token');
839
- }
892
+ /**
893
+ * Fire-and-forget setMeta call that logs errors instead of throwing.
894
+ * Used in ensurePubsub error handlers where we want to record a flag
895
+ * but must not block or alter the original error flow.
896
+ */
897
+ setMetaFireAndForget(appId, meta) {
898
+ this.setMeta(appId, meta).catch(metaErr => {
899
+ logger.error({ msg: 'Failed to set metadata', app: appId, err: metaErr });
900
+ });
901
+ }
840
902
 
903
+ /**
904
+ * Try to set up Pub/Sub for the given app and attach results to the result object.
905
+ * Errors are logged but not thrown so they do not prevent create/update from succeeding.
906
+ */
907
+ async tryEnsurePubsub(id, result) {
841
908
  try {
842
- if (topicName) {
843
- // fails if topic does not exist
844
- await client.request(accessToken, topicUrl, 'DELETE', Buffer.alloc(0), { returnText: true });
845
- /*
846
- {}
847
- */
909
+ let appData = await this.get(id);
910
+ if (appData.baseScopes === 'pubsub') {
911
+ let pubsubUpdates = await this.ensurePubsub(appData);
912
+ result.pubsubUpdates = pubsubUpdates || {};
848
913
  }
849
914
  } catch (err) {
850
- switch (err?.oauthRequest?.response?.error?.code) {
851
- case 403:
852
- // no permissions
853
- logger.error({
854
- msg: 'Service client does not have permissions to delete Pub/Sub topics. Make sure the role for the service user is "Pub/Sub Admin".',
855
- app: appData.id,
856
- topic: topicName
857
- });
858
- throw err;
859
- case 404: {
860
- // does not exist
861
- logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
862
- break;
863
- }
864
- default:
865
- throw err;
866
- }
915
+ logger.error({ msg: 'Failed to set up pubsub', app: id, err });
867
916
  }
917
+ }
868
918
 
919
+ async _retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, reason) {
920
+ logger.warn({
921
+ msg: `${reason} deleting Pub/Sub ${resourceType}, retrying in ${delay}ms`,
922
+ app: appData.id,
923
+ [resourceType]: resourceName
924
+ });
925
+ await new Promise(resolve => setTimeout(resolve, delay));
869
926
  try {
870
- if (subscriptionName) {
871
- // fails if subscription does not exist
872
- await client.request(accessToken, subscriptionUrl, 'DELETE', Buffer.alloc(0), { returnText: true });
873
- /*
874
- {}
875
- */
927
+ await doDelete();
928
+ } catch (retryErr) {
929
+ if (retryErr?.oauthRequest?.response?.error?.code === 404) {
930
+ logger.info({
931
+ msg: `${resourceType} no longer exists after retry`,
932
+ app: appData.id,
933
+ [resourceType]: resourceName
934
+ });
935
+ return;
876
936
  }
937
+ logger.error({
938
+ msg: `Retry failed deleting Pub/Sub ${resourceType}, resource may need manual cleanup`,
939
+ app: appData.id,
940
+ [resourceType]: resourceName,
941
+ code: retryErr.code || retryErr?.oauthRequest?.response?.error?.code
942
+ });
943
+ throw retryErr;
944
+ }
945
+ }
946
+
947
+ async _deletePubSubResource(client, accessToken, appData, resourceType, resourceName) {
948
+ if (!resourceName) {
949
+ return;
950
+ }
951
+ let url = `https://pubsub.googleapis.com/v1/${resourceName}`;
952
+ const doDelete = () => client.request(accessToken, url, 'DELETE', Buffer.alloc(0), { returnText: true });
953
+ try {
954
+ await doDelete();
877
955
  } catch (err) {
878
956
  switch (err?.oauthRequest?.response?.error?.code) {
879
957
  case 403:
880
- // no permissions
881
958
  logger.error({
882
- msg: 'Service client does not have permissions to delete Pub/Sub subscriptions. Make sure the role for the service user is "Pub/Sub Admin".',
959
+ msg: `Service client does not have permissions to delete Pub/Sub ${resourceType}s. Make sure the role for the service user is "Pub/Sub Admin".`,
883
960
  app: appData.id,
884
- topic: topicName
961
+ [resourceType]: resourceName
885
962
  });
886
963
  throw err;
887
- case 404: {
888
- // does not exist
889
- logger.info({ msg: 'Subscription does not exist', app: appData.id, topic: topicName });
964
+ case 404:
965
+ logger.info({ msg: `${resourceType} does not exist`, app: appData.id, [resourceType]: resourceName });
966
+ break;
967
+ case 429: {
968
+ let retryAfter = err.retryAfter || err.oauthRequest?.retryAfter;
969
+ let delay = retryAfter ? Math.min(retryAfter * 1000, 30000) : 2000;
970
+ await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, 'Rate limited');
890
971
  break;
891
972
  }
892
973
  default:
974
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
975
+ await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, 2000, 'Transient network error');
976
+ break;
977
+ }
893
978
  throw err;
894
979
  }
895
980
  }
896
981
  }
897
982
 
983
+ async deleteTopic(appData) {
984
+ let client = await this.getClient(appData.id);
985
+
986
+ let accessToken = await this.getServiceAccessToken(appData, client);
987
+ if (!accessToken) {
988
+ throw new Error('Failed to get access token');
989
+ }
990
+
991
+ await this._deletePubSubResource(client, accessToken, appData, 'subscription', appData.pubSubSubscription);
992
+ await this._deletePubSubResource(client, accessToken, appData, 'topic', appData.pubSubTopic);
993
+ }
994
+
898
995
  async ensurePubsub(appData) {
899
996
  let project = appData.googleProjectId;
900
997
 
901
998
  let topic = appData.googleTopicName || `ee-pub-${appData.id}`;
902
999
  let subscription = appData.googleSubscriptionName || `ee-sub-${appData.id}`;
903
1000
 
1001
+ // Determine desired expirationPolicy from setting
1002
+ let gmailSubscriptionTtl = await settings.get('gmailSubscriptionTtl');
1003
+ let desiredExpirationPolicy;
1004
+ if (gmailSubscriptionTtl === 0) {
1005
+ // Indefinite (no expiration)
1006
+ desiredExpirationPolicy = {};
1007
+ } else if (typeof gmailSubscriptionTtl === 'number' && gmailSubscriptionTtl > 0) {
1008
+ // Convert days to seconds for Google API
1009
+ desiredExpirationPolicy = { ttl: `${gmailSubscriptionTtl * 86400}s` };
1010
+ } else {
1011
+ // Not set - let Google apply its default 31-day TTL
1012
+ desiredExpirationPolicy = null;
1013
+ }
1014
+
904
1015
  let results = {};
1016
+ let ttlPatchFailed = null;
905
1017
 
906
- if (!project || !topic) {
1018
+ if (!project) {
1019
+ logger.warn({ msg: 'googleProjectId is required for Pub/Sub setup', app: appData.id });
907
1020
  return results;
908
1021
  }
909
1022
 
@@ -916,130 +1029,181 @@ class OAuth2AppsHandler {
916
1029
  const member = 'serviceAccount:gmail-api-push@system.gserviceaccount.com';
917
1030
  const role = 'roles/pubsub.publisher';
918
1031
 
919
- let client = await this.getClient(appData.id);
920
-
921
- // Step 1. Get access token for service client
922
- let accessToken = await this.getServiceAccessToken(appData, client);
923
- if (!accessToken) {
924
- throw new Error('Failed to get access token');
1032
+ // Acquire per-app lock to prevent concurrent ensurePubsub races (e.g., user update + background recovery)
1033
+ let lockKey = ['oauth', 'pubsub', appData.id].join(':');
1034
+ let ensureLock;
1035
+ try {
1036
+ ensureLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
1037
+ if (!ensureLock.success) {
1038
+ logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey });
1039
+ throw new Error('Failed to get ensurePubsub lock');
1040
+ }
1041
+ } catch (err) {
1042
+ logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey, err });
1043
+ throw err;
925
1044
  }
926
1045
 
927
- // Step 2. Ensure topic
928
1046
  try {
929
- // fails if topic does not exist
930
- await client.request(accessToken, topicUrl, 'GET');
931
- logger.debug({ msg: 'Topic already exists', app: appData.id, topic: topicName });
932
- /*
933
- {name: 'projects/...'}
934
- */
935
- } catch (err) {
936
- if (TRANSIENT_NETWORK_CODES.has(err.code)) {
937
- logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
938
- throw err;
1047
+ let client = await this.getClient(appData.id);
1048
+
1049
+ // Step 1. Get access token for service client
1050
+ let accessToken = await this.getServiceAccessToken(appData, client);
1051
+ if (!accessToken) {
1052
+ throw new Error('Failed to get access token');
939
1053
  }
940
- switch (err?.oauthRequest?.response?.error?.code) {
941
- case 403:
942
- // no permissions
943
- if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
944
- this.setMeta(appData.id, {
945
- authFlag: {
946
- message:
947
- 'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
948
- description: err?.oauthRequest?.response?.error?.message
949
- }
950
- });
951
- } else {
952
- this.setMeta(appData.id, {
953
- authFlag: {
954
- message: 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
955
- }
956
- });
957
- }
958
1054
 
1055
+ // Step 2. Ensure topic
1056
+ try {
1057
+ // fails if topic does not exist
1058
+ await client.request(accessToken, topicUrl, 'GET');
1059
+ logger.debug({ msg: 'Topic already exists', app: appData.id, topic: topicName });
1060
+ /*
1061
+ {name: 'projects/...'}
1062
+ */
1063
+ } catch (err) {
1064
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1065
+ logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
959
1066
  throw err;
960
- case 404: {
961
- // does not exist
962
- logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
963
- try {
964
- let topicCreateRes = await client.request(accessToken, topicUrl, 'PUT', Buffer.alloc(0));
965
- /*
1067
+ }
1068
+ switch (err?.oauthRequest?.response?.error?.code) {
1069
+ case 403:
1070
+ // no permissions
1071
+ if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
1072
+ this.setMetaFireAndForget(appData.id, {
1073
+ authFlag: {
1074
+ message:
1075
+ 'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
1076
+ description: err?.oauthRequest?.response?.error?.message
1077
+ },
1078
+ ttlWarning: null
1079
+ });
1080
+ } else {
1081
+ this.setMetaFireAndForget(appData.id, {
1082
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1083
+ ttlWarning: null
1084
+ });
1085
+ }
1086
+
1087
+ throw err;
1088
+ case 404: {
1089
+ // does not exist
1090
+ logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
1091
+ try {
1092
+ let topicCreateRes = await client.request(accessToken, topicUrl, 'PUT', Buffer.alloc(0));
1093
+ /*
966
1094
  {name: 'projects/...'}
967
1095
  */
968
- if (!topicCreateRes?.name) {
969
- throw new Error('Topic was not created');
970
- }
1096
+ if (!topicCreateRes?.name) {
1097
+ throw new Error('Topic was not created', { cause: err });
1098
+ }
971
1099
 
972
- await this.update(
973
- appData.id,
974
- {
975
- pubSubTopic: topicName
976
- },
977
- { partial: true }
978
- );
979
-
980
- results.pubSubTopic = topicName;
981
- } catch (err) {
982
- if (TRANSIENT_NETWORK_CODES.has(err.code)) {
983
- logger.warn({ msg: 'Network error creating Pub/Sub topic', app: appData.id, code: err.code });
984
- throw err;
985
- }
986
- switch (err?.oauthRequest?.response?.error?.code) {
987
- case 403:
988
- // no permissions
989
- this.setMeta(appData.id, {
990
- authFlag: {
991
- message:
992
- 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
993
- }
994
- });
995
- throw err;
996
- case 409:
997
- // already exists
998
- logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
999
- break;
1000
- default:
1100
+ await this.update(
1101
+ appData.id,
1102
+ {
1103
+ pubSubTopic: topicName
1104
+ },
1105
+ { partial: true }
1106
+ );
1107
+
1108
+ results.pubSubTopic = topicName;
1109
+ } catch (err) {
1110
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1111
+ logger.warn({ msg: 'Network error creating Pub/Sub topic', app: appData.id, code: err.code });
1001
1112
  throw err;
1113
+ }
1114
+ switch (err?.oauthRequest?.response?.error?.code) {
1115
+ case 403:
1116
+ // no permissions
1117
+ this.setMetaFireAndForget(appData.id, {
1118
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1119
+ ttlWarning: null
1120
+ });
1121
+ throw err;
1122
+ case 409:
1123
+ // already exists
1124
+ logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
1125
+ break;
1126
+ default:
1127
+ throw err;
1128
+ }
1002
1129
  }
1130
+ break;
1003
1131
  }
1004
- break;
1132
+ default:
1133
+ throw err;
1005
1134
  }
1006
- default:
1007
- throw err;
1008
1135
  }
1009
- }
1010
1136
 
1011
- // Step 3. Set up subscriber
1137
+ // Step 3. Set up subscriber
1012
1138
 
1013
- try {
1014
- // fails if topic does not exist
1015
- await client.request(accessToken, subscriptionUrl, 'GET');
1016
- logger.debug({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1017
- /*
1139
+ try {
1140
+ // fails if topic does not exist
1141
+ let subscriptionData = await client.request(accessToken, subscriptionUrl, 'GET');
1142
+ logger.debug({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1143
+ /*
1018
1144
  {name: 'projects/...'}
1019
1145
  */
1020
- } catch (err) {
1021
- if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1022
- logger.warn({ msg: 'Network error checking Pub/Sub subscription', app: appData.id, code: err.code });
1023
- throw err;
1024
- }
1025
- switch (err?.oauthRequest?.response?.error?.code) {
1026
- case 403:
1027
- // no permissions
1028
- this.setMeta(appData.id, {
1029
- authFlag: {
1030
- message: 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
1031
- }
1032
- });
1033
- throw err;
1034
- case 404: {
1035
- // does not exist
1036
- logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
1146
+
1147
+ // Patch existing subscription's expiration policy if it differs from desired
1148
+ // When no explicit TTL is configured, use Google's default (31 days) for comparison
1149
+ let effectivePolicy = desiredExpirationPolicy ?? { ttl: GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL };
1150
+ let currentTtl = subscriptionData?.expirationPolicy?.ttl || null;
1151
+ let desiredTtl = effectivePolicy.ttl || null;
1152
+ if (currentTtl !== desiredTtl) {
1037
1153
  try {
1038
- let subscriptionCreateRes = await client.request(accessToken, subscriptionUrl, 'PUT', {
1039
- topic: topicName,
1040
- ackDeadlineSeconds: 30
1154
+ await client.request(accessToken, subscriptionUrl, 'PATCH', {
1155
+ subscription: { expirationPolicy: effectivePolicy },
1156
+ updateMask: 'expirationPolicy'
1041
1157
  });
1042
- /*
1158
+ logger.info({
1159
+ msg: 'Updated expiration policy on subscription',
1160
+ app: appData.id,
1161
+ subscription: subscriptionName,
1162
+ desiredTtl
1163
+ });
1164
+ // Update stored TTL after successful patch
1165
+ subscriptionData.expirationPolicy = effectivePolicy;
1166
+ } catch (patchErr) {
1167
+ ttlPatchFailed = patchErr;
1168
+ logger.warn({
1169
+ msg: 'Failed to update expiration policy on subscription',
1170
+ app: appData.id,
1171
+ subscription: subscriptionName,
1172
+ err: patchErr
1173
+ });
1174
+ }
1175
+ }
1176
+
1177
+ // Store current expiration info in meta
1178
+ await this.setMeta(appData.id, {
1179
+ subscriptionExpiration: subscriptionData?.expirationPolicy?.ttl || null
1180
+ });
1181
+ } catch (err) {
1182
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1183
+ logger.warn({ msg: 'Network error checking Pub/Sub subscription', app: appData.id, code: err.code });
1184
+ throw err;
1185
+ }
1186
+ switch (err?.oauthRequest?.response?.error?.code) {
1187
+ case 403:
1188
+ // no permissions
1189
+ this.setMetaFireAndForget(appData.id, {
1190
+ authFlag: { message: PUBSUB_PERM_VIEW },
1191
+ ttlWarning: null
1192
+ });
1193
+ throw err;
1194
+ case 404: {
1195
+ // does not exist
1196
+ logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
1197
+ try {
1198
+ let createPayload = {
1199
+ topic: topicName,
1200
+ ackDeadlineSeconds: GMAIL_PUBSUB_ACK_DEADLINE_SECONDS
1201
+ };
1202
+ if (desiredExpirationPolicy !== null) {
1203
+ createPayload.expirationPolicy = desiredExpirationPolicy;
1204
+ }
1205
+ let subscriptionCreateRes = await client.request(accessToken, subscriptionUrl, 'PUT', createPayload);
1206
+ /*
1043
1207
  {
1044
1208
  name: 'projects/webhooks-425411/subscriptions/ee-sub-AAABkE9_uNMAAAAH',
1045
1209
  topic: 'projects/webhooks-425411/topics/ee-pub-AAABkE9_uNMAAAAH',
@@ -1051,57 +1215,61 @@ class OAuth2AppsHandler {
1051
1215
  }
1052
1216
  */
1053
1217
 
1054
- if (!subscriptionCreateRes?.name) {
1055
- throw new Error('Topic was not created');
1056
- }
1218
+ if (!subscriptionCreateRes?.name) {
1219
+ throw new Error('Subscription was not created', { cause: err });
1220
+ }
1057
1221
 
1058
- await this.update(
1059
- appData.id,
1060
- {
1061
- pubSubSubscription: subscriptionName
1062
- },
1063
- { partial: true }
1064
- );
1065
-
1066
- results.pubSubSubscription = subscriptionName;
1067
- } catch (err) {
1068
- if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1069
- logger.warn({ msg: 'Network error creating Pub/Sub subscription', app: appData.id, code: err.code });
1070
- throw err;
1071
- }
1072
- switch (err?.oauthRequest?.response?.error?.code) {
1073
- case 403:
1074
- // no permissions
1075
- this.setMeta(appData.id, {
1076
- authFlag: {
1077
- message:
1078
- 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
1079
- }
1080
- });
1081
- throw err;
1082
- case 409:
1083
- // already exists
1084
- logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1085
- break;
1086
- default:
1222
+ await this.update(
1223
+ appData.id,
1224
+ {
1225
+ pubSubSubscription: subscriptionName
1226
+ },
1227
+ { partial: true }
1228
+ );
1229
+
1230
+ results.pubSubSubscription = subscriptionName;
1231
+
1232
+ // Store expiration info from the created subscription
1233
+ await this.setMeta(appData.id, {
1234
+ subscriptionExpiration: subscriptionCreateRes?.expirationPolicy?.ttl || null
1235
+ });
1236
+ } catch (err) {
1237
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1238
+ logger.warn({ msg: 'Network error creating Pub/Sub subscription', app: appData.id, code: err.code });
1087
1239
  throw err;
1240
+ }
1241
+ switch (err?.oauthRequest?.response?.error?.code) {
1242
+ case 403:
1243
+ // no permissions
1244
+ this.setMetaFireAndForget(appData.id, {
1245
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1246
+ ttlWarning: null
1247
+ });
1248
+ throw err;
1249
+ case 409:
1250
+ // already exists
1251
+ logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1252
+ break;
1253
+ default:
1254
+ throw err;
1255
+ }
1088
1256
  }
1257
+ break;
1089
1258
  }
1090
- break;
1259
+ default:
1260
+ throw err;
1091
1261
  }
1092
- default:
1093
- throw err;
1094
1262
  }
1095
- }
1096
1263
 
1097
- // Step 4. Grant access to Gmail publisher
1264
+ // Step 4. Grant access to Gmail publisher
1098
1265
 
1099
- let existingPolicy;
1100
- try {
1101
- // Check for an existing policy grant
1102
- let getIamPolycyRes = await client.request(accessToken, `${topicUrl}:getIamPolicy`, 'GET');
1103
- existingPolicy = getIamPolycyRes?.bindings?.find(binding => binding?.role === role && binding?.members?.includes(member));
1104
- /*
1266
+ let existingPolicy;
1267
+ let getIamPolicyRes;
1268
+ try {
1269
+ // Check for an existing policy grant
1270
+ getIamPolicyRes = await client.request(accessToken, `${topicUrl}:getIamPolicy`, 'GET');
1271
+ existingPolicy = getIamPolicyRes?.bindings?.find(binding => binding?.role === role && binding?.members?.includes(member));
1272
+ /*
1105
1273
  {
1106
1274
  version: 1,
1107
1275
  etag: 'BwYbtWmb5c0=',
@@ -1113,77 +1281,93 @@ class OAuth2AppsHandler {
1113
1281
  ]
1114
1282
  }
1115
1283
  */
1116
- } catch (err) {
1117
- if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1118
- logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
1119
- throw err;
1120
- }
1121
- switch (err?.oauthRequest?.response?.error?.code) {
1122
- case 403:
1123
- // no permissions
1124
- this.setMeta(appData.id, {
1125
- authFlag: {
1126
- message: 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
1127
- }
1128
- });
1129
- throw err;
1130
- default:
1131
- throw err;
1132
- }
1133
- }
1134
-
1135
- if (existingPolicy) {
1136
- logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
1137
- } else {
1138
- logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
1139
- const policyPayload = {
1140
- policy: {
1141
- bindings: [
1142
- {
1143
- members: [member],
1144
- role
1145
- }
1146
- ]
1147
- }
1148
- };
1149
- try {
1150
- await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
1151
- results.iamPolicy = {
1152
- members: [member],
1153
- role
1154
- };
1155
-
1156
- await this.update(
1157
- appData.id,
1158
- {
1159
- pubSubIamPolicy: results.iamPolicy
1160
- },
1161
- { partial: true }
1162
- );
1163
1284
  } catch (err) {
1164
1285
  if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1165
- logger.warn({ msg: 'Network error setting Pub/Sub IAM policy', app: appData.id, code: err.code });
1286
+ logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
1166
1287
  throw err;
1167
1288
  }
1168
1289
  switch (err?.oauthRequest?.response?.error?.code) {
1169
1290
  case 403:
1170
1291
  // no permissions
1171
- this.setMeta(appData.id, {
1172
- authFlag: {
1173
- message: 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
1174
- }
1292
+ this.setMetaFireAndForget(appData.id, {
1293
+ authFlag: { message: PUBSUB_PERM_VIEW },
1294
+ ttlWarning: null
1175
1295
  });
1176
1296
  throw err;
1177
1297
  default:
1178
1298
  throw err;
1179
1299
  }
1180
1300
  }
1181
- }
1182
1301
 
1183
- // clear auth flag if everything worked
1184
- await this.setMeta(appData.id, { authFlag: null });
1302
+ if (existingPolicy) {
1303
+ logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
1304
+ } else {
1305
+ logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
1306
+ let existingBindings = (getIamPolicyRes && getIamPolicyRes.bindings) || [];
1307
+ const policyPayload = {
1308
+ policy: {
1309
+ bindings: existingBindings.concat([
1310
+ {
1311
+ members: [member],
1312
+ role
1313
+ }
1314
+ ])
1315
+ }
1316
+ };
1317
+ if (getIamPolicyRes && getIamPolicyRes.etag) {
1318
+ policyPayload.policy.etag = getIamPolicyRes.etag;
1319
+ }
1320
+ try {
1321
+ await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
1322
+ results.iamPolicy = {
1323
+ members: [member],
1324
+ role
1325
+ };
1326
+
1327
+ await this.update(
1328
+ appData.id,
1329
+ {
1330
+ pubSubIamPolicy: results.iamPolicy
1331
+ },
1332
+ { partial: true }
1333
+ );
1334
+ } catch (err) {
1335
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1336
+ logger.warn({ msg: 'Network error setting Pub/Sub IAM policy', app: appData.id, code: err.code });
1337
+ throw err;
1338
+ }
1339
+ switch (err?.oauthRequest?.response?.error?.code) {
1340
+ case 403:
1341
+ // no permissions
1342
+ this.setMetaFireAndForget(appData.id, {
1343
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1344
+ ttlWarning: null
1345
+ });
1346
+ throw err;
1347
+ default:
1348
+ throw err;
1349
+ }
1350
+ }
1351
+ }
1185
1352
 
1186
- return results;
1353
+ // clear auth flag if everything worked; set or clear TTL warning
1354
+ let metaUpdate = { authFlag: null };
1355
+ if (ttlPatchFailed) {
1356
+ metaUpdate.ttlWarning = {
1357
+ message: 'Failed to update expiration policy on subscription',
1358
+ description: [ttlPatchFailed.message, ttlPatchFailed.code].filter(Boolean).join('; ')
1359
+ };
1360
+ } else {
1361
+ metaUpdate.ttlWarning = null;
1362
+ }
1363
+ await this.setMeta(appData.id, metaUpdate);
1364
+
1365
+ return results;
1366
+ } finally {
1367
+ if (ensureLock?.success) {
1368
+ await lock.releaseLock(ensureLock);
1369
+ }
1370
+ }
1187
1371
  }
1188
1372
 
1189
1373
  async getClient(id, extraOpts) {
@@ -1202,7 +1386,7 @@ class OAuth2AppsHandler {
1202
1386
  let redirectUrl = appData.redirectUrl;
1203
1387
  let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
1204
1388
 
1205
- let googleProjectId = appData.projectIdv;
1389
+ let googleProjectId = appData.googleProjectId;
1206
1390
  let workspaceAccounts = appData.googleWorkspaceAccounts;
1207
1391
 
1208
1392
  if (!clientId || !clientSecret || !redirectUrl) {
@@ -1245,7 +1429,7 @@ class OAuth2AppsHandler {
1245
1429
 
1246
1430
  let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
1247
1431
 
1248
- let googleProjectId = appData.projectIdv;
1432
+ let googleProjectId = appData.googleProjectId;
1249
1433
  let workspaceAccounts = appData.googleWorkspaceAccounts;
1250
1434
 
1251
1435
  if (!serviceClient || !serviceKey) {
@@ -1291,7 +1475,7 @@ class OAuth2AppsHandler {
1291
1475
  let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, outlookScopes(cloud), appData.skipScopes);
1292
1476
 
1293
1477
  if (!clientId || !clientSecret || !authority || !redirectUrl) {
1294
- let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook'), { statusCode: 400 });
1478
+ let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook (delegated)'), { statusCode: 400 });
1295
1479
  throw error;
1296
1480
  }
1297
1481
 
@@ -1322,6 +1506,41 @@ class OAuth2AppsHandler {
1322
1506
  );
1323
1507
  }
1324
1508
 
1509
+ case 'outlookService': {
1510
+ let authority = appData.authority;
1511
+ let clientId = appData.clientId;
1512
+ let clientSecret = appData.clientSecret ? await this.decrypt(appData.clientSecret) : null;
1513
+
1514
+ let cloud = appData.cloud || 'global';
1515
+
1516
+ if (!clientId || !clientSecret || !authority) {
1517
+ let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook (application)'), { statusCode: 400 });
1518
+ throw error;
1519
+ }
1520
+
1521
+ // Client credentials use {apiBase}/.default scope; per-scope config is not applicable
1522
+ return new OutlookOauth(
1523
+ Object.assign(
1524
+ {
1525
+ authority,
1526
+ clientId,
1527
+ clientSecret,
1528
+ cloud,
1529
+ provider: 'outlookService',
1530
+ useClientCredentials: true,
1531
+ setFlag: async flag => {
1532
+ try {
1533
+ await this.setMeta(id, { authFlag: flag });
1534
+ } catch (err) {
1535
+ logger.error({ msg: 'Failed to set OAuth flag', provider: 'outlookService', err });
1536
+ }
1537
+ }
1538
+ },
1539
+ extraOpts
1540
+ )
1541
+ );
1542
+ }
1543
+
1325
1544
  case 'mailRu': {
1326
1545
  let clientId = appData.clientId;
1327
1546
  let clientSecret = appData.clientSecret ? await this.decrypt(appData.clientSecret) : null;
@@ -1411,14 +1630,14 @@ class OAuth2AppsHandler {
1411
1630
  let { access_token: accessToken, expires_in: expiresIn } = await client.refreshToken({ isPrincipal });
1412
1631
  let expires = new Date(now + expiresIn * 1000);
1413
1632
  if (!accessToken) {
1414
- recordTokenMetric('failure', 'gmailService', '0');
1633
+ recordTokenMetric('failure', appData.provider || 'unknown', '0');
1415
1634
  return null;
1416
1635
  }
1417
1636
 
1418
1637
  logger.debug({ msg: 'Renewed access token for service account', app: appData.id, isPrincipal });
1419
1638
 
1420
1639
  // Record successful token refresh
1421
- recordTokenMetric('success', 'gmailService', '200');
1640
+ recordTokenMetric('success', appData.provider || 'unknown', '200');
1422
1641
 
1423
1642
  await this.update(
1424
1643
  appData.id,
@@ -1434,7 +1653,7 @@ class OAuth2AppsHandler {
1434
1653
  } catch (err) {
1435
1654
  // Record failed token refresh
1436
1655
  const statusCode = err.statusCode || err.tokenRequest?.status || 0;
1437
- recordTokenMetric('failure', 'gmailService', statusCode);
1656
+ recordTokenMetric('failure', appData.provider || 'unknown', statusCode);
1438
1657
 
1439
1658
  logger.info({
1440
1659
  msg: 'Failed to renew OAuth2 access token',
@@ -1445,15 +1664,27 @@ class OAuth2AppsHandler {
1445
1664
  });
1446
1665
  throw err;
1447
1666
  } finally {
1448
- await lock.releaseLock(renewLock);
1667
+ if (renewLock?.success) {
1668
+ await lock.releaseLock(renewLock);
1669
+ }
1449
1670
  }
1450
1671
  }
1451
1672
  }
1452
1673
 
1674
+ /**
1675
+ * Returns true if the given OAuth2 app uses API-based access (Graph API, Gmail API)
1676
+ * rather than IMAP/SMTP. Service account providers always use API access.
1677
+ */
1678
+ function isApiBasedApp(app) {
1679
+ return app && (app.baseScopes === 'api' || SERVICE_ACCOUNT_PROVIDERS.has(app.provider));
1680
+ }
1681
+
1453
1682
  module.exports = {
1454
1683
  oauth2Apps: new OAuth2AppsHandler({ redis }),
1455
1684
  OAUTH_PROVIDERS,
1685
+ SERVICE_ACCOUNT_PROVIDERS,
1456
1686
  LEGACY_KEYS,
1457
1687
  oauth2ProviderData,
1458
- formatExtraScopes
1688
+ formatExtraScopes,
1689
+ isApiBasedApp
1459
1690
  };