emailengine-app 2.63.3 → 2.64.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 (51) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/eslint.config.js +2 -0
  4. package/lib/account.js +6 -2
  5. package/lib/consts.js +17 -1
  6. package/lib/email-client/gmail/gmail-api.js +1 -12
  7. package/lib/email-client/imap/mailbox.js +24 -6
  8. package/lib/email-client/imap/sync-operations.js +17 -5
  9. package/lib/email-client/imap-client.js +25 -16
  10. package/lib/email-client/outlook/graph-api.js +7 -13
  11. package/lib/email-client/outlook-client.js +363 -167
  12. package/lib/imapproxy/imap-server.js +1 -0
  13. package/lib/oauth/gmail.js +12 -1
  14. package/lib/oauth/pubsub/google.js +253 -85
  15. package/lib/oauth2-apps.js +554 -377
  16. package/lib/routes-ui.js +186 -91
  17. package/lib/schemas.js +18 -1
  18. package/lib/tools.js +6 -0
  19. package/lib/ui-routes/account-routes.js +1 -1
  20. package/lib/ui-routes/admin-entities-routes.js +3 -3
  21. package/lib/ui-routes/oauth-routes.js +9 -3
  22. package/package.json +13 -13
  23. package/sbom.json +1 -1
  24. package/server.js +54 -22
  25. package/static/licenses.html +39 -29
  26. package/translations/de.mo +0 -0
  27. package/translations/de.po +54 -42
  28. package/translations/en.mo +0 -0
  29. package/translations/en.po +55 -43
  30. package/translations/et.mo +0 -0
  31. package/translations/et.po +54 -42
  32. package/translations/fr.mo +0 -0
  33. package/translations/fr.po +54 -42
  34. package/translations/ja.mo +0 -0
  35. package/translations/ja.po +54 -42
  36. package/translations/messages.pot +74 -52
  37. package/translations/nl.mo +0 -0
  38. package/translations/nl.po +54 -42
  39. package/translations/pl.mo +0 -0
  40. package/translations/pl.po +54 -42
  41. package/views/config/oauth/app.hbs +12 -0
  42. package/views/config/oauth/index.hbs +2 -0
  43. package/views/config/oauth/subscriptions.hbs +175 -0
  44. package/views/error.hbs +4 -4
  45. package/views/partials/oauth_tabs.hbs +8 -0
  46. package/workers/api.js +174 -96
  47. package/workers/documents.js +1 -0
  48. package/workers/imap.js +30 -47
  49. package/workers/smtp.js +1 -0
  50. package/workers/submit.js +1 -0
  51. 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,6 +43,9 @@ 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',
@@ -176,11 +168,13 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
176
168
  defaultScopes = (baseScopes && defaultScopesList[baseScopes]) || defaultScopesList.imap;
177
169
  }
178
170
 
179
- let extras = [];
180
171
  if (!extraScopes && !skipScopes.length) {
181
172
  return defaultScopes;
182
173
  }
183
174
 
175
+ extraScopes = extraScopes || [];
176
+
177
+ let extras = [];
184
178
  for (let extraScope of extraScopes) {
185
179
  if (defaultScopes.includes(extraScope) || (scopePrefix && defaultScopes.includes(`${scopePrefix}/${extraScope}`))) {
186
180
  // skip existing
@@ -192,19 +186,16 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
192
186
  let result = extras.length ? extras.concat(defaultScopes) : defaultScopes;
193
187
 
194
188
  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
- });
189
+ result = result.filter(
190
+ scope =>
191
+ !skipScopes.some(
192
+ skipScope =>
193
+ scope === skipScope ||
194
+ scope === `https://outlook.office.com/${skipScope}` ||
195
+ scope === `https://graph.microsoft.com/${skipScope}` ||
196
+ scope === `https://www.googleapis.com/auth/${skipScope}`
197
+ )
198
+ );
208
199
  }
209
200
 
210
201
  return result;
@@ -216,6 +207,9 @@ class OAuth2AppsHandler {
216
207
  this.redis = this.options.redis;
217
208
 
218
209
  this.secret = null;
210
+
211
+ this._pubSubBackfillDone = false;
212
+ this._pubSubBackfillPromise = null;
219
213
  }
220
214
 
