emailengine-app 2.65.0 → 2.67.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 (50) hide show
  1. package/.github/workflows/deploy.yml +8 -8
  2. package/.github/workflows/release.yaml +9 -9
  3. package/.github/workflows/test.yml +2 -2
  4. package/CHANGELOG.md +53 -0
  5. package/bin/emailengine.js +3 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/lib/account.js +35 -29
  8. package/lib/consts.js +5 -0
  9. package/lib/email-client/gmail-client.js +23 -27
  10. package/lib/email-client/imap/mailbox.js +46 -19
  11. package/lib/email-client/imap/sync-operations.js +51 -19
  12. package/lib/email-client/imap-client.js +28 -5
  13. package/lib/email-client/outlook-client.js +155 -1
  14. package/lib/oauth/gmail.js +52 -1
  15. package/lib/passkeys.js +206 -0
  16. package/lib/routes-ui.js +522 -21
  17. package/lib/ui-routes/oauth-routes.js +6 -1
  18. package/package.json +13 -11
  19. package/sbom.json +1 -1
  20. package/static/js/login-passkey.js +75 -0
  21. package/static/js/passkey-register.js +107 -0
  22. package/static/licenses.html +238 -38
  23. package/static/vendor/handlebars/handlebars.min-v4.7.9.js +29 -0
  24. package/static/vendor/simplewebauthn/browser.min.js +2 -0
  25. package/translations/de.mo +0 -0
  26. package/translations/de.po +91 -53
  27. package/translations/en.mo +0 -0
  28. package/translations/en.po +84 -52
  29. package/translations/et.mo +0 -0
  30. package/translations/et.po +95 -60
  31. package/translations/fr.mo +0 -0
  32. package/translations/fr.po +102 -65
  33. package/translations/ja.mo +0 -0
  34. package/translations/ja.po +93 -57
  35. package/translations/messages.pot +101 -76
  36. package/translations/nl.mo +0 -0
  37. package/translations/nl.po +92 -56
  38. package/translations/pl.mo +0 -0
  39. package/translations/pl.po +106 -70
  40. package/views/account/login.hbs +35 -25
  41. package/views/account/password.hbs +4 -4
  42. package/views/account/security.hbs +101 -12
  43. package/views/account/totp.hbs +3 -3
  44. package/views/config/oauth/app.hbs +25 -0
  45. package/views/layout/app.hbs +2 -2
  46. package/views/layout/login.hbs +6 -1
  47. package/views/oauth-scope-error.hbs +29 -0
  48. package/workers/api.js +81 -3
  49. package/workers/imap.js +4 -0
  50. package/static/vendor/handlebars/handlebars.min-v4.7.7.js +0 -29
@@ -3,7 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const { compareExisting, calculateFetchBackoff, readEnvValue } = require('../../tools');
5
5
  const config = require('@zone-eu/wild-config');
6
- const { DEFAULT_FETCH_BATCH_SIZE } = require('../../consts');
6
+ const { DEFAULT_FETCH_BATCH_SIZE, FETCH_RETRY_MAX_TIME, FETCH_RETRY_MIN_ATTEMPTS } = require('../../consts');
7
7
 
8
8
  // Configurable batch size for fetching messages (default: 250)
9
9
  const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || config.service.fetchBatchSize) || DEFAULT_FETCH_BATCH_SIZE;
@@ -11,6 +11,15 @@ const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || conf
11
11
  // Do not check for flag updates using full sync more often than this value (30 minutes)
12
12
  const FULL_SYNC_DELAY = 30 * 60 * 1000;
13
13
 
