emailengine-app 2.70.0 → 2.72.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 (60) hide show
  1. package/.github/workflows/codeql.yml +3 -0
  2. package/.github/workflows/e2e.yml +56 -0
  3. package/.github/workflows/test.yml +81 -12
  4. package/.ncurc.js +20 -20
  5. package/CHANGELOG.md +25 -0
  6. package/Gruntfile.js +19 -23
  7. package/bin/emailengine.js +8 -1
  8. package/config/default.toml +5 -0
  9. package/config/e2e.toml +35 -0
  10. package/config/test.toml +5 -0
  11. package/data/google-crawlers.json +1 -1
  12. package/getswagger.sh +4 -0
  13. package/lib/account.js +31 -25
  14. package/lib/api-routes/message-routes.js +125 -121
  15. package/lib/auth-token.js +83 -0
  16. package/lib/delivery-error.js +62 -0
  17. package/lib/document-store.js +22 -1
  18. package/lib/email-client/base-client.js +3 -2
  19. package/lib/email-client/gmail-client.js +33 -1
  20. package/lib/email-client/imap/mailbox.js +2 -2
  21. package/lib/email-client/notification-handler.js +2 -2
  22. package/lib/export.js +12 -0
  23. package/lib/feature-flags.js +6 -0
  24. package/lib/imap-proxy-auth.js +81 -0
  25. package/lib/imapproxy/imap-server.js +8 -103
  26. package/lib/license-beacon.js +367 -0
  27. package/lib/logger.js +11 -1
  28. package/lib/oauth/gmail.js +3 -0
  29. package/lib/oauth/outlook.js +3 -0
  30. package/lib/oauth2-apps.js +100 -11
  31. package/lib/routes-ui.js +2 -1
  32. package/lib/smtp-auth.js +70 -0
  33. package/lib/sub-script.js +8 -2
  34. package/lib/tools.js +26 -2
  35. package/lib/ui-routes/admin-config-routes.js +4 -3
  36. package/lib/ui-routes/document-store-routes.js +7 -1
  37. package/package.json +30 -24
  38. package/playwright.config.js +45 -0
  39. package/sbom.json +1 -1
  40. package/server.js +30 -8
  41. package/static/licenses.html +108 -128
  42. package/test-coverage-plan.md +233 -0
  43. package/translations/de.mo +0 -0
  44. package/translations/de.po +154 -142
  45. package/translations/et.mo +0 -0
  46. package/translations/et.po +129 -131
  47. package/translations/fr.mo +0 -0
  48. package/translations/fr.po +133 -136
  49. package/translations/ja.mo +0 -0
  50. package/translations/ja.po +126 -129
  51. package/translations/messages.pot +37 -37
  52. package/translations/nl.mo +0 -0
  53. package/translations/nl.po +128 -130
  54. package/translations/pl.mo +0 -0
  55. package/translations/pl.po +125 -128
  56. package/views/dashboard.hbs +22 -0
  57. package/workers/api.js +22 -5
  58. package/workers/export.js +58 -43
  59. package/workers/smtp.js +5 -85
  60. package/workers/submit.js +2 -12
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' });
package/workers/smtp.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 { getDuration, emitChangeEvent, readEnvValue, threadStats, loadTlsConfig, getByteSize } = require('../lib/tools');
10
- const { matchIp } = require('../lib/utils/network');
10
+ const { createSmtpAuthHandler } = require('../lib/smtp-auth');
11
11
 
12
12
  const { initSentry } = require('../lib/sentry');
13
13
  initSentry('smtp');
@@ -20,7 +20,6 @@ const getSecret = require('../lib/get-secret');
20
20
  const { Splitter, Joiner } = require('@zone-eu/mailsplit');
21
21
  const { HeadersRewriter } = require('../lib/headers-rewriter');
22
22
  const settings = require('../lib/settings');
23
- const tokens = require('../lib/tokens');
24
23
 
25
24
  const { encrypt, decrypt } = require('../lib/encrypt');
26
25
  const { Certs } = require('@postalsys/certs');
@@ -109,89 +108,10 @@ for (let level of ['trace', 'debug', 'info', 'warn', 'error', 'fatal']) {
109
108
  };
110
109
  }
111
110
 