221
215
  async encrypt(value) {
@@ -252,6 +246,56 @@ class OAuth2AppsHandler {
252
246
  return `${REDIS_PREFIX}settings`;
253
247
  }
254
248
 
249
+ async backfillPubSubApps() {
250
+ if (this._pubSubBackfillDone) {
251
+ return await this.redis.smembers(this.getSubscribersKey());
252
+ }
253
+
254
+ if (this._pubSubBackfillPromise) {
255
+ return await this._pubSubBackfillPromise;
256
+ }
257
+
258
+ this._pubSubBackfillPromise = this._doBackfillPubSubApps();
259
+ try {
260
+ return await this._pubSubBackfillPromise;
261
+ } finally {
262
+ this._pubSubBackfillPromise = null;
263
+ }
264
+ }
265
+
266
+ async _doBackfillPubSubApps() {
267
+ let [subscriberIds, allIds] = await Promise.all([this.redis.smembers(this.getSubscribersKey()), this.redis.smembers(this.getIndexKey())]);
268
+ let subscriberSet = new Set(subscriberIds);
269
+ let missingIds = allIds.filter(id => !subscriberSet.has(id));
270
+ if (!missingIds.length) {
271
+ this._pubSubBackfillDone = true;
272
+ return subscriberIds;
273
+ }
274
+ let bufKeys = missingIds.map(id => `${id}:data`);
275
+ let entries = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
276
+ let toAdd = [];
277
+ for (let i = 0; i < entries.length; i++) {
278
+ if (!entries[i]) {
279
+ continue;
280
+ }
281
+ try {
282
+ let data = msgpack.decode(entries[i]);
283
+ if (data.baseScopes === 'pubsub') {
284
+ toAdd.push(missingIds[i]);
285
+ logger.info({ msg: 'Backfilled pubsub app to subscribers set', app: missingIds[i] });
286
+ }
287
+ } catch (err) {
288
+ logger.warn({ msg: 'Failed to decode app data during backfill', app: missingIds[i], err });
289
+ }
290
+ }
291
+ if (toAdd.length) {
292
+ await this.redis.sadd(this.getSubscribersKey(), ...toAdd);
293
+ subscriberIds.push(...toAdd);
294
+ }
295
+ this._pubSubBackfillDone = true;
296
+ return subscriberIds;
297
+ }
298
+
255
299
  async list(page, pageSize, opts) {
256
300
  opts = opts || {};
257
301
  page = Math.max(Number(page) || 0, 0);
@@ -259,14 +303,12 @@ class OAuth2AppsHandler {
259
303
 
260
304
  let startPos = page * pageSize;
261
305
 
262
- let keyFunc;
306
+ let idList;
263
307
  if (opts.pubsub) {
264
- keyFunc = 'getSubscribersKey';
308
+ idList = await this.backfillPubSubApps();
265
309
  } else {
266
- keyFunc = 'getIndexKey';
310
+ idList = await this.redis.smembers(this.getIndexKey());
267
311
  }
268
-
269
- let idList = await this.redis.smembers(this[keyFunc]());
270
312
  idList = [].concat(idList || []).sort((a, b) => -a.localeCompare(b));
271
313
 
272
314
  if (!opts.pubsub) {
@@ -303,7 +345,7 @@ class OAuth2AppsHandler {
303
345
  let data = await this.get(legacyKey);
304
346
  response.apps.push(data);
305
347
  } catch (err) {
306
- logger.error({ msg: 'Failed to process legacy app', legacyKey });
348
+ logger.error({ msg: 'Failed to process legacy app', legacyKey, err });
307
349
  continue;
308
350
  }
309
351
  }
@@ -311,18 +353,21 @@ class OAuth2AppsHandler {
311
353
  if (keys.length) {
312
354
  let bufKeys = keys.flatMap(id => [`${id}:data`, `${id}:meta`]);
313
355
  let list = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
314
- for (let i = 0; i < list.length; i++) {
315
- let entry = list[i];
356
+ for (let i = 0; i < list.length; i += 2) {
357
+ let dataEntry = list[i];
358
+ let metaEntry = list[i + 1];
316
359
  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);
360
+ let data = msgpack.decode(dataEntry);
361
+ if (metaEntry) {
362
+ try {
363
+ data.meta = msgpack.decode(metaEntry);
364
+ } catch (metaErr) {
365
+ logger.error({ msg: 'Failed to process app meta', entryLength: metaEntry?.length || 0 });
366
+ }
322
367
  }
368
+ response.apps.push(data);
323
369
  } catch (err) {
324
- logger.error({ msg: 'Failed to process app', entry: entry.toString('base64') });
325
- continue;
370
+ logger.error({ msg: 'Failed to process app', entryLength: dataEntry?.length || 0 });
326
371
  }
327
372
  }
328
373
  }
@@ -370,13 +415,11 @@ class OAuth2AppsHandler {
370
415
  async generateId() {
371
416
  let idNum = await this.redis.hincrby(this.getSettingsKey(), 'idcount', 1);
372
417
 
373
- let idBuf = Buffer.alloc(8 + 4);
418
+ let idBuf = Buffer.alloc(12);
374
419
  idBuf.writeBigUInt64BE(BigInt(Date.now()), 0);
375
420
  idBuf.writeUInt32BE(idNum, 8);
376
421
 
377
- const id = idBuf.toString('base64url');
378
-
379
- return id;
422
+ return idBuf.toString('base64url');
380
423
  }
381
424
 
382
425
  async getLegacyApp(id) {
@@ -589,15 +632,15 @@ class OAuth2AppsHandler {
589
632
  try {
590
633
  data = msgpack.decode(getDataBuf);
591
634
  } catch (err) {
592
- logger.error({ msg: 'Failed to process app', app: id, entry: getDataBuf.toString('base64') });
635
+ logger.error({ msg: 'Failed to process app', app: id, entryLength: getDataBuf?.length || 0 });
593
636
  throw err;
594
637
  }
595
638
 
596
639
  if (getMetaBuf) {
597
640
  try {
598
- data = Object.assign(data, { meta: msgpack.decode(getMetaBuf) });
641
+ data.meta = msgpack.decode(getMetaBuf);
599
642
  } catch (err) {
600
- logger.error({ msg: 'Failed to process app', app: id, entry: getMetaBuf.toString('base64') });
643
+ logger.error({ msg: 'Failed to process app meta', app: id, entryLength: getMetaBuf?.length || 0 });
601
644
  }
602
645
  }
603
646
 
@@ -635,7 +678,7 @@ class OAuth2AppsHandler {
635
678
  [`${id}:data`]: msgpack.encode(entry)
636
679
  });
637
680
 
638
- if (data.pubSubSubscription) {
681
+ if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
639
682
  insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
640
683
  }
641
684
 
@@ -645,9 +688,9 @@ class OAuth2AppsHandler {
645
688
 
646
689
  let insertResult = await insertResultReq.exec();
647
690
 
648
- let hasError = (insertResult[0] && insertResult[0][0]) || (insertResult[1] && insertResult[1][0]);
649
- if (hasError) {
650
- throw hasError;
691
+ let errorEntry = insertResult.find(entry => entry && entry[0]);
692
+ if (errorEntry) {
693
+ throw errorEntry[0];
651
694
  }
652
695
 
653
696
  const result = {
@@ -655,17 +698,7 @@ class OAuth2AppsHandler {
655
698
  created: true
656
699
  };
657
700
 
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
- }
701
+ await this.tryEnsurePubsub(id, result);
669
702
 
670
703
  return result;
671
704
  }
@@ -687,6 +720,8 @@ class OAuth2AppsHandler {
687
720
 
688
721
  let existingData = msgpack.decode(existingDataBuf);
689
722
 
723
+ let oldPubSubApp = existingData.pubSubApp || null;
724
+
690
725
  let encryptedValues = {};
691
726
  for (let key of ['clientSecret', 'serviceKey', 'accessToken']) {
692
727
  if (data[key]) {
@@ -706,22 +741,25 @@ class OAuth2AppsHandler {
706
741
 
707
742
  let insertResultReq = this.redis.multi().sadd(this.getIndexKey(), id).hmset(this.getDataKey(), updates);
708
743
 
709
- if (data.pubSubSubscription) {
744
+ if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
710
745
  insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
711
746
  }
712
747
 
713
748
  if (data.pubSubApp) {
714
- if (existingData.pubSubApp && existingData.pubSubApp !== data.pubSubApp) {
715
- insertResultReq = insertResultReq.srem(this.getPubsubAppKey(existingData.pubSubApp), id);
749
+ if (oldPubSubApp && oldPubSubApp !== data.pubSubApp) {
750
+ insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
716
751
  }
717
752
  insertResultReq = insertResultReq.sadd(this.getPubsubAppKey(data.pubSubApp), id);
753
+ } else if (oldPubSubApp && 'pubSubApp' in data) {
754
+ // pubSubApp was explicitly cleared
755
+ insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
718
756
  }
719
757
 
720
758
  let insertResult = await insertResultReq.exec();
721
759
 
722
- let hasError = (insertResult[0] && insertResult[0][0]) || (insertResult[1] && insertResult[1][0]);
723
- if (hasError) {
724
- throw hasError;
760
+ let errorEntry = insertResult.find(entry => entry && entry[0]);
761
+ if (errorEntry) {
762
+ throw errorEntry[0];
725
763
  }
726
764
 
727
765
  if (opts.partial) {
@@ -739,17 +777,7 @@ class OAuth2AppsHandler {
739
777
  updated: true
740
778
  };
741
779
 
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
- }
780
+ await this.tryEnsurePubsub(id, result);
753
781
 
754
782
  return result;
755
783
  }
@@ -762,148 +790,224 @@ class OAuth2AppsHandler {
762
790
 
763
791
  let appData = await this.get(id);
764
792
 
765
- if (appData.pubSubTopic) {
766
- // try to delete topic
793
+ if (!appData) {
794
+ return { id, deleted: false, accounts: 0 };
795
+ }
796
+
797
+ // Acquire the same lock used by ensurePubsub to prevent racing with recovery
798
+ let needsPubSubLock = appData.pubSubTopic || appData.baseScopes === 'pubsub';
799
+ let delLock;
800
+
801
+ if (needsPubSubLock) {
802
+ let lockKey = ['oauth', 'pubsub', appData.id].join(':');
767
803
  try {
768
- await this.deleteTopic(appData);
804
+ delLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
805
+ if (!delLock.success) {
806
+ throw new Error('Failed to get lock for pubsub app deletion');
807
+ }
769
808
  } catch (err) {
770
- logger.error({ msg: 'Failed to delete existing pubsub topic', app: appData.id, topic: appData.pubSubTopic, err });
809
+ logger.error({ msg: 'Failed to acquire lock for pubsub app deletion', lockKey, err });
810
+ throw err;
771
811
  }
772
812
  }
773
813
 
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
- }
814
+ try {
815
+ if (appData.pubSubTopic) {
816
+ // try to delete topic
817
+ try {
818
+ await this.deleteTopic(appData);
819
+ } catch (err) {
820
+ logger.error({
821
+ msg: 'Failed to delete existing pubsub topic',
822
+ app: appData.id,
823
+ topic: appData.pubSubTopic,
824
+ subscription: appData.pubSubSubscription,
825
+ err
826
+ });
827
+ }
828
+ }
786
829
 
787
- let deleteResult = await pipeline.exec();
830
+ let pipeline = this.redis
831
+ .multi()
832
+ .srem(this.getIndexKey(), id)
833
+ .hdel(this.getDataKey(), [`${id}:data`, `${id}:meta`])
834
+ .scard(`${REDIS_PREFIX}oapp:a:${id}`)
835
+ .del(`${REDIS_PREFIX}oapp:a:${id}`)
836
+ .del(`${REDIS_PREFIX}oapp:h:${id}`)
837
+ .srem(this.getSubscribersKey(), id);
838
+
839
+ if (appData.pubSubApp) {
840
+ pipeline = pipeline.srem(this.getPubsubAppKey(appData.pubSubApp), id);
841
+ }
788
842
 
789
- let hasError = (deleteResult[0] && deleteResult[0][0]) || (deleteResult[1] && deleteResult[1][0]);
790
- if (hasError) {
791
- throw hasError;
792
- }
843
+ if (appData.baseScopes === 'pubsub') {
844
+ pipeline = pipeline.del(this.getPubsubAppKey(id));
845
+ }
793
846
 
794
- let deletedDocs = ((deleteResult[0] && deleteResult[0][1]) || 0) + ((deleteResult[1] && deleteResult[1][1]) || 0);
847
+ let deleteResult = await pipeline.exec();
795
848
 
796
- return {
797
- id,
798
- deleted: deletedDocs >= 2,
799
- accounts: Number(deleteResult[2] && deleteResult[2][1]) || 0
800
- };
849
+ let errorEntry = deleteResult.find(entry => entry && entry[0]);
850
+ if (errorEntry) {
851
+ throw errorEntry[0];
852
+ }
853
+
854
+ let deletedDocs = ((deleteResult[0] && deleteResult[0][1]) || 0) + ((deleteResult[1] && deleteResult[1][1]) || 0);
855
+
856
+ return {
857
+ id,
858
+ deleted: deletedDocs >= 2,
859
+ accounts: Number(deleteResult[2] && deleteResult[2][1]) || 0
860
+ };
861
+ } finally {
862
+ if (delLock?.success) {
863
+ await lock.releaseLock(delLock);
864
+ }
865
+ }
801
866
  }
802
867
 
868
+ // Note: not atomic (read-modify-write). Concurrent callers use last-write-wins,
869
+ // which is acceptable for self-correcting informational metadata.
803
870
  async setMeta(id, meta) {
804
- let existingMeta;
805
871
  let existingMetaBuf = await this.redis.hgetBuffer(this.getDataKey(), `${id}:meta`);
806
- if (!existingMetaBuf) {
807
- existingMeta = {};
808
- } else {
809
- existingMeta = msgpack.decode(existingMetaBuf);
810
- }
872
+ let existingMeta = existingMetaBuf ? msgpack.decode(existingMetaBuf) : {};
811
873
 
812
874
  let entry = Object.assign(existingMeta, meta || {});
813
875
 
814
- let updates = {
876
+ await this.redis.hmset(this.getDataKey(), {
815
877
  [`${id}:meta`]: msgpack.encode(entry)
816
- };
817
-
818
- await this.redis.hmset(this.getDataKey(), updates);
878
+ });
819
879
 
820
- return {
821
- id,
822
- updated: true
823
- };
880
+ return { id, updated: true };
824
881
  }
825
882
 
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
- }
883
+ /**
884
+ * Fire-and-forget setMeta call that logs errors instead of throwing.
885
+ * Used in ensurePubsub error handlers where we want to record a flag
886
+ * but must not block or alter the original error flow.
887
+ */
888
+ setMetaFireAndForget(appId, meta) {
889
+ this.setMeta(appId, meta).catch(metaErr => {
890
+ logger.error({ msg: 'Failed to set metadata', app: appId, err: metaErr });
891
+ });
892
+ }
840
893
 
894
+ /**
895
+ * Try to set up Pub/Sub for the given app and attach results to the result object.
896
+ * Errors are logged but not thrown so they do not prevent create/update from succeeding.
897
+ */
898
+ async tryEnsurePubsub(id, result) {
841
899
  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
- */
900
+ let appData = await this.get(id);
901
+ if (appData.baseScopes === 'pubsub') {
902
+ let pubsubUpdates = await this.ensurePubsub(appData);
903
+ result.pubsubUpdates = pubsubUpdates || {};
848
904
  }
849
905
  } 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
- }
906
+ logger.error({ msg: 'Failed to set up pubsub', app: id, err });
867
907
  }
908
+ }
868
909
 
910
+ async _retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, reason) {
911
+ logger.warn({
912
+ msg: `${reason} deleting Pub/Sub ${resourceType}, retrying in ${delay}ms`,
913
+ app: appData.id,
914
+ [resourceType]: resourceName
915
+ });
916
+ await new Promise(resolve => setTimeout(resolve, delay));
869
917
  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
- */
918
+ await doDelete();
919
+ } catch (retryErr) {
920
+ if (retryErr?.oauthRequest?.response?.error?.code === 404) {
921
+ logger.info({
922
+ msg: `${resourceType} no longer exists after retry`,
923
+ app: appData.id,
924
+ [resourceType]: resourceName
925
+ });
926
+ return;
876
927
  }
928
+ logger.error({
929
+ msg: `Retry failed deleting Pub/Sub ${resourceType}, resource may need manual cleanup`,
930
+ app: appData.id,
931
+ [resourceType]: resourceName,
932
+ code: retryErr.code || retryErr?.oauthRequest?.response?.error?.code
933
+ });
934
+ throw retryErr;
935
+ }
936
+ }
937
+
938
+ async _deletePubSubResource(client, accessToken, appData, resourceType, resourceName) {
939
+ if (!resourceName) {
940
+ return;
941
+ }
942
+ let url = `https://pubsub.googleapis.com/v1/${resourceName}`;
943
+ const doDelete = () => client.request(accessToken, url, 'DELETE', Buffer.alloc(0), { returnText: true });
944
+ try {
945
+ await doDelete();
877
946
  } catch (err) {
878
947
  switch (err?.oauthRequest?.response?.error?.code) {
879
948
  case 403:
880
- // no permissions
881
949
  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".',
950
+ 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
951
  app: appData.id,
884
- topic: topicName
952
+ [resourceType]: resourceName
885
953
  });
886
954
  throw err;
887
- case 404: {
888
- // does not exist
889
- logger.info({ msg: 'Subscription does not exist', app: appData.id, topic: topicName });
955
+ case 404:
956
+ logger.info({ msg: `${resourceType} does not exist`, app: appData.id, [resourceType]: resourceName });
957
+ break;
958
+ case 429: {
959
+ let retryAfter = err.retryAfter || err.oauthRequest?.retryAfter;
960
+ let delay = retryAfter ? Math.min(retryAfter * 1000, 30000) : 2000;
961
+ await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, 'Rate limited');
890
962
  break;
891
963
  }
892
964
  default:
965
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
966
+ await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, 2000, 'Transient network error');
967
+ break;
968
+ }
893
969
  throw err;
894
970
  }
895
971
  }
896
972
  }
897
973
 
974
+ async deleteTopic(appData) {
975
+ let client = await this.getClient(appData.id);
976
+
977
+ let accessToken = await this.getServiceAccessToken(appData, client);
978
+ if (!accessToken) {
979
+ throw new Error('Failed to get access token');
980
+ }
981
+
982
+ await this._deletePubSubResource(client, accessToken, appData, 'subscription', appData.pubSubSubscription);
983
+ await this._deletePubSubResource(client, accessToken, appData, 'topic', appData.pubSubTopic);
984
+ }
985
+
898
986
  async ensurePubsub(appData) {
899
987
  let project = appData.googleProjectId;
900
988
 
901
989
  let topic = appData.googleTopicName || `ee-pub-${appData.id}`;
902
990
  let subscription = appData.googleSubscriptionName || `ee-sub-${appData.id}`;
903
991
 
992
+ // Determine desired expirationPolicy from setting
993
+ let gmailSubscriptionTtl = await settings.get('gmailSubscriptionTtl');
994
+ let desiredExpirationPolicy;
995
+ if (gmailSubscriptionTtl === 0) {
996
+ // Indefinite (no expiration)
997
+ desiredExpirationPolicy = {};
998
+ } else if (typeof gmailSubscriptionTtl === 'number' && gmailSubscriptionTtl > 0) {
999
+ // Convert days to seconds for Google API
1000
+ desiredExpirationPolicy = { ttl: `${gmailSubscriptionTtl * 86400}s` };
1001
+ } else {
1002
+ // Not set - let Google apply its default 31-day TTL
1003
+ desiredExpirationPolicy = null;
1004
+ }
1005
+
904
1006
  let results = {};
1007
+ let ttlPatchFailed = null;
905
1008
 
906
- if (!project || !topic) {
1009
+ if (!project) {
1010
+ logger.warn({ msg: 'googleProjectId is required for Pub/Sub setup', app: appData.id });
907
1011
  return results;
908
1012
  }
909
1013
 
@@ -916,130 +1020,181 @@ class OAuth2AppsHandler {
916
1020
  const member = 'serviceAccount:gmail-api-push@system.gserviceaccount.com';
917
1021
  const role = 'roles/pubsub.publisher';
918
1022
 
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');
1023
+ // Acquire per-app lock to prevent concurrent ensurePubsub races (e.g., user update + background recovery)
1024
+ let lockKey = ['oauth', 'pubsub', appData.id].join(':');
1025
+ let ensureLock;
1026
+ try {
1027
+ ensureLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
1028
+ if (!ensureLock.success) {
1029
+ logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey });
1030
+ throw new Error('Failed to get ensurePubsub lock');
1031
+ }
1032
+ } catch (err) {
1033
+ logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey, err });
1034
+ throw err;
925
1035
  }