14
+ function checkFetchRetryTimeout(retryCount, startTime) {
15
+ if (retryCount >= FETCH_RETRY_MIN_ATTEMPTS && Date.now() - startTime > FETCH_RETRY_MAX_TIME) {
16
+ const err = new Error('FETCH retry time limit exceeded');
17
+ err.code = 'FetchRetryTimeout';
18
+ err.statusCode = 504;
19
+ throw err;
20
+ }
21
+ }
22
+
14
23
  /**
15
24
  * Calculates the next range of sequence numbers to fetch based on the last fetched range
16
25
  * @param {Number} totalMessages - Total number of messages in the mailbox
@@ -234,6 +243,7 @@ class SyncOperations {
234
243
  // Fetch messages with retry logic
235
244
  let fetchCompleted = false;
236
245
  let fetchRetryCount = 0;
246
+ let fetchStartTime = Date.now();
237
247
 
238
248
  while (!fetchCompleted) {
239
249
  try {
@@ -312,9 +322,11 @@ class SyncOperations {
312
322
 
313
323
  // Retry with exponential backoff
314
324
  if (!imapClient.usable) {
315
- // nothing to do here, connection closed
316
- this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, err });
317
- return;
325
+ // connection closed, throw so callers know sync was interrupted
326
+ const connErr = new Error('IMAP connection closed during sync');
327
+ connErr.code = 'IMAPConnectionClosing';
328
+ connErr.statusCode = 503;
329
+ throw connErr;
318
330
  }
319
331
 
320
332
  const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
@@ -322,10 +334,14 @@ class SyncOperations {
322
334
  await new Promise(r => setTimeout(r, fetchRetryDelay));
323
335
 
324
336
  if (!imapClient.usable) {
325
- // nothing to do here, connection closed
326
- this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, err });
327
- return;
337
+ // connection closed, throw so callers know sync was interrupted
338
+ const connErr = new Error('IMAP connection closed during sync');
339
+ connErr.code = 'IMAPConnectionClosing';
340
+ connErr.statusCode = 503;
341
+ throw connErr;
328
342
  }
343
+
344
+ checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
329
345
  }
330
346
  }
331
347
  }
@@ -379,6 +395,7 @@ class SyncOperations {
379
395
  // only fetch messages if there are some
380
396
  let fetchCompleted = false;
381
397
  let fetchRetryCount = 0;
398
+ let fetchStartTime = Date.now();
382
399
  while (!fetchCompleted) {
383
400
  // Get fresh imapClient reference inside retry loop
384
401
  let imapClient = this.connection.imapClient;
@@ -461,9 +478,11 @@ class SyncOperations {
461
478
 
462
479
  // Retry with exponential backoff
463
480
  if (!imapClient.usable) {
464
- // nothing to do here, connection closed
465
- this.logger.error({ msg: `FETCH failed, connection already closed, not retrying` });
466
- return;
481
+ // connection closed, throw so callers know sync was interrupted
482
+ const connErr = new Error('IMAP connection closed during sync');
483
+ connErr.code = 'IMAPConnectionClosing';
484
+ connErr.statusCode = 503;
485
+ throw connErr;
467
486
  }
468
487
 
469
488
  const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
@@ -471,10 +490,14 @@ class SyncOperations {
471
490
  await new Promise(r => setTimeout(r, fetchRetryDelay));
472
491
 
473
492
  if (!imapClient.usable) {
474
- // nothing to do here, connection closed
475
- this.logger.error({ msg: `FETCH failed, connection already closed, not retrying` });
476
- return;
493
+ // connection closed, throw so callers know sync was interrupted
494
+ const connErr = new Error('IMAP connection closed during sync');
495
+ connErr.code = 'IMAPConnectionClosing';
496
+ connErr.statusCode = 503;
497
+ throw connErr;
477
498
  }
499
+
500
+ checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
478
501
  }
479
502
  }
480
503
  }
@@ -545,6 +568,8 @@ class SyncOperations {
545
568
  let range = false;
546
569
  let lastHighestUid = 0;
547
570
  let batchNumber = 0;
571
+ let fetchStartTime = Date.now();
572
+ let totalFetchRetryCount = 0;
548
573
  // process messages in batches
549
574
  while ((range = getFetchRange(mailboxStatus.messages, range))) {
550
575
  batchNumber++;
@@ -703,9 +728,11 @@ class SyncOperations {
703
728
  });
704
729
 
705
730
  if (!imapClient.usable) {
706
- // nothing to do here, connection closed
707
- this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, loopId });
708
- return;
731
+ // connection closed, throw so callers know sync was interrupted
732
+ const connErr = new Error('IMAP connection closed during sync');
733
+ connErr.code = 'IMAPConnectionClosing';
734
+ connErr.statusCode = 503;
735
+ throw connErr;
709
736
  }
710
737
 
711
738
  try {
@@ -728,14 +755,17 @@ class SyncOperations {
728
755
  }
729
756
 
730
757
  // Retry with exponential backoff
758
+ totalFetchRetryCount++;
731
759
  const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
732
760
  this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, loopId, batchNumber });
733
761
  await new Promise(r => setTimeout(r, fetchRetryDelay));
734
762
 
735
763
  if (!imapClient.usable) {
736
- // nothing to do here, connection closed
737
- this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, loopId });
738
- return;
764
+ // connection closed, throw so callers know sync was interrupted
765
+ const connErr = new Error('IMAP connection closed during sync');
766
+ connErr.code = 'IMAPConnectionClosing';
767
+ connErr.statusCode = 503;
768
+ throw connErr;
739
769
  }
740
770
 
741
771
  // Verify we're still on the correct mailbox after the delay
@@ -764,6 +794,8 @@ class SyncOperations {
764
794
  newMessages: mailboxStatus.messages,
765
795
  range
766
796
  });
797
+
798
+ checkFetchRetryTimeout(totalFetchRetryCount, fetchStartTime);
767
799
  }
768
800
  }
769
801
  }
@@ -299,7 +299,9 @@ class IMAPClient extends BaseClient {
299
299
 
300
300
  // Set up error handling for the command connection
301
301
  const onErr = err => {
302
- commandClient?.log.warn({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
302
+ // Idle timeout (ETIMEOUT) is expected - the command client has no keepalive
303
+ const logLevel = err.code === 'ETIMEOUT' ? 'debug' : 'warn';
304
+ commandClient?.log[logLevel]({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
303
305
  commandClient.close();
304
306
  this.commandClient = null;
305
307
  };
@@ -605,9 +607,15 @@ class IMAPClient extends BaseClient {
605
607
  })
606
608
  .filter(entry => entry);
607
609
 
610
+ // Build lookup map from stored listing for O(1) access
611
+ const storedListingMap = new Map();
612
+ for (const entry of storedListing) {
613
+ storedListingMap.set(normalizePath(entry.path), entry);
614
+ }
615
+
608
616
  // compare listings to detect changes
609
617
  for (let mailbox of listing) {
610
- let existingMailbox = storedListing.find(entry => normalizePath(entry.path) === normalizePath(mailbox.path));
618
+ let existingMailbox = storedListingMap.get(normalizePath(mailbox.path));
611
619
  if (!existingMailbox) {
612
620
  // found new!
613
621
  mailbox.isNew = true;
@@ -622,8 +630,9 @@ class IMAPClient extends BaseClient {
622
630
  }
623
631
 
624
632
  // Check for deleted mailboxes
633
+ const listingPathSet = new Set(listing.map(mailbox => normalizePath(mailbox.path)));
625
634
  for (let entry of storedListing) {
626
- if (!listing.some(mailbox => normalizePath(entry.path) === normalizePath(mailbox.path))) {
635
+ if (!listingPathSet.has(normalizePath(entry.path))) {
627
636
  // found deleted!
628
637
  await this.clearMailboxEntry(entry);
629
638
  hasChanges = true;
@@ -1207,9 +1216,14 @@ class IMAPClient extends BaseClient {
1207
1216
  }
1208
1217
 
1209
1218
  // Provider-specific workarounds
1210
- if (/(\.rambler\.ru|\.163\.com)$/i.test(imapConfig.host)) {
1211
- // Special case for Rambler and 163. Break IDLE at least once a minute
1219
+ if (/\.rambler\.ru$/i.test(imapConfig.host)) {
1220
+ // Rambler supports IDLE but needs it restarted frequently
1221
+ imapConfig.maxIdleTime = 55 * 1000;
1222
+ } else if (/\.163\.com$/i.test(imapConfig.host)) {
1223
+ // 163.com (Coremail) does not support IDLE and ignores NOOP for idle timer reset.
1224
+ // Use STATUS as the keepalive command - it counts as real activity for the server.
1212
1225
  imapConfig.maxIdleTime = 55 * 1000;
1226
+ imapConfig.missingIdleCommand = 'STATUS';
1213
1227
  } else if (/\.yahoo\.com$/i.test(imapConfig.host)) {
1214
1228
  // Special case for Yahoo. Break IDLE at least once every three minutes
1215
1229
  imapConfig.maxIdleTime = 3 * 60 * 1000;
@@ -2461,6 +2475,9 @@ class IMAPClient extends BaseClient {
2461
2475
  const mailboxes = [];
2462
2476
 
2463
2477
  const listing = await this.getCurrentListing(false, { allowSecondary: true });
2478
+ // Capture reference so we can close it after the await-heavy loop below
2479
+ // without risking closure of a different command client obtained by a concurrent caller
2480
+ const commandClientForListing = this.commandClient;
2464
2481
 
2465
2482
  // Process each configured subconnection
2466
2483
  for (const path of accountData.subconnections || []) {
@@ -2588,6 +2605,12 @@ class IMAPClient extends BaseClient {
2588
2605
  await subconnection.init();
2589
2606
  }
2590
2607
 
2608
+ // Close the command client if it is still the same instance we captured above
2609
+ if (commandClientForListing && this.commandClient === commandClientForListing) {
2610
+ this.commandClient.close();
2611
+ this.commandClient = null;
2612
+ }
2613
+
2591
2614
  return this.subconnections.length;
2592
2615
  }
2593
2616
 
@@ -2875,7 +2875,7 @@ class OutlookClient extends BaseClient {
2875
2875
  return this.oAuth2Client;
2876
2876
  }
2877
2877
  let accountData = await (this.delegatedAccountObject || this.accountObject).loadAccountData();
2878
- this.oAuth2Client = await oauth2Apps.getClient(accountData.oauth2.provider, {
2878
+ this.oAuth2Client = await oauth2Apps.getClient(accountData.oauth2?.provider || accountData._app?.id, {
2879
2879
  logger: this.logger,
2880
2880
  logRaw: this.options.logRaw
2881
2881
  });
@@ -3857,8 +3857,11 @@ class OutlookClient extends BaseClient {
3857
3857
  async processHistory() {
3858
3858
  let event;
3859
3859
  let newMessageOptions;
3860
+ let processedAny = false;
3860
3861
 
3861
3862
  while ((event = await this.accountObject.pullQueueEvent()) !== null) {
3863
+ processedAny = true;
3864
+
3862
3865
  switch (event.type) {
3863
3866
  case 'updated':
3864
3867
  await this.processUpdatedMessage(event.message);
@@ -3900,6 +3903,157 @@ class OutlookClient extends BaseClient {
3900
3903
  break;
3901
3904
  }
3902
3905
  }
3906
+
3907
+ if (processedAny) {
3908
+ await this._updateLastNotificationTime();
3909
+ }
3910
+ }
3911
+
3912
+ async _updateLastNotificationTime() {
3913
+ try {
3914
+ await this.redis.hset(this.getAccountKey(), 'outlookLastNotification', Date.now().toString());
3915
+ } catch (err) {
3916
+ this.logger.error({ msg: 'Failed to update last notification time', err });
3917
+ }
3918
+ }
3919
+
3920
+ /**
3921
+ * Recover messages missed due to failed webhook delivery.
3922
+ * Triggered by MS Graph 'missed' lifecycle event.
3923
+ * Queries recent messages and processes any not already seen via normal webhooks.
3924
+ * @returns {boolean} Whether recovery ran (false if debounced)
3925
+ */
3926
+ async syncMissedMessages() {
3927
+ // Debounce: skip if recovery already ran recently
3928
+ const cooldownKey = `${REDIS_PREFIX}iam:${this.account}:missedRecovery`;
3929
+ const acquired = await this.redis.set(cooldownKey, '1', 'EX', 300, 'NX');
3930
+ if (!acquired) {
3931
+ this.logger.debug({ msg: 'Missed notification recovery skipped (cooldown active)', account: this.account });
3932
+ return false;
3933
+ }
3934
+
3935
+ // Determine lookback window
3936
+ let sinceTime;
3937
+ const lastNotification = await this.redis.hget(this.getAccountKey(), 'outlookLastNotification');
3938
+ if (lastNotification) {
3939
+ // 2-minute safety margin before last known good notification
3940
+ sinceTime = new Date(Number(lastNotification) - 2 * 60 * 1000);
3941
+ } else {
3942
+ sinceTime = new Date(Date.now() - 30 * 60 * 1000);
3943
+ }
3944
+
3945
+ this.logger.info({
3946
+ msg: 'Recovering missed notifications',
3947
+ account: this.account,
3948
+ since: sinceTime.toISOString()
3949
+ });
3950
+
3951
+ let messages = [];
3952
+ let reqUrl = `/${this.oauth2UserPath}/messages`;
3953
+ let reqPayload = {
3954
+ $filter: `receivedDateTime gt ${sinceTime.toISOString()}`,
3955
+ $select: 'id,parentFolderId',
3956
+ $top: 250,
3957
+ $orderby: 'receivedDateTime desc'
3958
+ };
3959
+
3960
+ try {
3961
+ while (reqUrl) {
3962
+ let pageResult = await this.request(reqUrl, 'get', reqPayload);
3963
+
3964
+ if (pageResult?.value?.length) {
3965
+ messages.push(...pageResult.value);
3966
+ if (messages.length >= 1000) {
3967
+ messages.length = 1000;
3968
+ break;
3969
+ }
3970
+ }
3971
+
3972
+ if (pageResult['@odata.nextLink']) {
3973
+ reqUrl = pageResult['@odata.nextLink'];
3974
+ reqPayload = null; // nextLink includes all query params
3975
+ } else {
3976
+ reqUrl = null;
3977
+ }
3978
+ }
3979
+ } catch (err) {
3980
+ this.logger.error({ msg: 'Failed to query recent messages for missed notification recovery', account: this.account, err });
3981
+ await this.redis.del(cooldownKey);
3982
+ return false;
3983
+ }
3984
+
3985
+ if (!messages.length) {
3986
+ this.logger.debug({ msg: 'No recent messages found during missed notification recovery', account: this.account });
3987
+ return true;
3988
+ }
3989
+
3990
+ this.logger.info({
3991
+ msg: 'Found recent messages for missed notification recovery',
3992
+ account: this.account,
3993
+ count: messages.length
3994
+ });
3995
+
3996
+ let newMessageOptions = await this.getMessageFetchOptions();
3997
+ newMessageOptions.showPath = true;
3998
+
3999
+ // Pre-resolve unique folder IDs once to avoid per-message Redis lookups
4000
+ let folderMap = new Map();
4001
+ let cachedListing = await this.getCachedMailboxListing();
4002
+ if (cachedListing) {
4003
+ for (let msg of messages) {
4004
+ if (msg.parentFolderId && !folderMap.has(msg.parentFolderId)) {
4005
+ let folder = cachedListing.find(entry => entry.id === msg.parentFolderId);
4006
+ if (folder) {
4007
+ folderMap.set(msg.parentFolderId, folder);
4008
+ }
4009
+ }
4010
+ }
4011
+ }
4012
+
4013
+ let recoveredCount = 0;
4014
+
4015
+ for (const msg of messages) {
4016
+ try {
4017
+ // Pre-filter: check rollingBucketLock BEFORE the expensive getMessage() call
4018
+ let preFilterChecked = false;
4019
+ let folder = folderMap.get(msg.parentFolderId);
4020
+ if (folder) {
4021
+ let recentlySeen = await this.rollingBucketLock(`${msg.id}:created`, folder.pathName);
4022
+ if (recentlySeen) {
4023
+ continue;
4024
+ }
4025
+ preFilterChecked = true;
4026
+ }
4027
+
4028
+ let messageData = await this.prepareNewMessage(msg.id, newMessageOptions);
4029
+ if (!messageData) {
4030
+ continue;
4031
+ }
4032
+
4033
+ if (!preFilterChecked) {
4034
+ let recentlySeen = await this.rollingBucketLock(`${messageData.id}:created`, messageData.path);
4035
+ if (recentlySeen) {
4036
+ continue;
4037
+ }
4038
+ }
4039
+
4040
+ await this.processNew(messageData, newMessageOptions);
4041
+ recoveredCount++;
4042
+ } catch (err) {
4043
+ this.logger.error({ msg: 'Failed to process message during missed notification recovery', emailId: msg.id, account: this.account, err });
4044
+ }
4045
+ }
4046
+
4047
+ this.logger.info({
4048
+ msg: 'Missed notification recovery completed',
4049
+ account: this.account,
4050
+ checked: messages.length,
4051
+ recovered: recoveredCount
4052
+ });
4053
+
4054
+ await this._updateLastNotificationTime();
4055
+
4056
+ return true;
3903
4057
  }