112
- async function onAuth(auth, session) {
113
- if (!session.eeAuthEnabled) {
114
- throw new Error('Authentication not enabled');
115
- }
116
-
117
- let account = auth.username;
118
-
119
- let smtpPassword = await settings.get('smtpServerPassword');
120
- let authPass = false;
121
-
122
- if (!smtpPassword || auth.password !== smtpPassword) {
123
- if (/^[0-9a-f]{64}$/i.test(auth.password)) {
124
- // fallback to tokens
125
- let tokenData;
126
- try {
127
- tokenData = await tokens.get(auth.password, false, { log: true, remoteAddress: session.remoteAddress });
128
- } catch (err) {
129
- // ignore?
130
- }
131
-
132
- if (tokenData) {
133
- if (tokenData.account && tokenData.account !== auth.username) {
134
- throw new Error('Access denied, invalid username');
135
- }
136
-
137
- if (tokenData.scopes && !tokenData.scopes.includes('smtp') && !tokenData.scopes.includes('*')) {
138
- logger.error({
139
- msg: 'Trying to use invalid scope for a token',
140
- tokenAccount: tokenData.account,
141
- tokenId: tokenData.id,
142
- account,
143
- requestedScope: 'smtp',
144
- scopes: tokenData.scopes
145
- });
146
-
147
- throw new Error('Access denied, invalid scope');
148
- }
149
-
150
- if (tokenData.restrictions && tokenData.restrictions.addresses && !matchIp(session.remoteAddress, tokenData.restrictions.addresses)) {
151
- logger.error({
152
- msg: 'Trying to use invalid IP for a token',
153
- tokenAccount: tokenData.account,
154
- tokenId: tokenData.id,
155
- account,
156
- remoteAddress: session.remoteAddress,
157
- addressAllowlist: tokenData.restrictions.addresses
158
- });
159
-
160
- throw new Error('Access denied, traffic not accepted from this IP');
161
- }
162
-
163
- authPass = true;
164
- }
165
- }
166
-
167
- if (!authPass) {
168
- throw new Error('Failed to authenticate user');
169
- }
170
- }
171
-
172
- let accountObject = new Account({ account, redis, call, secret: await getSecret() });
173
- let accountData;
174
- try {
175
- accountData = await accountObject.loadAccountData();
176
- } catch (err) {
177
- let respErr = new Error('Failed to authenticate user');
178
-
179
- if (!err.output || err.output.statusCode !== 404) {
180
- // only log non-obvious errors
181
- logger.error({ msg: 'Failed to load account data', account: auth.username, err });
182
- respErr.statusCode = 454;
183
- }
184
-
185
- throw respErr;
186
- }
187
-
188
- if (!accountData) {
189
- throw new Error('Failed to authenticate user');
190
- }
191
-
192
- ACCOUNT_CACHE.set(session, accountObject);
193
- return { user: accountData.account };
194
- }
111
+ // Authentication logic lives in lib/smtp-auth.js so it can be unit tested
112
+ // without booting this worker. The shared ACCOUNT_CACHE and call() are injected
113
+ // so onAuth caches the Account for later processing steps.
114
+ const onAuth = createSmtpAuthHandler({ accountCache: ACCOUNT_CACHE, call });
195
115
 
196
116
  function processMessage(stream, session, meta) {
197
117
  meta = meta || {};
package/workers/submit.js CHANGED
@@ -42,15 +42,7 @@ const SUBMIT_QC = (readEnvValue('EENGINE_SUBMIT_QC') && Number(readEnvValue('EEN
42
42
 
43
43
  const SUBMIT_DELAY = getDuration(readEnvValue('EENGINE_SUBMIT_DELAY') || config.submitDelay) || null;
44
44
 
45
- const NON_RETRYABLE_CODES = new Set([
46
- 'EAUTH', // authentication failed
47
- 'ENOAUTH', // no credentials provided
48
- 'EOAUTH2', // OAuth2 token failure
49
- 'ETLS', // TLS handshake failed
50
- 'EENVELOPE', // invalid sender/recipients
51
- 'EMESSAGE', // message content error
52
- 'EPROTOCOL' // SMTP protocol mismatch
53
- ]);
45
+ const { shouldDiscardJob } = require('../lib/delivery-error');
54
46
 
55
47
  let callQueue = new Map();
56
48
  let mids = 0;
@@ -288,9 +280,7 @@ const submitWorker = new Worker(
288
280
  // ignore
289
281
  }
290
282
 
291
- const isPermanentSmtp = err.statusCode >= 500 && err.statusCode !== 503;
292
- const isPermanentCode = NON_RETRYABLE_CODES.has(err.code);
293
- if ((isPermanentSmtp || isPermanentCode) && job.attemptsMade < job.opts.attempts) {
283
+ if (shouldDiscardJob(err, job)) {
294
284
  try {
295
285
  // do not retry after 5xx error (except 503 which is transient)
296
286
  await job.discard();