926
1036
 
927
- // Step 2. Ensure topic
928
1037
  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;
1038
+ let client = await this.getClient(appData.id);
1039
+
1040
+ // Step 1. Get access token for service client
1041
+ let accessToken = await this.getServiceAccessToken(appData, client);
1042
+ if (!accessToken) {
1043
+ throw new Error('Failed to get access token');
939
1044
  }
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
1045
 
1046
+ // Step 2. Ensure topic
1047
+ try {
1048
+ // fails if topic does not exist
1049
+ await client.request(accessToken, topicUrl, 'GET');
1050
+ logger.debug({ msg: 'Topic already exists', app: appData.id, topic: topicName });
1051
+ /*
1052
+ {name: 'projects/...'}
1053
+ */
1054
+ } catch (err) {
1055
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1056
+ logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
959
1057
  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
- /*
1058
+ }
1059
+ switch (err?.oauthRequest?.response?.error?.code) {
1060
+ case 403:
1061
+ // no permissions
1062
+ if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
1063
+ this.setMetaFireAndForget(appData.id, {
1064
+ authFlag: {
1065
+ message:
1066
+ 'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
1067
+ description: err?.oauthRequest?.response?.error?.message
1068
+ },
1069
+ ttlWarning: null
1070
+ });
1071
+ } else {
1072
+ this.setMetaFireAndForget(appData.id, {
1073
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1074
+ ttlWarning: null
1075
+ });
1076
+ }
1077
+
1078
+ throw err;
1079
+ case 404: {
1080
+ // does not exist
1081
+ logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
1082
+ try {
1083
+ let topicCreateRes = await client.request(accessToken, topicUrl, 'PUT', Buffer.alloc(0));
1084
+ /*
966
1085
  {name: 'projects/...'}
967
1086
  */
968
- if (!topicCreateRes?.name) {
969
- throw new Error('Topic was not created');
970
- }
1087
+ if (!topicCreateRes?.name) {
1088
+ throw new Error('Topic was not created', { cause: err });
1089
+ }
971
1090
 
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:
1091
+ await this.update(
1092
+ appData.id,
1093
+ {
1094
+ pubSubTopic: topicName
1095
+ },
1096
+ { partial: true }
1097
+ );
1098
+
1099
+ results.pubSubTopic = topicName;
1100
+ } catch (err) {
1101
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1102
+ logger.warn({ msg: 'Network error creating Pub/Sub topic', app: appData.id, code: err.code });
1001
1103
  throw err;
1104
+ }
1105
+ switch (err?.oauthRequest?.response?.error?.code) {
1106
+ case 403:
1107
+ // no permissions
1108
+ this.setMetaFireAndForget(appData.id, {
1109
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1110
+ ttlWarning: null
1111
+ });
1112
+ throw err;
1113
+ case 409:
1114
+ // already exists
1115
+ logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
1116
+ break;
1117
+ default:
1118
+ throw err;
1119
+ }
1002
1120
  }
