emailengine-app 2.68.0 → 2.69.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 (74) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +8 -0
  4. package/.github/workflows/release.yaml +4 -0
  5. package/.github/workflows/test.yml +3 -0
  6. package/CHANGELOG.md +49 -0
  7. package/SECURITY.md +80 -0
  8. package/SECURITY.txt +27 -0
  9. package/config/default.toml +2 -0
  10. package/data/google-crawlers.json +13 -1
  11. package/lib/account.js +62 -25
  12. package/lib/api-routes/account-routes.js +493 -75
  13. package/lib/api-routes/blocklist-routes.js +337 -0
  14. package/lib/api-routes/delivery-test-routes.js +321 -0
  15. package/lib/api-routes/export-routes.js +1 -12
  16. package/lib/api-routes/gateway-routes.js +376 -0
  17. package/lib/api-routes/license-routes.js +142 -0
  18. package/lib/api-routes/mailbox-routes.js +318 -0
  19. package/lib/api-routes/message-routes.js +21 -129
  20. package/lib/api-routes/oauth2-app-routes.js +631 -0
  21. package/lib/api-routes/outbox-routes.js +173 -0
  22. package/lib/api-routes/pubsub-routes.js +98 -0
  23. package/lib/api-routes/route-helpers.js +45 -0
  24. package/lib/api-routes/settings-routes.js +331 -0
  25. package/lib/api-routes/stats-routes.js +77 -0
  26. package/lib/api-routes/submit-routes.js +472 -0
  27. package/lib/api-routes/template-routes.js +7 -55
  28. package/lib/api-routes/token-routes.js +297 -0
  29. package/lib/api-routes/webhook-route-routes.js +152 -0
  30. package/lib/email-client/gmail-client.js +14 -0
  31. package/lib/email-client/imap/mailbox.js +34 -11
  32. package/lib/email-client/imap/subconnection.js +20 -12
  33. package/lib/email-client/imap/sync-operations.js +130 -2
  34. package/lib/email-client/imap-client.js +116 -58
  35. package/lib/email-client/outlook-client.js +85 -13
  36. package/lib/export.js +60 -19
  37. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  38. package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
  39. package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
  40. package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
  41. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  42. package/lib/imapproxy/imap-server.js +92 -29
  43. package/lib/message-port-stream.js +113 -16
  44. package/lib/reject-worker-calls.js +42 -0
  45. package/lib/routes-ui.js +37 -8778
  46. package/lib/schemas.js +26 -1
  47. package/lib/tools.js +73 -0
  48. package/lib/ui-routes/account-routes.js +40 -210
  49. package/lib/ui-routes/admin-config-routes.js +913 -487
  50. package/lib/ui-routes/admin-entities-routes.js +1 -0
  51. package/lib/ui-routes/auth-routes.js +1339 -0
  52. package/lib/ui-routes/dashboard-routes.js +188 -0
  53. package/lib/ui-routes/document-store-routes.js +800 -0
  54. package/lib/ui-routes/export-routes.js +217 -0
  55. package/lib/ui-routes/internals-routes.js +354 -0
  56. package/lib/ui-routes/network-config-routes.js +759 -0
  57. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  58. package/lib/ui-routes/route-helpers.js +316 -0
  59. package/lib/ui-routes/smtp-test-routes.js +236 -0
  60. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  61. package/lib/webhook-request.js +36 -0
  62. package/package.json +17 -17
  63. package/sbom.json +1 -1
  64. package/server.js +217 -19
  65. package/static/licenses.html +52 -182
  66. package/translations/messages.pot +131 -151
  67. package/views/dashboard.hbs +7 -26
  68. package/views/internals/index.hbs +15 -0
  69. package/views/tokens/index.hbs +9 -0
  70. package/workers/api.js +198 -4401
  71. package/workers/export.js +87 -54
  72. package/workers/imap.js +29 -13
  73. package/workers/submit.js +20 -11
  74. package/workers/webhooks.js +6 -20
package/workers/export.js CHANGED
@@ -17,7 +17,7 @@ const {
17
17
  DEFAULT_EXPORT_MAX_MESSAGES,
18
18
  DEFAULT_EXPORT_MAX_SIZE
19
19
  } = require('../lib/consts');
