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.
- package/.github/workflows/codeql.yml +3 -0
- package/.github/workflows/e2e.yml +56 -0
- package/.github/workflows/test.yml +81 -12
- package/.ncurc.js +20 -20
- package/CHANGELOG.md +25 -0
- package/Gruntfile.js +19 -23
- package/bin/emailengine.js +8 -1
- package/config/default.toml +5 -0
- package/config/e2e.toml +35 -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/auth-token.js +83 -0
- package/lib/delivery-error.js +62 -0
- package/lib/document-store.js +22 -1
- package/lib/email-client/base-client.js +3 -2
- package/lib/email-client/gmail-client.js +33 -1
- 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/imap-proxy-auth.js +81 -0
- package/lib/imapproxy/imap-server.js +8 -103
- package/lib/license-beacon.js +367 -0
- package/lib/logger.js +11 -1
- package/lib/oauth/gmail.js +3 -0
- package/lib/oauth/outlook.js +3 -0
- package/lib/oauth2-apps.js +100 -11
- package/lib/routes-ui.js +2 -1
- package/lib/smtp-auth.js +70 -0
- package/lib/sub-script.js +8 -2
- 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 +30 -24
- package/playwright.config.js +45 -0
- package/sbom.json +1 -1
- package/server.js +30 -8
- package/static/licenses.html +108 -128
- package/test-coverage-plan.md +233 -0
- 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/smtp.js +5 -85
- 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
|
|
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' });
|
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 {
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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();
|