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
package/workers/api.js CHANGED
@@ -163,7 +163,8 @@ const {
163
163
  googleSubscriptionNameSchema,
164
164
  messageReferenceSchema,
165
165
  idempotencyKeySchema,
166
- headerTimeoutSchema
166
+ headerTimeoutSchema,
167
+ pubSubErrorSchema
167
168
  } = require('../lib/schemas');
168
169
 
169
170
  const OAuth2ProviderSchema = Joi.string()
@@ -180,6 +181,21 @@ const AccountTypeSchema = Joi.string()
180
181
  .required()
181
182
  .label('AccountType');
182
183
 
184
+ function flattenOAuthAppMeta(app) {
185
+ if (!app.meta) {
186
+ return;
187
+ }
188
+ let authFlag = app.meta.authFlag;
189
+ let pubSubFlag = app.meta.pubSubFlag;
190
+ delete app.meta;
191
+ if (authFlag && authFlag.message) {
192
+ app.lastError = { response: authFlag.message };
193
+ }
194
+ if (pubSubFlag && pubSubFlag.message) {
195
+ app.pubSubError = { message: pubSubFlag.message, description: pubSubFlag.description || null };
196
+ }
197
+ }
198
+
183
199
  const SUPPORTED_LOCALES = locales.map(locale => locale.locale);
184
200
 
185
201
  const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash'];
@@ -362,6 +378,7 @@ async function call(message, transferList) {
362
378
  err.statusCode = 504;
363
379
  err.code = 'Timeout';
364
380
  err.ttl = ttl;
381
+ callQueue.delete(mid);
365
382
  reject(err);
366
383
  }, ttl);
367
384
 
@@ -1557,11 +1574,12 @@ Include your token in requests using one of these methods:
1557
1574
  let checkKey = `${REDIS_PREFIX}test:${Date.now()}`;
1558
1575
  let expected = crypto.randomBytes(8).toString('hex');
1559
1576
  let res = await redis.multi().set(checkKey, expected).get(checkKey).del(checkKey).exec();
1560
- if (res[1] && res[1][1] === expected && res[2] && res[2][1] === 1) {
1561
- return { success: true };
1577
+ if (!(res[1] && res[1][1] === expected && res[2] && res[2][1] === 1)) {
1578
+ let error = Boom.boomify(new Error('Database check failed'), { statusCode: 500 });
1579
+ throw error;
1562
1580
  }
1563
- let error = Boom.boomify(new Error('Database check failed'), { statusCode: 500 });
1564
- throw error;
1581
+
1582
+ return { success: true };
1565
1583
  },
1566
1584
  options: {
1567
1585
  description: 'Health check',
@@ -1570,6 +1588,99 @@ Include your token in requests using one of these methods:
1570
1588
  }
1571
1589
  });
1572
1590
 
