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.
Files changed (43) hide show
  1. package/.github/workflows/test.yml +73 -12
  2. package/.ncurc.js +3 -3
  3. package/CHANGELOG.md +18 -0
  4. package/Gruntfile.js +19 -23
  5. package/bin/emailengine.js +8 -1
  6. package/config/default.toml +5 -0
  7. package/config/test.toml +5 -0
  8. package/data/google-crawlers.json +1 -1
  9. package/getswagger.sh +4 -0
  10. package/lib/account.js +31 -25
  11. package/lib/api-routes/message-routes.js +125 -121
  12. package/lib/document-store.js +22 -1
  13. package/lib/email-client/base-client.js +3 -2
  14. package/lib/email-client/imap/mailbox.js +2 -2
  15. package/lib/email-client/notification-handler.js +2 -2
  16. package/lib/export.js +12 -0
  17. package/lib/feature-flags.js +6 -0
  18. package/lib/license-beacon.js +367 -0
  19. package/lib/logger.js +11 -1
  20. package/lib/routes-ui.js +2 -1
  21. package/lib/tools.js +26 -2
  22. package/lib/ui-routes/admin-config-routes.js +4 -3
  23. package/lib/ui-routes/document-store-routes.js +7 -1
  24. package/package.json +19 -16
  25. package/sbom.json +1 -1
  26. package/server.js +30 -8
  27. package/static/licenses.html +43 -123
  28. package/translations/de.mo +0 -0
  29. package/translations/de.po +154 -142
  30. package/translations/et.mo +0 -0
  31. package/translations/et.po +129 -131
  32. package/translations/fr.mo +0 -0
  33. package/translations/fr.po +133 -136
  34. package/translations/ja.mo +0 -0
  35. package/translations/ja.po +126 -129
  36. package/translations/messages.pot +37 -37
  37. package/translations/nl.mo +0 -0
  38. package/translations/nl.po +128 -130
  39. package/translations/pl.mo +0 -0
  40. package/translations/pl.po +125 -128
  41. package/views/dashboard.hbs +22 -0
  42. package/workers/api.js +22 -5
  43. 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 retries = FOLDER_INDEX_MAX_RETRIES;
196
- let lastError = null;
195
+ let attempt = 0;
197
196
 
198
- while (retries > 0) {
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
- lastError = err;
218
- retries--;
219
- if (retries > 0) {
220
- const attemptNumber = FOLDER_INDEX_MAX_RETRIES - retries;
221
- const delay = FOLDER_INDEX_RETRY_DELAY_MS * Math.pow(2, attemptNumber - 1);
222
- logger.warn({
223
- msg: 'Folder indexing failed, retrying',
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
- retriesLeft: retries,
228
- delayMs: delay,
227
+ attempts: attempt,
229
228
  err
230
229
  });
231
- await new Promise(resolve => setTimeout(resolve, delay));
230
+ err.message = `Failed to index folder "${folderPath}": ${err.message}`;
231
+ throw err;
232
232
  }
233
- }
234
- }
235
233
 
236
- if (lastError) {
237
- logger.warn({
238
- msg: 'Failed to index folder after retries',
239
- account,
240
- exportId,
241
- folder: folderPath,
242
- maxRetries: FOLDER_INDEX_MAX_RETRIES,
243
- err: lastError
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
- // Count the folder as scanned whether or not it succeeded so progress reaches foldersTotal.
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 rate-limit backoff so a batch stuck retrying does not let the lock lapse.
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 MAX_RATE_LIMIT_RETRIES = 5; // Max retries for rate-limited messages
430
- const RATE_LIMIT_BASE_DELAY = 5000; // Base delay for rate limit backoff (5 seconds)
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 rateLimitRetry = 0;
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 rateLimitedEntries = [];
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
- const isRateLimited = err.statusCode === 429 || err.code === 'rateLimitExceeded' || err.code === 'userRateLimitExceeded';
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 (isRateLimited && rateLimitRetry < MAX_RATE_LIMIT_RETRIES) {
565
- rateLimitedEntries.push(entry);
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 (rateLimitedEntries.length > 0) {
596
- rateLimitRetry++;
597
- const delay = RATE_LIMIT_BASE_DELAY * Math.pow(2, rateLimitRetry - 1) + Math.random() * 1000;
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: 'Rate limited during export, retrying batch',
604
+ msg: 'Retrying failed messages during export batch',
600
605
  account,
601
606
  exportId,
602
- rateLimitedCount: rateLimitedEntries.length,
603
- attempt: rateLimitRetry,
604
- maxAttempts: MAX_RATE_LIMIT_RETRIES,
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 = rateLimitedEntries;
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' });