1121
+ break;
1003
1122
  }
1004
- break;
1123
+ default:
1124
+ throw err;
1005
1125
  }
1006
- default:
1007
- throw err;
1008
1126
  }
1009
- }
1010
1127
 
1011
- // Step 3. Set up subscriber
1128
+ // Step 3. Set up subscriber
1012
1129
 
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
- /*
1130
+ try {
1131
+ // fails if topic does not exist
1132
+ let subscriptionData = await client.request(accessToken, subscriptionUrl, 'GET');
1133
+ logger.debug({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1134
+ /*
1018
1135
  {name: 'projects/...'}
1019
1136
  */
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 });
1137
+
1138
+ // Patch existing subscription's expiration policy if it differs from desired
1139
+ // When no explicit TTL is configured, use Google's default (31 days) for comparison
1140
+ let effectivePolicy = desiredExpirationPolicy ?? { ttl: GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL };
1141
+ let currentTtl = subscriptionData?.expirationPolicy?.ttl || null;
1142
+ let desiredTtl = effectivePolicy.ttl || null;
1143
+ if (currentTtl !== desiredTtl) {
1037
1144
  try {
1038
- let subscriptionCreateRes = await client.request(accessToken, subscriptionUrl, 'PUT', {
1039
- topic: topicName,
1040
- ackDeadlineSeconds: 30
1145
+ await client.request(accessToken, subscriptionUrl, 'PATCH', {
1146
+ subscription: { expirationPolicy: effectivePolicy },
1147
+ updateMask: 'expirationPolicy'
1041
1148
  });
1042
- /*
1149
+ logger.info({
1150
+ msg: 'Updated expiration policy on subscription',
1151
+ app: appData.id,
1152
+ subscription: subscriptionName,
1153
+ desiredTtl
1154
+ });
1155
+ // Update stored TTL after successful patch
1156
+ subscriptionData.expirationPolicy = effectivePolicy;
1157
+ } catch (patchErr) {
1158
+ ttlPatchFailed = patchErr;
1159
+ logger.warn({
1160
+ msg: 'Failed to update expiration policy on subscription',
1161
+ app: appData.id,
1162
+ subscription: subscriptionName,
1163
+ err: patchErr
1164
+ });
1165
+ }
1166
+ }
1167
+
1168
+ // Store current expiration info in meta
1169
+ await this.setMeta(appData.id, {
1170
+ subscriptionExpiration: subscriptionData?.expirationPolicy?.ttl || null
1171
+ });
1172
+ } catch (err) {
1173
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1174
+ logger.warn({ msg: 'Network error checking Pub/Sub subscription', app: appData.id, code: err.code });
1175
+ throw err;
1176
+ }
1177
+ switch (err?.oauthRequest?.response?.error?.code) {
1178
+ case 403:
1179
+ // no permissions
1180
+ this.setMetaFireAndForget(appData.id, {
1181
+ authFlag: { message: PUBSUB_PERM_VIEW },
1182
+ ttlWarning: null
1183
+ });
1184
+ throw err;
1185
+ case 404: {
1186
+ // does not exist
1187
+ logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
1188
+ try {
1189
+ let createPayload = {
1190
+ topic: topicName,
1191
+ ackDeadlineSeconds: GMAIL_PUBSUB_ACK_DEADLINE_SECONDS
1192
+ };
1193
+ if (desiredExpirationPolicy !== null) {
1194
+ createPayload.expirationPolicy = desiredExpirationPolicy;
1195
+ }
1196
+ let subscriptionCreateRes = await client.request(accessToken, subscriptionUrl, 'PUT', createPayload);
1197
+ /*
1043
1198
  {
1044
1199
  name: 'projects/webhooks-425411/subscriptions/ee-sub-AAABkE9_uNMAAAAH',
1045
1200
  topic: 'projects/webhooks-425411/topics/ee-pub-AAABkE9_uNMAAAAH',
@@ -1051,57 +1206,61 @@ class OAuth2AppsHandler {
1051
1206
  }
1052
1207
  */
1053
1208
 
1054
- if (!subscriptionCreateRes?.name) {
1055
- throw new Error('Topic was not created');
1056
- }
1209
+ if (!subscriptionCreateRes?.name) {
1210
+ throw new Error('Subscription was not created', { cause: err });
1211
+ }
1057
1212
 
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:
1213
+ await this.update(
1214
+ appData.id,
1215
+ {
1216
+ pubSubSubscription: subscriptionName
1217
+ },
1218
+ { partial: true }
1219
+ );
1220
+
1221
+ results.pubSubSubscription = subscriptionName;
1222
+
1223
+ // Store expiration info from the created subscription
1224
+ await this.setMeta(appData.id, {
1225
+ subscriptionExpiration: subscriptionCreateRes?.expirationPolicy?.ttl || null
1226
+ });
1227
+ } catch (err) {
1228
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1229
+ logger.warn({ msg: 'Network error creating Pub/Sub subscription', app: appData.id, code: err.code });
1087
1230
  throw err;
1231
+ }
1232
+ switch (err?.oauthRequest?.response?.error?.code) {
1233
+ case 403:
1234
+ // no permissions
1235
+ this.setMetaFireAndForget(appData.id, {
1236
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1237
+ ttlWarning: null
1238
+ });
1239
+ throw err;
1240
+ case 409:
1241
+ // already exists
1242
+ logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1243
+ break;
1244
+ default:
1245
+ throw err;
1246
+ }
1088
1247
  }
1248
+ break;
1089
1249
  }
1090
- break;
1250
+ default:
1251
+ throw err;
1091
1252
  }