1591
+ server.route({
1592
+ method: 'GET',
1593
+ path: '/v1/pubsub/status',
1594
+
1595
+ async handler(request) {
1596
+ try {
1597
+ let response = await oauth2Apps.list(request.query.page, request.query.pageSize, { pubsub: true });
1598
+
1599
+ let apps = response.apps.map(app => {
1600
+ flattenOAuthAppMeta(app);
1601
+ return { id: app.id, name: app.name || null, lastError: app.lastError || null, pubSubError: app.pubSubError || null };
1602
+ });
1603
+
1604
+ return {
1605
+ total: response.total,
1606
+ page: response.page,
1607
+ pages: response.pages,
1608
+ apps
1609
+ };
1610
+ } catch (err) {
1611
+ request.logger.error({ msg: 'API request failed', err });
1612
+ if (Boom.isBoom(err)) {
1613
+ throw err;
1614
+ }
1615
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1616
+ if (err.code) {
1617
+ error.output.payload.code = err.code;
1618
+ }
1619
+ throw error;
1620
+ }
1621
+ },
1622
+
1623
+ options: {
1624
+ description: 'List Pub/Sub status',
1625
+ notes: 'Lists Pub/Sub enabled OAuth2 applications and their subscription status',
1626
+ tags: ['api', 'OAuth2 Applications'],
1627
+
1628
+ plugins: {},
1629
+
1630
+ auth: {
1631
+ strategy: 'api-token',
1632
+ mode: 'required'
1633
+ },
1634
+ cors: CORS_CONFIG,
1635
+
1636
+ validate: {
1637
+ options: {
1638
+ stripUnknown: false,
1639
+ abortEarly: false,
1640
+ convert: true
1641
+ },
1642
+ failAction,
1643
+
1644
+ query: Joi.object({
1645
+ page: Joi.number()
1646
+ .integer()
1647
+ .min(0)
1648
+ .max(1024 * 1024)
1649
+ .default(0)
1650
+ .example(0)
1651
+ .description('Page number (zero indexed, so use 0 for first page)')
1652
+ .label('PageNumber'),
1653
+ pageSize: Joi.number().integer().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize')
1654
+ }).label('PubSubStatusFilter')
1655
+ },
1656
+
1657
+ response: {
1658
+ schema: Joi.object({
1659
+ total: Joi.number().integer().example(120).description('How many matching entries').label('TotalNumber'),
1660
+ page: Joi.number().integer().example(0).description('Current page (0-based index)').label('PageNumber'),
1661
+ pages: Joi.number().integer().example(24).description('Total page count').label('PagesNumber'),
1662
+
1663
+ apps: Joi.array()
1664
+ .items(
1665
+ Joi.object({
1666
+ id: Joi.string().max(256).required().example('AAABhaBPHscAAAAH').description('OAuth2 application ID'),
1667
+ name: Joi.string().allow(null).max(256).example('My Gmail App').description('Display name for the app'),
1668
+ lastError: Joi.object({
1669
+ response: Joi.string().example('Enable the Cloud Pub/Sub API').description('Setup error message')
1670
+ })
1671
+ .allow(null)
1672
+ .description('Setup error from ensurePubsub, if any')
1673
+ .label('PubSubSetupError'),
1674
+ pubSubError: pubSubErrorSchema.allow(null)
1675
+ }).label('PubSubAppStatus')
1676
+ )
1677
+ .label('PubSubAppStatusList')
1678
+ }).label('PubSubStatusResponse'),
1679
+ failAction: 'log'
1680
+ }
1681
+ }
1682
+ });
1683
+
1573
1684
  server.route({
1574
1685
  method: 'GET',
1575
1686
  path: '/redirect',
@@ -1936,6 +2047,10 @@ Include your token in requests using one of these methods:
1936
2047
 
1937
2048
  const outlookSubscription = accountData.outlookSubscription;
1938
2049
 
2050
+ // Deduplicate lifecycle events within the same batch to prevent
2051
+ // concurrent handlers racing (e.g., two subscriptionRemoved entries)
2052
+ const seenLifecycleEvents = new Set();
2053
+
1939
2054
  for (let entry of (request.payload && request.payload.value) || []) {
1940
2055
  request.logger.debug({
1941
2056
  msg: 'MS Graph subscription event',
@@ -1960,82 +2075,43 @@ Include your token in requests using one of these methods:
1960
2075
  continue;
1961
2076
  }
1962
2077
 
1963
- switch (entry.lifecycleEvent) {
1964
- case 'reauthorizationRequired': {
1965
- // Microsoft is requesting reauthorization - force renewal immediately
1966
- request.logger.info({
1967
- msg: 'Received reauthorizationRequired lifecycle event',
1968
- subscriptionId: outlookSubscription.id,
2078
+ // Route recognized lifecycle events to the IMAP worker
2079
+ // so the live client with its OAuth state handles them
2080
+ if (entry.lifecycleEvent === 'reauthorizationRequired' || entry.lifecycleEvent === 'subscriptionRemoved') {
2081
+ const dedupeKey = `${entry.lifecycleEvent}:${entry.subscriptionId}`;
2082
+ if (seenLifecycleEvents.has(dedupeKey)) {
2083
+ request.logger.debug({
2084
+ msg: 'Skipping duplicate lifecycle event in batch',
2085
+ lifecycleEvent: entry.lifecycleEvent,
2086
+ subscriptionId: entry.subscriptionId,
1969
2087
  account: request.query.account
1970
2088
  });
1971
-
1972
- // Use the unified renewal method from OutlookClient
1973
- // We need to create a client instance to call the renewal method
1974
- const { OutlookClient } = require('../lib/email-client/outlook-client');
1975
- const client = new OutlookClient(accountData, {
1976
- redis,
1977
- secret: await getSecret(),
1978
- logger: request.logger
1979
- });
1980
-
1981
- try {
1982
- // Force renewal when we get reauthorizationRequired
1983
- const renewalResult = await client.renewSubscription(true);
1984
-
1985
- if (renewalResult.success) {
1986
- request.logger.info({
1987
- msg: 'Successfully renewed subscription from lifecycle event',
1988
- subscriptionId: outlookSubscription.id,
1989
- account: request.query.account,
1990
- newExpirationDateTime: renewalResult.expirationDateTime
1991
- });
1992
- } else {
1993
- request.logger.error({
1994
- msg: 'Failed to renew subscription from lifecycle event',
1995
- subscriptionId: outlookSubscription.id,
1996
- account: request.query.account,
1997
- reason: renewalResult.reason,
1998
- error: renewalResult.error
1999
- });
2000
- }
2001
- } catch (err) {
2002
- request.logger.error({
2003
- msg: 'Exception while renewing subscription from lifecycle event',
2004
- subscriptionId: outlookSubscription.id,
2005
- account: request.query.account,
2006
- err
2007
- });
2008
- } finally {
2009
- // Clean up client instance
2010
- if (client && typeof client.close === 'function') {
2011
- try {
2012
- await client.close();
2013
- } catch (cleanupErr) {
2014
- request.logger.debug({
2015
- msg: 'Error closing client after lifecycle renewal',
2016
- account: request.query.account,
2017
- err: cleanupErr
2018
- });
2019
- }
2020
- }
2021
- }
2022
-
2023
- break;
2089
+ continue;
2024
2090
  }
2091
+ seenLifecycleEvents.add(dedupeKey);
2025
2092
 
2026
- case 'subscriptionRemoved': {
2027
- // subscription was removed, should we recreate it?
2028
- await accountObject.update({
2029
- outlookSubscription: {
2030
- state: {
2031
- state: 'error',
2032
- error: `Subscription removed`,
2033
- time: Date.now()
2034
- }
2035
- }
2093
+ request.logger.info({
2094
+ msg: 'Received lifecycle event',
2095
+ lifecycleEvent: entry.lifecycleEvent,
2096
+ subscriptionId: outlookSubscription.id,
2097
+ account: request.query.account
2098
+ });
2099
+
2100
+ // Fire-and-forget: return HTTP 202 immediately so Microsoft
2101
+ // does not time out the lifecycle webhook delivery
2102
+ call({
2103
+ cmd: 'subscriptionLifecycle',
2104
+ account: request.query.account,
2105
+ event: entry.lifecycleEvent,
2106
+ timeout: consts.OUTLOOK_SUBSCRIPTION_LOCK_TTL
2107
+ }).catch(err => {
2108
+ request.logger.error({
2109
+ msg: 'Failed to handle lifecycle event via worker',
2110
+ account: request.query.account,
2111
+ lifecycleEvent: entry.lifecycleEvent,
2112
+ err
2036
2113
  });
2037
- break;
2038
- }
2114
+ });
2039
2115
  }
2040
2116
  }
2041
2117
 
@@ -4876,13 +4952,7 @@ Include your token in requests using one of these methods:
4876
4952
  delete app.app;
4877
4953
  }
4878
4954
 
4879
- if (app.meta) {
4880
- let authFlag = app.meta.authFlag;
4881
- delete app.meta;
4882
- if (authFlag && authFlag.message) {
4883
- app.lastError = { response: authFlag.message };
4884
- }
4885
- }
4955
+ flattenOAuthAppMeta(app);
4886
4956
  }
4887
4957
 
4888
4958
  return response;
@@ -5002,7 +5072,8 @@ Include your token in requests using one of these methods:
5002
5072
  .example('******')
5003
5073
  .description('PEM formatted service secret for 2-legged OAuth2 applications. Actual value is not revealed.'),
5004
5074
 
5005
- lastError: lastErrorSchema.allow(null)
5075
+ lastError: lastErrorSchema.allow(null),
5076
+ pubSubError: pubSubErrorSchema.allow(null)
5006
5077
  }).label('OAuth2ResponseItem')
5007
5078
  )
5008
5079
  .label('OAuth2Entries')
@@ -5035,13 +5106,7 @@ Include your token in requests using one of these methods:
5035
5106
  delete app.app;
5036
5107
  }
5037
5108
 
5038
- if (app.meta) {
5039
- let authFlag = app.meta.authFlag;
5040
- delete app.meta;
5041
- if (authFlag && authFlag.message) {
5042
- app.lastError = { response: authFlag.message };
5043
- }
5044
- }
5109
+ flattenOAuthAppMeta(app);
5045
5110
 
5046
5111
  return app;
5047
5112
  } catch (err) {
@@ -5145,7 +5210,8 @@ Include your token in requests using one of these methods:
5145
5210
  .example(12)
5146
5211
  .description('The number of accounts registered with this application. Not available for legacy apps.'),
5147
5212
 
5148
- lastError: lastErrorSchema.allow(null)
5213
+ lastError: lastErrorSchema.allow(null),
5214
+ pubSubError: pubSubErrorSchema.allow(null)
5149
5215
  }).label('ApplicationResponse'),
5150
5216
  failAction: 'log'
5151
5217
  }
