emailengine-app 2.68.1 → 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.
- package/.github/workflows/deploy.yml +2 -0
- package/.github/workflows/release.yaml +4 -0
- package/CHANGELOG.md +40 -0
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +7 -1
- package/lib/account.js +62 -25
- package/lib/api-routes/account-routes.js +493 -75
- package/lib/api-routes/blocklist-routes.js +337 -0
- package/lib/api-routes/delivery-test-routes.js +321 -0
- package/lib/api-routes/export-routes.js +1 -12
- package/lib/api-routes/gateway-routes.js +376 -0
- package/lib/api-routes/license-routes.js +142 -0
- package/lib/api-routes/mailbox-routes.js +318 -0
- package/lib/api-routes/message-routes.js +21 -129
- package/lib/api-routes/oauth2-app-routes.js +631 -0
- package/lib/api-routes/outbox-routes.js +173 -0
- package/lib/api-routes/pubsub-routes.js +98 -0
- package/lib/api-routes/route-helpers.js +45 -0
- package/lib/api-routes/settings-routes.js +331 -0
- package/lib/api-routes/stats-routes.js +77 -0
- package/lib/api-routes/submit-routes.js +472 -0
- package/lib/api-routes/template-routes.js +7 -55
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +152 -0
- package/lib/email-client/gmail-client.js +14 -0
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -12
- package/lib/email-client/imap/sync-operations.js +130 -2
- package/lib/email-client/imap-client.js +116 -58
- package/lib/email-client/outlook-client.js +85 -13
- package/lib/export.js +60 -19
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
- package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -23
- package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/message-port-stream.js +113 -16
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +26 -1
- package/lib/tools.js +68 -0
- package/lib/ui-routes/account-routes.js +40 -210
- package/lib/ui-routes/admin-config-routes.js +913 -487
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
- package/lib/ui-routes/route-helpers.js +316 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +234 -0
- package/lib/webhook-request.js +36 -0
- package/package.json +8 -8
- package/sbom.json +1 -1
- package/server.js +214 -16
- package/static/licenses.html +12 -12
- package/translations/messages.pot +129 -149
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +198 -4401
- package/workers/export.js +87 -54
- package/workers/imap.js +29 -13
- package/workers/submit.js +20 -11
- 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,
|
|
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
|
-
|
|
220
|
+
const maybeExtendLease = createLeaseExtender(job, account, exportId);
|
|
199
221
|
|
|
200
222
|
for (let i = 0; i < foldersToProcess.length; i++) {
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
|
package/workers/webhooks.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
425
|
+
let status;
|
|
428
426
|
try {
|
|
429
|
-
|
|
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
|
|
449
|
+
status,
|
|
464
450
|
account: job.data.account,
|
|
465
451
|
route: customRoute && customRoute.id
|
|
466
452
|
});
|