3904
4058
 
3905
4059
  /**
@@ -20,6 +20,8 @@ const GMAIL_API_SCOPES = {
20
20
  labels: 'https://www.googleapis.com/auth/gmail.labels'
21
21
  };
22
22
 
23
+ const OPENID_SCOPES = ['openid', 'email', 'profile'];
24
+
23
25
  const EXPOSE_PARTIAL_SECRET_KEY_REGEX = /Unauthorized/i;
24
26
 
25
27
  const checkForFlags = (err, isPrincipal) => {
@@ -171,7 +173,7 @@ class GmailOauth {
171
173
  // Service accounts use JWT-based authentication and don't need OpenID scopes
172
174
  // These are non-privileged scopes that provide email and profile info for 3-legged OAuth
173
175
  if (!opts.serviceClient) {
174
- const openidScopes = ['openid', 'email', 'profile'];
176
+ const openidScopes = OPENID_SCOPES;
175
177
 
176
178
  // Add OpenID scopes if not already present
177
179
  for (let scope of openidScopes) {
@@ -203,6 +205,7 @@ class GmailOauth {
203
205
  this.setFlag = opts.setFlag;
204
206
 
205
207
  this.tokenUrl = `https://oauth2.googleapis.com/token`;
208
+ this.revokeUrl = `https://oauth2.googleapis.com/revoke`;
206
209
  }
207
210
 
208
211
  generateAuthUrl(opts) {
@@ -675,8 +678,56 @@ class GmailOauth {
675
678
  const signature = crypto.createSign('RSA-SHA256').update(encodedPayload).sign(this.serviceKey);
676
679
  return [encodedPayload, Buffer.from(signature).toString('base64url')].join('.');
677
680
  }
681
+
682
+ async revokeToken(token) {
683
+ const fetchOpts = {
684
+ method: 'post',
685
+ headers: {
686
+ 'Content-Type': 'application/x-www-form-urlencoded',
687
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
688
+ },
689
+ body: new URLSearchParams({ token }).toString()
690
+ };
691
+
692
+ try {
693
+ let res = await fetchCmd(this.revokeUrl, Object.assign(fetchOpts, { dispatcher: httpAgent.retry }));
694
+
695
+ let responseText;
696
+ try {
697
+ responseText = await res.text();
698
+ } catch (err) {
699
+ // ignore
700
+ }
701
+
702
+ if (this.logger) {
703
+ this.logger.info({
704
+ msg: 'OAuth2 token revocation request',
705
+ action: 'oauth2Fetch',
706
+ fn: 'revokeToken',
707
+ url: this.revokeUrl,
708
+ success: !!res.ok,
709
+ status: res.status
710
+ });
711
+ }
712
+
713
+ if (!res.ok) {
714
+ if (this.logger) {
715
+ this.logger.warn({
716
+ msg: 'OAuth2 token revocation failed',
717
+ status: res.status,
718
+ response: responseText
719
+ });
720
+ }
721
+ }
722
+ } catch (err) {
723
+ if (this.logger) {
724
+ this.logger.error({ msg: 'OAuth2 token revocation error', err });
725
+ }
726
+ }
727
+ }
678
728
  }
679
729
 
680
730
  module.exports.GmailOauth = GmailOauth;
681
731
  module.exports.GMAIL_SCOPES = GMAIL_SCOPES;
682
732
  module.exports.GMAIL_API_SCOPES = GMAIL_API_SCOPES;
733
+ module.exports.OPENID_SCOPES = OPENID_SCOPES;