1092
- default:
1093
- throw err;
1094
1253
  }
1095
- }
1096
1254
 
1097
- // Step 4. Grant access to Gmail publisher
1255
+ // Step 4. Grant access to Gmail publisher
1098
1256
 
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
- /*
1257
+ let existingPolicy;
1258
+ let getIamPolicyRes;
1259
+ try {
1260
+ // Check for an existing policy grant
1261
+ getIamPolicyRes = await client.request(accessToken, `${topicUrl}:getIamPolicy`, 'GET');
1262
+ existingPolicy = getIamPolicyRes?.bindings?.find(binding => binding?.role === role && binding?.members?.includes(member));
1263
+ /*
1105
1264
  {
1106
1265
  version: 1,
1107
1266
  etag: 'BwYbtWmb5c0=',
@@ -1113,77 +1272,93 @@ class OAuth2AppsHandler {
1113
1272
  ]
1114
1273
  }
1115
1274
  */
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
1275
  } catch (err) {
1164
1276
  if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1165
- logger.warn({ msg: 'Network error setting Pub/Sub IAM policy', app: appData.id, code: err.code });
1277
+ logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
1166
1278
  throw err;
1167
1279
  }
1168
1280
  switch (err?.oauthRequest?.response?.error?.code) {
1169
1281
  case 403:
1170
1282
  // 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
- }
1283
+ this.setMetaFireAndForget(appData.id, {
1284
+ authFlag: { message: PUBSUB_PERM_VIEW },
1285
+ ttlWarning: null
1175
1286
  });