@@ -5160,8 +5226,9 @@ Include your token in requests using one of these methods:
5160
5226
  try {
5161
5227
  let result = await oauth2Apps.create(request.payload);
5162
5228
 
5163
- if (result && result.pubsubUpdates && result.pubsubUpdates.pubSubSubscription) {
5229
+ if (result && result.pubsubUpdates && Object.keys(result.pubsubUpdates).length > 0) {
5164
5230
  await call({ cmd: 'googlePubSub', app: result.id });
5231
+ delete result.pubsubUpdates;
5165
5232
  }
5166
5233
 
5167
5234
  return result;
@@ -5220,8 +5287,9 @@ Include your token in requests using one of these methods:
5220
5287
  try {
5221
5288
  let result = await oauth2Apps.update(request.params.app, request.payload);
5222
5289
 
5223
- if (result && result.pubsubUpdates && result.pubsubUpdates.pubSubSubscription) {
5290
+ if (result && result.pubsubUpdates && Object.keys(result.pubsubUpdates).length > 0) {
5224
5291
  await call({ cmd: 'googlePubSub', app: result.id });
5292
+ delete result.pubsubUpdates;
5225
5293
  }
5226
5294
 
5227
5295
  return result;
@@ -5369,7 +5437,15 @@ Include your token in requests using one of these methods:
5369
5437
 
5370
5438
  async handler(request) {
5371
5439
  try {
5372
- return await oauth2Apps.del(request.params.app);
5440
+ let result = await oauth2Apps.del(request.params.app);
5441
+
5442
+ try {
5443
+ await call({ cmd: 'googlePubSubRemove', app: request.params.app });
5444
+ } catch (err) {
5445
+ request.logger.error({ msg: 'Failed to notify workers about OAuth2 app deletion', err, app: request.params.app });
5446
+ }
5447
+
5448
+ return result;
5373
5449
  } catch (err) {
5374
5450
  request.logger.error({ msg: 'API request failed', err });
5375
5451
  if (Boom.isBoom(err)) {
@@ -6942,11 +7018,13 @@ ${now}`,
6942
7018
  // Replace error with friendly HTML
6943
7019
  const error = response;
6944
7020
  const ctx = {
7021
+ statusCode: error.output.statusCode,
6945
7022
  message:
6946
7023
  error.output.statusCode === 404
6947
7024
  ? request.app.gt.gettext('Requested page not found')
6948
7025
  : (error.output && error.output.payload && error.output.payload.message) || request.app.gt.gettext('Something went wrong'),
6949
- details: error.output && error.output.payload && error.output.payload.details
7026
+ details: error.output && error.output.payload && error.output.payload.details,
7027
+ templateLocale: request.app.locale
6950
7028
  };
6951
7029
 
6952
7030
  if (error.output && error.output.payload) {
@@ -72,6 +72,7 @@ async function call(message, transferList) {
72
72
  err.statusCode = 504;
73
73
  err.code = 'Timeout';
74
74
  err.ttl = ttl;
75
+ callQueue.delete(mid);
75
76
  reject(err);
76
77
  }, ttl);
77
78
 
package/workers/imap.js CHANGED
@@ -78,10 +78,6 @@ class ConnectionHandler {
78
78
  this.mids = 0;
79
79
 
80
80
  this.accounts = new Map();
81
-
82
- // Reconnection metrics tracking
83
- this.reconnectMetrics = new Map(); // Track metrics per account
84
- this.metricsWindow = 60000; // 1-minute window
85
81
  }
86
82
 
87
83
  async init() {
@@ -639,6 +635,34 @@ class ConnectionHandler {
639
635
  return await accountData.connection.externalNotify(message);
640
636
  }
641
637
 
638
+ async subscriptionLifecycle(message) {
639
+ if (!this.accounts.has(message.account)) {
640
+ throw NO_ACTIVE_HANDLER_RESP_ERR;
641
+ }
642
+
643
+ let accountData = this.accounts.get(message.account);
644
+ if (!accountData.connection) {
645
+ throw NO_ACTIVE_HANDLER_RESP_ERR;
646
+ }
647
+
648
+ let connection = accountData.connection;
649
+
650
+ switch (message.event) {
651
+ case 'reauthorizationRequired':
652
+ return await connection.renewSubscription({ force: true });
653
+
654
+ case 'subscriptionRemoved':
655
+ // Clear stored subscription since MS deleted it server-side, under lock
656
+ logger.info({ msg: 'Handling subscriptionRemoved lifecycle event', account: message.account });
657
+ await connection.ensureSubscription({ clearExisting: true });
658
+ return true;
659
+
660
+ default:
661
+ logger.warn({ msg: 'Unknown subscription lifecycle event', event: message.event, account: message.account });
662
+ return false;
663
+ }
664
+ }
665
+
642
666
  async getQuota(message) {
643
667
  if (!this.accounts.has(message.account)) {
644
668
  throw NO_ACTIVE_HANDLER_RESP_ERR;
@@ -725,49 +749,6 @@ class ConnectionHandler {
725
749
  };
726
750
  }
727
751
 
728
- /**
729
- * Track reconnection attempts for monitoring (without blocking)
730
- * @param {string} account - Account identifier
731
- */
732
- trackReconnection(account) {
733
- const now = Date.now();
734
- const metrics = this.reconnectMetrics.get(account) || {
735
- attempts: [],
736
- warnings: 0
737
- };
738
-
739
- // Clean old attempts outside window
740
- metrics.attempts = metrics.attempts.filter(t => now - t < this.metricsWindow);
741
- metrics.attempts.push(now);
742
-
743
- // Log warning if excessive reconnections
744
- if (metrics.attempts.length > 20) {
745
- // More than 20 per minute
746
- metrics.warnings++;
747
- logger.warn({
748
- msg: 'Excessive reconnection rate detected',
749
- account,
750
- rate: `${metrics.attempts.length}/min`,
751
- totalWarnings: metrics.warnings
752
- });
753
-
754
- // Emit metrics for monitoring/alerting
755
- try {
756
- parentPort.postMessage({
757
- cmd: 'metrics',
758
- key: 'imap.reconnect.excessive',
759
- method: 'inc',
760
- args: [1],
761
- meta: { account }
762
- });
763
- } catch (err) {
764
- logger.error({ msg: 'Failed to send metrics', err });
765
- }
766
- }
767
-
768
- this.reconnectMetrics.set(account, metrics);
769
- }
770
-
771
752
  async getAttachment(message) {
772
753
  if (!this.accounts.has(message.account)) {
773
754
  throw NO_ACTIVE_HANDLER_RESP_ERR;
@@ -892,6 +873,7 @@ class ConnectionHandler {
892
873
  case 'uploadMessage':
893
874
  case 'subconnections':
894
875
  case 'externalNotify':
876
+ case 'subscriptionLifecycle':
895
877
  case 'listSignatures':
896
878
  return await this[message.cmd](message);
897
879
 
@@ -940,6 +922,7 @@ class ConnectionHandler {
940
922
  err.statusCode = 504;
941
923
  err.code = 'Timeout';
942
924
  err.ttl = ttl;
925
+ this.callQueue.delete(mid);
943
926
  reject(err);
944
927
  }, ttl);
945
928
 
package/workers/smtp.js CHANGED
@@ -77,6 +77,7 @@ async function call(message, transferList) {
77
77
  err.statusCode = 504;
78
78
  err.code = 'Timeout';
79
79
  err.ttl = ttl;
80
+ callQueue.delete(mid);
80
81
  reject(err);
81
82
  }, ttl);
82
83
 
package/workers/submit.js CHANGED
@@ -85,6 +85,7 @@ async function call(message, transferList) {
85
85
  err.statusCode = 504;
86
86
  err.code = 'Timeout';
87
87
  err.ttl = ttl;
88
+ callQueue.delete(mid);
88
89
  reject(err);
89
90
  }, ttl);
90
91
 
@@ -67,6 +67,7 @@ async function call(message, transferList) {
67
67
  err.statusCode = 504;
68
68
  err.code = 'Timeout';
69
69
  err.ttl = ttl;
70
+ callQueue.delete(mid);
70
71
  reject(err);
71
72
  }, ttl);
72
73
 
@@ -117,6 +118,14 @@ async function onCommand(command) {
117
118
  case 'googlePubSub':
118
119
  await googlePubSub.update(command.app);
119
120
  return true;
121
+ case 'googlePubSubRemove':
122
+ googlePubSub.remove(command.app);
123
+ return true;
124
+ case 'close':
125
+ clearTimeout(startRetryTimer);
126
+ googlePubSub.stopAll();
127
+ await notifyWorker.close(true);
128
+ return true;
120
129
  default:
121
130
  logger.debug({ msg: 'Unhandled command', command });
122
131
  return 999;
@@ -132,6 +141,18 @@ setInterval(() => {
132
141
  }
133
142
  }, 10 * 1000).unref();
134
143
 
144
+ // Clean up Pub/Sub instances when parent port closes
145
+ parentPort.on('close', () => {
146
+ clearTimeout(startRetryTimer);
147
+ googlePubSub.stopAll();
148
+ // notifyWorker.close() may throw synchronously if not yet initialized
149
+ try {
150
+ notifyWorker.close(true).catch(() => {});
151
+ } catch {
152
+ // ignore
153
+ }
154
+ });
155
+
135
156
  // Send initial ready signal
136
157
  parentPort.postMessage({ cmd: 'ready' });
137
158
 
@@ -461,28 +482,6 @@ const notifyWorker = new Worker(
461
482
  status: 'success'
462
483
  });
463
484
  } catch (err) {
464
- /*
465
- // do not disable by default
466
- if (err.status === 410) {
467
- // disable webhook
468
- logger.error({
469
- msg: 'Webhooks were disabled by server',
470
- action: 'webhook',
471
- queue: job.queue.name,
472
- code: 'disabled_by_server',
473
- job: job.id,
474
- webhooks,
475
- accountWebhooks: !!accountWebhooks,
476
- event: job.name,
477
- status: err.status,
478
- account: job.data.account,
479
- route: customRoute && customRoute.id,
480
- err
481
- });
482
- await settings.set('webhooksEnabled', false);
483
- return;
484
- }
485
- */
486
485
  logger.error({
487
486
  msg: 'Failed posting webhook',
488
487
  action: 'webhook',
@@ -606,13 +605,26 @@ notifyWorker.on('failed', async job => {
606
605
  });
607
606
  });
608
607
 
609
- googlePubSub
610
- .start()
611
- .then(() => {
612
- logger.info({ msg: 'Started processing Google pub/sub' });
613
- })
614
- .catch(err => {
615
- logger.fatal({ msg: 'Failed to start processing Google pub/sub', err });
616
- });
608
+ let startRetryTimer = null;
609
+
610
+ (function startGooglePubSub(attempt) {
611
+ googlePubSub
612
+ .start()
613
+ .then(() => {
614
+ logger.info({ msg: 'Started processing Google pub/sub' });
615
+ })
616
+ .catch(err => {
617
+ let maxNormalAttempts = 20;
618
+ let delay;
619
+ if (attempt < maxNormalAttempts) {
620
+ delay = Math.min(5000 * Math.pow(2, Math.min(attempt, 10)), 60000);
621
+ logger.error({ msg: 'Failed to start processing Google pub/sub', err, attempt: attempt + 1, retryMs: delay });
622
+ } else {
623
+ delay = 5 * 60 * 1000;
624
+ logger.warn({ msg: 'Failed to start processing Google pub/sub (reduced frequency)', err, attempt: attempt + 1, retryMs: delay });
625
+ }
626
+ startRetryTimer = setTimeout(() => startGooglePubSub(attempt + 1), delay);
627
+ });
628
+ })(0);
617
629
 
618
630
  logger.info({ msg: 'Started Webhooks worker thread', version: packageData.version });