20
- const { getDuration, readEnvValue, threadStats, reloadHttpProxyAgent } = require('../lib/tools');
20
+ const { getDuration, readEnvValue, threadStats, maybeReloadHttpProxyAgent } = require('../lib/tools');
21
21
  const { webhooks: Webhooks } = require('../lib/webhooks');
22
22
  const settings = require('../lib/settings');
23
23
  const { Export } = require('../lib/export');
@@ -155,6 +155,28 @@ async function notify(account, event, data) {
155
155
  await Webhooks.pushToQueue(event, await Webhooks.formatPayload(event, payload));
156
156
  }
157
157
 
158
+ // Returns a throttled callback that extends both the BullMQ job lock and the export's Redis key
159
+ // expiry on the LOCK_EXTENSION_INTERVAL cadence. Long-running indexing/exporting loops call it
160
+ // freely; the lock/expiry are refreshed at most once per interval. Without this the lock could lapse
161
+ // (stalling and reprocessing the job) and the export keys could expire mid-run.
162
+ function createLeaseExtender(job, account, exportId) {
163
+ let lastExtension = Date.now();
164
+ return async function maybeExtendLease() {
165
+ if (Date.now() - lastExtension <= LOCK_EXTENSION_INTERVAL) {
166
+ return;
167
+ }
168
+ await Promise.all([
169
+ job.extendLock(job.token, 10 * 60 * 1000).catch(err => {
170
+ logger.warn({ msg: 'Failed to extend job lock', account, exportId, err });
171
+ }),
172
+ Export.extendExpiry(account, exportId).catch(err => {
173
+ logger.warn({ msg: 'Failed to extend export expiry', account, exportId, err });
174
+ })
175
+ ]);
176
+ lastExtension = Date.now();
177
+ };
178
+ }
179
+
158
180
  async function indexMessages(job, exportData) {
159
181
  const { account, exportId } = job.data;
160
182
  const folders = JSON.parse(exportData.folders || '[]');
@@ -195,15 +217,10 @@ async function indexMessages(job, exportData) {
195
217
 
196
218
  let totalIndexed = 0;
197
219
  let truncated = false;
198
- let lastLockExtension = Date.now();
220
+ const maybeExtendLease = createLeaseExtender(job, account, exportId);
199
221
 
200
222
  for (let i = 0; i < foldersToProcess.length; i++) {
201
- if (Date.now() - lastLockExtension > LOCK_EXTENSION_INTERVAL) {
202
- await job.extendLock(job.token, 10 * 60 * 1000).catch(err => {
203
- logger.warn({ msg: 'Failed to extend job lock during indexing', account, exportId, err });
204
- });
205
- lastLockExtension = Date.now();
206
- }
223
+ await maybeExtendLease();
207
224
 
208
225
  if (await Export.isCancelled(account, exportId)) {
209
226
  const err = new Error('Export cancelled by user');
@@ -221,8 +238,6 @@ async function indexMessages(job, exportData) {
221
238
  const queued = await indexFolder(accountObject, account, exportId, folderPath, startDate, endDate, indexingStartTime, remaining);
222
239
  totalIndexed += queued;
223
240
 
224
- await Export.update(account, exportId, { foldersScanned: i + 1 });
225
-
226
241
  logger.trace({
227
242
  msg: 'Folder indexed',
228
243
  account,
@@ -266,6 +281,9 @@ async function indexMessages(job, exportData) {
266
281
  });
267
282
  }
268
283
 
284
+ // Count the folder as scanned whether or not it succeeded so progress reaches foldersTotal.
285
+ await Export.update(account, exportId, { foldersScanned: i + 1 });
286
+
269
287
  if (maxMessages && totalIndexed >= maxMessages) {
270
288
  truncated = true;
271
289
  logger.warn({
@@ -357,7 +375,9 @@ async function exportMessages(job, exportData) {
357
375
  const { filePath } = exportData;
358
376
  const includeAttachments = exportData.includeAttachments === '1';
359
377
  const textType = exportData.textType || '*';
360
- const maxBytes = Number(exportData.maxBytes) || 5 * 1024 * 1024;
378
+ const rawMaxBytes = Number(exportData.maxBytes);
379
+ // Preserve an explicit 0 ("unlimited" per the API contract); only fall back when unset/invalid.
380
+ const maxBytes = Number.isFinite(rawMaxBytes) ? rawMaxBytes : 5 * 1024 * 1024;
361
381
  const maxMessageSize = (await settings.get('exportMaxMessageSize')) || DEFAULT_EXPORT_MAX_MESSAGE_SIZE;
362
382
  const maxExportSize = Number(await settings.get('exportMaxSize')) || DEFAULT_EXPORT_MAX_SIZE;
363
383
  const isEncrypted = exportData.isEncrypted === '1';
@@ -419,14 +439,15 @@ async function exportMessages(job, exportData) {
419
439
  });
420
440
  }
421
441
 
422
- let lastScore = Number(exportData.lastProcessedScore) || 0;
423
442
  let processed = 0;
424
443
  let totalBytesWritten = 0;
425
444
  let processingError = null;
426
445
  let sizeLimitReached = false;
427
446
 
428
447
  let lastAccountCheck = Date.now();
429
- let lastLockExtension = Date.now();
448
+ // Refreshes the job lock and export key expiry on a fixed cadence; called from the main loop and
449
+ // from inside the rate-limit backoff so a batch stuck retrying does not let the lock lapse.
450
+ const maybeExtendLease = createLeaseExtender(job, account, exportId);
430
451
 
431
452
  const accountData = await accountObject.loadAccountData(account);
432
453
  const isApiAccount = await accountObject.isApiClient(accountData);
@@ -490,12 +511,7 @@ async function exportMessages(job, exportData) {
490
511
  break;
491
512
  }
492
513
 
493
- if (Date.now() - lastLockExtension > LOCK_EXTENSION_INTERVAL) {
494
- await job.extendLock(job.token, 10 * 60 * 1000).catch(err => {
495
- logger.warn({ msg: 'Failed to extend job lock', account, exportId, err });
496
- });
497
- lastLockExtension = Date.now();
498
- }
514
+ await maybeExtendLease();
499
515
 
500
516
  if (await Export.isCancelled(account, exportId)) {
501
517
  const err = new Error('Export cancelled by user');
@@ -513,7 +529,7 @@ async function exportMessages(job, exportData) {
513
529
  lastAccountCheck = Date.now();
514
530
  }
515
531
 
516
- const batch = await Export.getNextBatch(account, exportId, lastScore, BATCH_SIZE);
532
+ const batch = await Export.getNextBatch(account, exportId, BATCH_SIZE);
517
533
  if (batch.length === 0) {
518
534
  break;
519
535
  }
@@ -522,14 +538,12 @@ async function exportMessages(job, exportData) {
522
538
  for (const entry of batch) {
523
539
  if (includeAttachments && entry.size > maxMessageSize) {
524
540
  await Export.incrementSkipped(account, exportId);
525
- lastScore = entry.score;
526
541
  } else {
527
542
  entriesToFetch.push(entry);
528
543
  }
529
544
  }
530
545
 
531
546
  if (entriesToFetch.length === 0) {
532
- await Export.updateLastProcessedScore(account, exportId, lastScore);
533
547
  continue;
534
548
  }
535
549
 
@@ -573,7 +587,6 @@ async function exportMessages(job, exportData) {
573
587
  reason: err.message || err.code
574
588
  });
575
589
  await Export.incrementSkipped(account, exportId);
576
- lastScore = entry.score;
577
590
  } else if (isRateLimited && rateLimitRetry < MAX_RATE_LIMIT_RETRIES) {
578
591
  rateLimitedEntries.push(entry);
579
592
  } else {
@@ -584,7 +597,6 @@ async function exportMessages(job, exportData) {
584
597
  }
585
598
  } else if (result && result.data) {
586
599
  await processMessage(result.data, entry);
587
- lastScore = entry.score;
588
600
  if (maxExportSize && totalBytesWritten >= maxExportSize) {
589
601
  sizeLimitReached = true;
590
602
  break;
@@ -599,7 +611,6 @@ async function exportMessages(job, exportData) {
599
611
  reason: 'Message not found in batch results'
600
612
  });
601
613
  await Export.incrementSkipped(account, exportId);
602
- lastScore = entry.score;
603
614
  }
604
615
  }
605
616
 
@@ -620,6 +631,7 @@ async function exportMessages(job, exportData) {
620
631
  delayMs: Math.round(delay)
621
632
  });
622
633
  await new Promise(resolve => setTimeout(resolve, delay));
634
+ await maybeExtendLease();
623
635
  fetchBatch = rateLimitedEntries;
624
636
  } else {
625
637
  break;
@@ -672,7 +684,6 @@ async function exportMessages(job, exportData) {
672
684
  await processMessage(message, entry);
673
685
  if (maxExportSize && totalBytesWritten >= maxExportSize) {
674
686
  sizeLimitReached = true;
675
- lastScore = entry.score;
676
687
  break;
677
688
  }
678
689
  } else if (fetchError && isSkippableError(fetchError)) {
@@ -688,12 +699,9 @@ async function exportMessages(job, exportData) {
688
699
  } else if (fetchError) {
689
700
  throw fetchError;
690
701
  }
691
-
692
- lastScore = entry.score;
693
702
  }
694
703
  }
695
704
 
696
- await Export.updateLastProcessedScore(account, exportId, lastScore);
697
705
  logger.trace({ msg: 'Export batch processed', account, exportId, messagesExported: processed });
698
706
  }
699
707
  } catch (err) {
@@ -764,19 +772,26 @@ const exportWorker = new Worker(
764
772
 
765
773
  await Export.complete(account, exportId);
766
774
 
767
- const finalData = await redis.hgetall(`${REDIS_PREFIX}exp:${account}:${exportId}`);
775
+ // The export is complete and persisted. A failure while reading final stats or delivering
776
+ // the completion webhook must NOT fall through to the job catch below, which would delete
777
+ // the finished file and mark the export as failed.
778
+ try {
779
+ const finalData = await redis.hgetall(`${REDIS_PREFIX}exp:${account}:${exportId}`);
768
780
 
769
- await notify(account, EXPORT_COMPLETED_NOTIFY, {
770
- exportId,
771
- folders: JSON.parse(exportData.folders || '[]'),
772
- startDate: new Date(Number(exportData.startDate)).toISOString(),
773
- endDate: new Date(Number(exportData.endDate)).toISOString(),
774
- messagesExported: Number(finalData.messagesExported) || 0,
775
- messagesSkipped: Number(finalData.messagesSkipped) || 0,
776
- bytesWritten: Number(finalData.bytesWritten) || 0,
777
- duration: Date.now() - startTime,
778
- expiresAt: new Date(Number(finalData.expiresAt)).toISOString()
779
- });
781
+ await notify(account, EXPORT_COMPLETED_NOTIFY, {
782
+ exportId,
783
+ folders: JSON.parse(exportData.folders || '[]'),
784
+ startDate: new Date(Number(exportData.startDate)).toISOString(),
785
+ endDate: new Date(Number(exportData.endDate)).toISOString(),
786
+ messagesExported: Number(finalData.messagesExported) || 0,
787
+ messagesSkipped: Number(finalData.messagesSkipped) || 0,
788
+ bytesWritten: Number(finalData.bytesWritten) || 0,
789
+ duration: Date.now() - startTime,
790
+ expiresAt: new Date(Number(finalData.expiresAt)).toISOString()
791
+ });
792
+ } catch (notifyErr) {
793
+ logger.error({ msg: 'Failed to deliver export completion notification', account, exportId, err: notifyErr });
794
+ }
780
795
 
781
796
  logger.info({ msg: 'Export job completed', account, exportId, duration: Date.now() - startTime });
782
797
  } catch (err) {
@@ -795,14 +810,19 @@ const exportWorker = new Worker(
795
810
  }
796
811
 
797
812
  if (err.code !== 'AccountDeleted' && err.code !== 'AccountNotFound' && err.code !== 'ExportCancelled') {
798
- await notify(account, EXPORT_FAILED_NOTIFY, {
799
- exportId,
800
- error: err.message,
801
- errorCode: err.code,
802
- phase: exportData.phase || 'unknown',
803
- messagesExported: Number(exportData.messagesExported) || 0,
804
- messagesQueued: Number(exportData.messagesQueued) || 0
805
- });
813
+ try {
814
+ await notify(account, EXPORT_FAILED_NOTIFY, {
815
+ exportId,
816
+ error: err.message,
817
+ errorCode: err.code,
818
+ phase: exportData.phase || 'unknown',
819
+ messagesExported: Number(exportData.messagesExported) || 0,
820
+ messagesQueued: Number(exportData.messagesQueued) || 0
821
+ });
822
+ } catch (notifyErr) {
823
+ // Never let a failed notification replace the original error below.
824
+ logger.error({ msg: 'Failed to deliver export failure notification', account, exportId, err: notifyErr });
825
+ }
806
826
  }
807
827
 
808
828
  throw err;
@@ -876,7 +896,7 @@ function onCommand(command) {
876
896
  try {
877
897
  const activeExports = await redis.smembers(`${REDIS_PREFIX}exp:active`);
878
898
  for (const entry of activeExports) {
879
- const separatorIndex = entry.indexOf(':exp_');
899
+ const separatorIndex = entry.lastIndexOf(':exp_');
880
900
  if (separatorIndex === -1) continue;
881
901
  const entryAccount = entry.substring(0, separatorIndex);
882
902
  const entryExportId = entry.substring(separatorIndex + 1);
@@ -893,6 +913,22 @@ function onCommand(command) {
893
913
  5 * 60 * 1000
894
914
  ).unref();
895
915
 
916
+ // Periodically remove export files whose Redis state has already expired. Without this, files of
917
+ // expired exports would only be reclaimed on the next worker restart.
918
+ setInterval(
919
+ async () => {
920
+ try {
921
+ const cleaned = await Export.cleanup();
922
+ if (cleaned > 0) {
923
+ logger.info({ msg: 'Cleaned up orphaned export files', count: cleaned });
924
+ }
925
+ } catch (err) {
926
+ logger.error({ msg: 'Failed to clean up export files', err });
927
+ }
928
+ },
929
+ 60 * 60 * 1000
930
+ ).unref();
931
+
896
932
  parentPort.postMessage({ cmd: 'ready' });
897
933
  })();
898
934
 
@@ -919,10 +955,7 @@ parentPort.on('message', message => {
919
955
  }
920
956
 
921
957
  if (message && message.cmd === 'settings') {
922
- let d = message.data || {};
923
- if ('httpProxyEnabled' in d || 'httpProxyUrl' in d) {
924
- reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
925
- }
958
+ maybeReloadHttpProxyAgent(message.data);
926
959
  }
927
960
  });
928
961
 
package/workers/imap.js CHANGED
@@ -7,7 +7,7 @@ const logger = require('../lib/logger');
7
7
 
8
8
  const { REDIS_PREFIX } = require('../lib/consts');
9
9
 
10
- const { getDuration, getBoolean, emitChangeEvent, readEnvValue, hasEnvValue, threadStats, reloadHttpProxyAgent } = require('../lib/tools');
10
+ const { getDuration, getBoolean, emitChangeEvent, readEnvValue, hasEnvValue, threadStats, maybeReloadHttpProxyAgent } = require('../lib/tools');
11
11
 
12
12
  const Bugsnag = require('@bugsnag/js');
13
13
  if (readEnvValue('BUGSNAG_API_KEY')) {
@@ -39,7 +39,7 @@ const { BaseClient } = require('../lib/email-client/base-client');
39
39
  const { Account } = require('../lib/account');
40
40
  const { oauth2Apps, isApiBasedApp } = require('../lib/oauth2-apps');
41
41
  const { redis, notifyQueue, submitQueue, documentsQueue, getFlowProducer } = require('../lib/db');
42
- const { MessagePortWritable } = require('../lib/message-port-stream');
42
+ const { MessagePortWritable, pipeToMessagePort } = require('../lib/message-port-stream');
43
43
  const { getESClient } = require('../lib/document-store');
44
44
  const settings = require('../lib/settings');
45
45
  const msgpack = require('msgpack5')();
@@ -728,8 +728,6 @@ class ConnectionHandler {
728
728
  if (!accountData.connection) {
729
729
  throw NO_ACTIVE_HANDLER_RESP_ERR;
730
730
  }
731
- let stream = new MessagePortWritable(message.port);
732
-
733
731
  let source = await accountData.connection.getRawMessage(message.message);
734
732
  if (!source) {
735
733
  let err = new Error('Requested file not found');
@@ -737,11 +735,22 @@ class ConnectionHandler {
737
735
  throw err;
738
736
  }
739
737
 
738
+ // Build the cross-thread writable only after we know there is content to send.
739
+ // Constructing it earlier leaks the transferred MessagePort (and its message
740
+ // listener) whenever the lookup above throws (e.g. a 404).
741
+ let stream = new MessagePortWritable(message.port);
742
+
740
743
  setImmediate(() => {
741
744
  if (Buffer.isBuffer(source)) {
742
- stream.end(source);
745
+ // The consumer may have aborted and destroyed the writable (via a cancel
746
+ // message) before this runs; ending a destroyed stream would emit an
747
+ // unhandled 'error' and take down the worker. The streaming branch is
748
+ // safe because pipeline() tears down a destroyed destination cleanly.
749
+ if (!stream.destroyed) {
750
+ stream.end(source);
751
+ }
743
752
  } else {
744
- source.pipe(stream);
753
+ pipeToMessagePort(source, stream, accountData.connection.logger);
745
754
  }
746
755
  });
747
756
 
@@ -764,8 +773,6 @@ class ConnectionHandler {
764
773
  throw NO_ACTIVE_HANDLER_RESP_ERR;
765
774
  }
766
775
 
767
- let stream = new MessagePortWritable(message.port);
768
-
769
776
  let source = await accountData.connection.getAttachment(message.attachment);
770
777
  if (!source) {
771
778
  let err = new Error('Requested file not found');
@@ -773,11 +780,22 @@ class ConnectionHandler {
773
780
  throw err;
774
781
  }
775
782
 
783
+ // Build the cross-thread writable only after we know there is content to send.
784
+ // Constructing it earlier leaks the transferred MessagePort (and its message
785
+ // listener) whenever the lookup above throws (e.g. a 404).
786
+ let stream = new MessagePortWritable(message.port);
787
+
776
788
  setImmediate(() => {
777
789
  if (Buffer.isBuffer(source.data)) {
778
- stream.end(source.data);
790
+ // The consumer may have aborted and destroyed the writable (via a cancel
791
+ // message) before this runs; ending a destroyed stream would emit an
792
+ // unhandled 'error' and take down the worker. The streaming branch is
793
+ // safe because pipeline() tears down a destroyed destination cleanly.
794
+ if (!stream.destroyed) {
795
+ stream.end(source.data);
796
+ }
779
797
  } else {
780
- source.pipe(stream);
798
+ pipeToMessagePort(source, stream, accountData.connection.logger);
781
799
  }
782
800
  });
783
801
 
@@ -812,9 +830,7 @@ class ConnectionHandler {
812
830
 
813
831
  switch (message.cmd) {
814
832
  case 'settings':
815
- if (message.data && ('httpProxyEnabled' in message.data || 'httpProxyUrl' in message.data)) {
816
- reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
817
- }
833
+ maybeReloadHttpProxyAgent(message.data);
818
834
 
819
835
  if (message.data && message.data.logs) {
820
836
  for (let [account, accountObject] of this.accounts) {
package/workers/submit.js CHANGED
@@ -7,7 +7,7 @@ const config = require('@zone-eu/wild-config');
7
7
  const logger = require('../lib/logger');
8
8
 
9
9
  const { REDIS_PREFIX } = require('../lib/consts');
10
- const { getDuration, readEnvValue, threadStats, reloadHttpProxyAgent } = require('../lib/tools');
10
+ const { getDuration, readEnvValue, threadStats, maybeReloadHttpProxyAgent } = require('../lib/tools');
11
11
  const { webhooks: Webhooks } = require('../lib/webhooks');
12
12
  const settings = require('../lib/settings');
13
13
 
@@ -409,12 +409,24 @@ submitWorker.on('failed', async job => {
409
409
  logger.error({ msg: 'Failed to remove queue entry', account: job.data.account, queueId: job.data.queueId, messageId: job.data.messageId, err });
410
410
  }
411
411
  // report as failed
412
- await notify(job.data.account, EMAIL_FAILED_NOTIFY, {
413
- messageId: job.data.messageId,
414
- queueId: job.data.queueId,
415
- error: job.stacktrace && job.stacktrace[0] && job.stacktrace[0].split('\n').shift(),
416
- networkRouting: job.progress?.networkRouting
417
- });
412
+ try {
413
+ await notify(job.data.account, EMAIL_FAILED_NOTIFY, {
414
+ messageId: job.data.messageId,
415
+ queueId: job.data.queueId,
416
+ error: job.stacktrace && job.stacktrace[0] && job.stacktrace[0].split('\n').shift(),
417
+ networkRouting: job.progress?.networkRouting
418
+ });
419
+ } catch (notifyErr) {
420
+ // A failed webhook notification must not bubble out of this BullMQ
421
+ // event listener as an unhandled rejection and take down the worker.
422
+ logger.error({
423
+ msg: 'Failed to deliver submission failure notification',
424
+ account: job.data.account,
425
+ queueId: job.data.queueId,
426
+ messageId: job.data.messageId,
427
+ err: notifyErr
428
+ });
429
+ }
418
430
  }
419
431
  }
420
432
 
@@ -497,10 +509,7 @@ parentPort.on('message', message => {
497
509
  }
498
510
 
499
511
  if (message && message.cmd === 'settings') {
500
- let d = message.data || {};
501
- if ('httpProxyEnabled' in d || 'httpProxyUrl' in d) {
502
- reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
503
- }
512
+ maybeReloadHttpProxyAgent(message.data);
504
513
  }
505
514
  });
506
515
 
@@ -10,7 +10,8 @@ const { webhooks: Webhooks } = require('../lib/webhooks');
10
10
 
11
11
  const { GooglePubSub } = require('../lib/oauth/pubsub/google');
12
12
 
13
- const { readEnvValue, threadStats, getDuration, httpAgent, getServiceSecret, reloadHttpProxyAgent } = require('../lib/tools');
13
+ const { readEnvValue, threadStats, getDuration, httpAgent, getServiceSecret, maybeReloadHttpProxyAgent } = require('../lib/tools');
14
+ const { sendWebhookRequest } = require('../lib/webhook-request');
14
15
 
15
16
  const Bugsnag = require('@bugsnag/js');
16
17
  if (readEnvValue('BUGSNAG_API_KEY')) {
@@ -199,10 +200,7 @@ parentPort.on('message', message => {
199
200
  }
200
201
 
201
202
  if (message && message.cmd === 'settings') {
202
- let d = message.data || {};
203
- if ('httpProxyEnabled' in d || 'httpProxyUrl' in d) {
204
- reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
205
- }
203
+ maybeReloadHttpProxyAgent(message.data);
206
204
  }
207
205
  });
208
206
 
@@ -424,9 +422,9 @@ const notifyWorker = new Worker(
424
422
  headers['X-EE-Wh-Signature'] = hmac.digest('base64url');
425
423
 
426
424
  try {
427
- let res;
425
+ let status;
428
426
  try {
429
- res = await fetchCmd(parsed.toString(), {
427
+ status = await sendWebhookRequest(fetchCmd, parsed.toString(), {
430
428
  method: 'post',
431
429
  body,
432
430
  headers,
@@ -438,18 +436,6 @@ const notifyWorker = new Worker(
438
436
  throw err.cause || err;
439
437
  }
440
438
 
441
- if (!res.ok) {
442
- // Drain response body to release connection back to pool
443
- try {
444
- await res.text();
445
- } catch {
446
- // ignore drain errors
447
- }
448
- let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
449
- err.statusCode = res.status;
450
- throw err;
451
- }
452
-
453
439
  logger.trace({
454
440
  msg: 'Webhook posted',
455
441
  action: 'webhook',
@@ -460,7 +446,7 @@ const notifyWorker = new Worker(
460
446
  requestBodySize: body.length,
461
447
  accountWebhooks: !!accountWebhooks,
462
448
  event: job.name,
463
- status: res.status,
449
+ status,
464
450
  account: job.data.account,
465
451
  route: customRoute && customRoute.id
466
452
  });