1176
1287
  throw err;
1177
1288
  default:
1178
1289
  throw err;
1179
1290
  }
1180
1291
  }
1181
- }
1182
1292
 
1183
- // clear auth flag if everything worked
1184
- await this.setMeta(appData.id, { authFlag: null });
1293
+ if (existingPolicy) {
1294
+ logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
1295
+ } else {
1296
+ logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
1297
+ let existingBindings = (getIamPolicyRes && getIamPolicyRes.bindings) || [];
1298
+ const policyPayload = {
1299
+ policy: {
1300
+ bindings: existingBindings.concat([
1301
+ {
1302
+ members: [member],
1303
+ role
1304
+ }
1305
+ ])
1306
+ }
1307
+ };
1308
+ if (getIamPolicyRes && getIamPolicyRes.etag) {
1309
+ policyPayload.policy.etag = getIamPolicyRes.etag;
1310
+ }
1311
+ try {
1312
+ await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
1313
+ results.iamPolicy = {
1314
+ members: [member],
1315
+ role
1316
+ };
1317
+
1318
+ await this.update(
1319
+ appData.id,
1320
+ {
1321
+ pubSubIamPolicy: results.iamPolicy
1322
+ },
1323
+ { partial: true }
1324
+ );
1325
+ } catch (err) {
1326
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1327
+ logger.warn({ msg: 'Network error setting Pub/Sub IAM policy', app: appData.id, code: err.code });
1328
+ throw err;
1329
+ }
1330
+ switch (err?.oauthRequest?.response?.error?.code) {
1331
+ case 403:
1332
+ // no permissions
1333
+ this.setMetaFireAndForget(appData.id, {
1334
+ authFlag: { message: PUBSUB_PERM_MANAGE },
1335
+ ttlWarning: null
1336
+ });
1337
+ throw err;
1338
+ default:
1339
+ throw err;
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ // clear auth flag if everything worked; set or clear TTL warning
1345
+ let metaUpdate = { authFlag: null };
1346
+ if (ttlPatchFailed) {
1347
+ metaUpdate.ttlWarning = {
1348
+ message: 'Failed to update expiration policy on subscription',
1349
+ description: [ttlPatchFailed.message, ttlPatchFailed.code].filter(Boolean).join('; ')
1350
+ };
1351
+ } else {
1352
+ metaUpdate.ttlWarning = null;
1353
+ }
1354
+ await this.setMeta(appData.id, metaUpdate);
1185
1355
 
1186
- return results;
1356
+ return results;
1357
+ } finally {
1358
+ if (ensureLock?.success) {
1359
+ await lock.releaseLock(ensureLock);
1360
+ }
1361
+ }
1187
1362
  }
