emailengine-app 2.70.0 → 2.71.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/test.yml +73 -12
- package/.ncurc.js +3 -3
- package/CHANGELOG.md +18 -0
- package/Gruntfile.js +19 -23
- package/bin/emailengine.js +8 -1
- package/config/default.toml +5 -0
- package/config/test.toml +5 -0
- package/data/google-crawlers.json +1 -1
- package/getswagger.sh +4 -0
- package/lib/account.js +31 -25
- package/lib/api-routes/message-routes.js +125 -121
- package/lib/document-store.js +22 -1
- package/lib/email-client/base-client.js +3 -2
- package/lib/email-client/imap/mailbox.js +2 -2
- package/lib/email-client/notification-handler.js +2 -2
- package/lib/export.js +12 -0
- package/lib/feature-flags.js +6 -0
- package/lib/license-beacon.js +367 -0
- package/lib/logger.js +11 -1
- package/lib/routes-ui.js +2 -1
- package/lib/tools.js +26 -2
- package/lib/ui-routes/admin-config-routes.js +4 -3
- package/lib/ui-routes/document-store-routes.js +7 -1
- package/package.json +19 -16
- package/sbom.json +1 -1
- package/server.js +30 -8
- package/static/licenses.html +43 -123
- package/translations/de.mo +0 -0
- package/translations/de.po +154 -142
- package/translations/et.mo +0 -0
- package/translations/et.po +129 -131
- package/translations/fr.mo +0 -0
- package/translations/fr.po +133 -136
- package/translations/ja.mo +0 -0
- package/translations/ja.po +126 -129
- package/translations/messages.pot +37 -37
- package/translations/nl.mo +0 -0
- package/translations/nl.po +128 -130
- package/translations/pl.mo +0 -0
- package/translations/pl.po +125 -128
- package/views/dashboard.hbs +22 -0
- package/workers/api.js +22 -5
- package/workers/export.js +58 -43
package/workers/export.js
CHANGED
|
@@ -20,7 +20,7 @@ const {
|
|
|
20
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
|
-
const { Export, isTransientError, isSkippableError, isFolderMissingError } = require('../lib/export');
|
|
23
|
+
const { Export, isTransientError, isSkippableError, isFolderMissingError, isRetryableError } = require('../lib/export');
|
|
24
24
|
|
|
25
25
|
const { initSentry } = require('../lib/sentry');
|
|
26
26
|
initSentry('export');
|
|
@@ -192,10 +192,9 @@ async function indexMessages(job, exportData) {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const folderPath = foldersToProcess[i];
|
|
195
|
-
let
|
|
196
|
-
let lastError = null;
|
|
195
|
+
let attempt = 0;
|
|
197
196
|
|
|
198
|
-
while (
|
|
197
|
+
while (true) {
|
|
199
198
|
try {
|
|
200
199
|
const remaining = maxMessages ? maxMessages - totalIndexed : 0;
|
|
201
200
|
const queued = await indexFolder(accountObject, account, exportId, folderPath, startDate, endDate, indexingStartTime, remaining);
|
|
@@ -211,40 +210,43 @@ async function indexMessages(job, exportData) {
|
|
|
211
210
|
totalIndexed
|
|
212
211
|
});
|
|
213
212
|
|
|
214
|
-
lastError = null;
|
|
215
213
|
break;
|
|
216
214
|
} catch (err) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
215
|
+
attempt++;
|
|
216
|
+
// A folder that was deleted mid-export is handled inside indexFolder (treated as empty),
|
|
217
|
+
// so any error reaching here is a real failure. Only transient errors are worth retrying;
|
|
218
|
+
// a permanent error (deleted account, auth failure, unexpected server response) is not
|
|
219
|
+
// specific to this folder and would repeat for the rest, so fail the whole export. Silently
|
|
220
|
+
// skipping folders and marking an incomplete export "completed" hides data loss from the caller.
|
|
221
|
+
if (!isTransientError(err) || attempt >= FOLDER_INDEX_MAX_RETRIES) {
|
|
222
|
+
logger.error({
|
|
223
|
+
msg: 'Failed to index folder, failing export',
|
|
224
224
|
account,
|
|
225
225
|
exportId,
|
|
226
226
|
folder: folderPath,
|
|
227
|
-
|
|
228
|
-
delayMs: delay,
|
|
227
|
+
attempts: attempt,
|
|
229
228
|
err
|
|
230
229
|
});
|
|
231
|
-
|
|
230
|
+
err.message = `Failed to index folder "${folderPath}": ${err.message}`;
|
|
231
|
+
throw err;
|
|
232
232
|
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
233
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
234
|
+
const delay = FOLDER_INDEX_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
|
|
235
|
+
logger.warn({
|
|
236
|
+
msg: 'Folder indexing failed, retrying',
|
|
237
|
+
account,
|
|
238
|
+
exportId,
|
|
239
|
+
folder: folderPath,
|
|
240
|
+
attempt,
|
|
241
|
+
maxRetries: FOLDER_INDEX_MAX_RETRIES,
|
|
242
|
+
delayMs: delay,
|
|
243
|
+
err
|
|
244
|
+
});
|
|
245
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
246
|
+
}
|
|
245
247
|
}
|
|
246
248
|
|
|
247
|
-
//
|
|
249
|
+
// The folder indexed successfully (failures throw above); count it toward progress.
|
|
248
250
|
await Export.update(account, exportId, { foldersScanned: i + 1 });
|
|
249
251
|
|
|
250
252
|
if (maxMessages && totalIndexed >= maxMessages) {
|
|
@@ -420,14 +422,14 @@ async function exportMessages(job, exportData) {
|
|
|
420
422
|
|
|
421
423
|
let lastAccountCheck = Date.now();
|
|
422
424
|
// Refreshes the job lock and export key expiry on a fixed cadence; called from the main loop and
|
|
423
|
-
// from inside the
|
|
425
|
+
// from inside the retry backoff so a batch stuck retrying does not let the lock lapse.
|
|
424
426
|
const maybeExtendLease = createLeaseExtender(job, account, exportId);
|
|
425
427
|
|
|
426
428
|
const accountData = await accountObject.loadAccountData(account);
|
|
427
429
|
const isApiAccount = await accountObject.isApiClient(accountData);
|
|
428
430
|
const MESSAGE_FETCH_BATCH_SIZE = 10; // Batch size for parallel message fetching
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
+
const MAX_BATCH_RETRIES = 5; // Max retries for rate-limited or transient per-message errors within a batch
|
|
432
|
+
const BATCH_RETRY_BASE_DELAY = 5000; // Base delay for batch retry backoff (5 seconds)
|
|
431
433
|
|
|
432
434
|
async function processMessage(message, entry) {
|
|
433
435
|
message.path = entry.folder;
|
|
@@ -531,7 +533,7 @@ async function exportMessages(job, exportData) {
|
|
|
531
533
|
}
|
|
532
534
|
|
|
533
535
|
let fetchBatch = entriesToFetch.slice(i, i + MESSAGE_FETCH_BATCH_SIZE);
|
|
534
|
-
let
|
|
536
|
+
let batchRetry = 0;
|
|
535
537
|
|
|
536
538
|
while (fetchBatch.length > 0) {
|
|
537
539
|
const messageIds = fetchBatch.map(e => e.messageId);
|
|
@@ -542,14 +544,17 @@ async function exportMessages(job, exportData) {
|
|
|
542
544
|
resultMap.set(result.messageId, result);
|
|
543
545
|
}
|
|
544
546
|
|
|
545
|
-
const
|
|
547
|
+
const retryEntries = [];
|
|
546
548
|
|
|
547
549
|
for (const entry of fetchBatch) {
|
|
548
550
|
const result = resultMap.get(entry.messageId);
|
|
549
551
|
|
|
550
552
|
if (result && result.error) {
|
|
551
553
|
const err = result.error;
|
|
552
|
-
|
|
554
|
+
// A single transient blip (rate limit, dropped batch response, network/5xx)
|
|
555
|
+
// must not fail an entire multi-message export; only give up once the retry
|
|
556
|
+
// budget is exhausted. See isRetryableError for the full classification.
|
|
557
|
+
const isRetryable = isRetryableError(err);
|
|
553
558
|
|
|
554
559
|
if (isSkippableError(err)) {
|
|
555
560
|
logger.warn({
|
|
@@ -561,8 +566,8 @@ async function exportMessages(job, exportData) {
|
|
|
561
566
|
reason: err.message || err.code
|
|
562
567
|
});
|
|
563
568
|
await Export.incrementSkipped(account, exportId);
|
|
564
|
-
} else if (
|
|
565
|
-
|
|
569
|
+
} else if (isRetryable && batchRetry < MAX_BATCH_RETRIES) {
|
|
570
|
+
retryEntries.push(entry);
|
|
566
571
|
} else {
|
|
567
572
|
const error = new Error(err.message);
|
|
568
573
|
error.code = err.code;
|
|
@@ -592,21 +597,21 @@ async function exportMessages(job, exportData) {
|
|
|
592
597
|
break;
|
|
593
598
|
}
|
|
594
599
|
|
|
595
|
-
if (
|
|
596
|
-
|
|
597
|
-
const delay =
|
|
600
|
+
if (retryEntries.length > 0) {
|
|
601
|
+
batchRetry++;
|
|
602
|
+
const delay = BATCH_RETRY_BASE_DELAY * Math.pow(2, batchRetry - 1) + Math.random() * 1000;
|
|
598
603
|
logger.warn({
|
|
599
|
-
msg: '
|
|
604
|
+
msg: 'Retrying failed messages during export batch',
|
|
600
605
|
account,
|
|
601
606
|
exportId,
|
|
602
|
-
|
|
603
|
-
attempt:
|
|
604
|
-
maxAttempts:
|
|
607
|
+
retryCount: retryEntries.length,
|
|
608
|
+
attempt: batchRetry,
|
|
609
|
+
maxAttempts: MAX_BATCH_RETRIES,
|
|
605
610
|
delayMs: Math.round(delay)
|
|
606
611
|
});
|
|
607
612
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
608
613
|
await maybeExtendLease();
|
|
609
|
-
fetchBatch =
|
|
614
|
+
fetchBatch = retryEntries;
|
|
610
615
|
} else {
|
|
611
616
|
break;
|
|
612
617
|
}
|
|
@@ -807,6 +812,11 @@ const exportWorker = new Worker(
|
|
|
807
812
|
lockDuration: 10 * 60 * 1000,
|
|
808
813
|
stalledInterval: 2 * 60 * 1000,
|
|
809
814
|
maxStalledCount: 5,
|
|
815
|
+
// Do not start consuming jobs at construction. Startup recovery (markInterruptedAsFailed +
|
|
816
|
+
// cleanup) must finish first, otherwise the worker can pick up a queued export and start
|
|
817
|
+
// processing it while recovery concurrently marks that same export failed and deletes its
|
|
818
|
+
// file. run() is called from the startup IIFE once recovery completes.
|
|
819
|
+
autorun: false,
|
|
810
820
|
...queueConf
|
|
811
821
|
}
|
|
812
822
|
);
|
|
@@ -857,6 +867,11 @@ function onCommand(command) {
|
|
|
857
867
|
logger.error({ msg: 'Failed to clean up export files', err });
|
|
858
868
|
}
|
|
859
869
|
|
|
870
|
+
// Now that interrupted exports have been reconciled and orphaned files cleaned, start consuming
|
|
871
|
+
// jobs. Mirrors BullMQ's own autorun: a fatal run() failure is surfaced as an 'error' event,
|
|
872
|
+
// which (no listener) crashes the worker thread so the main process can restart it.
|
|
873
|
+
exportWorker.run().catch(error => exportWorker.emit('error', error));
|
|
874
|
+
|
|
860
875
|
setInterval(() => {
|
|
861
876
|
try {
|
|
862
877
|
parentPort.postMessage({ cmd: 'heartbeat' });
|