1188
1363
 
1189
1364
  async getClient(id, extraOpts) {
@@ -1202,7 +1377,7 @@ class OAuth2AppsHandler {
1202
1377
  let redirectUrl = appData.redirectUrl;
1203
1378
  let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
1204
1379
 
1205
- let googleProjectId = appData.projectIdv;
1380
+ let googleProjectId = appData.googleProjectId;
1206
1381
  let workspaceAccounts = appData.googleWorkspaceAccounts;
1207
1382
 
1208
1383
  if (!clientId || !clientSecret || !redirectUrl) {
@@ -1245,7 +1420,7 @@ class OAuth2AppsHandler {
1245
1420
 
1246
1421
  let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
1247
1422
 
1248
- let googleProjectId = appData.projectIdv;
1423
+ let googleProjectId = appData.googleProjectId;
1249
1424
  let workspaceAccounts = appData.googleWorkspaceAccounts;
1250
1425
 
1251
1426
  if (!serviceClient || !serviceKey) {
@@ -1445,7 +1620,9 @@ class OAuth2AppsHandler {
1445
1620
  });
1446
1621
  throw err;
1447
1622
  } finally {
1448
- await lock.releaseLock(renewLock);
1623
+ if (renewLock?.success) {
1624
+ await lock.releaseLock(renewLock);
1625
+ }
1449
1626
  }
1450
1627
  }
1451
1628
  }