emailengine-app 2.66.0 → 2.67.1
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 +1 -1
- package/.github/workflows/release.yaml +2 -2
- package/.github/workflows/test.yml +2 -2
- package/.ncurc.js +4 -1
- package/CHANGELOG.md +25 -0
- package/data/google-crawlers.json +1 -1
- package/lib/consts.js +2 -0
- package/lib/email-client/imap/mailbox.js +10 -2
- package/lib/email-client/imap/sync-operations.js +31 -6
- package/lib/email-client/imap-client.js +19 -3
- package/lib/email-client/outlook-client.js +154 -0
- package/lib/passkeys.js +56 -15
- package/lib/routes-ui.js +34 -25
- package/package.json +21 -21
- package/sbom.json +1 -1
- package/static/licenses.html +85 -144
- package/translations/de.mo +0 -0
- package/translations/de.po +98 -106
- package/translations/en.mo +0 -0
- package/translations/en.po +50 -46
- package/translations/et.mo +0 -0
- package/translations/et.po +67 -64
- package/translations/fr.mo +0 -0
- package/translations/fr.po +74 -71
- package/translations/ja.mo +0 -0
- package/translations/ja.po +64 -60
- package/translations/messages.pot +55 -51
- package/translations/nl.mo +0 -0
- package/translations/nl.po +66 -62
- package/translations/pl.mo +0 -0
- package/translations/pl.po +74 -71
- package/views/oauth-scope-error.hbs +10 -1
- package/workers/api.js +28 -2
- package/workers/imap.js +4 -0
|
@@ -32,7 +32,7 @@ jobs:
|
|
|
32
32
|
# a new release is created:
|
|
33
33
|
if: ${{ steps.release.outputs.release_created }}
|
|
34
34
|
|
|
35
|
-
- uses: actions/setup-node@
|
|
35
|
+
- uses: actions/setup-node@v6
|
|
36
36
|
with:
|
|
37
37
|
node-version: 24
|
|
38
38
|
registry-url: "https://registry.npmjs.org"
|
|
@@ -51,7 +51,7 @@ jobs:
|
|
|
51
51
|
id-token: write
|
|
52
52
|
steps:
|
|
53
53
|
- uses: actions/checkout@v6
|
|
54
|
-
- uses: actions/setup-node@
|
|
54
|
+
- uses: actions/setup-node@v6
|
|
55
55
|
with:
|
|
56
56
|
node-version: 24
|
|
57
57
|
registry-url: "https://registry.npmjs.org"
|
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
- uses: actions/checkout@v6
|
|
18
18
|
|
|
19
19
|
- name: Use Node.js 24
|
|
20
|
-
uses: actions/setup-node@
|
|
20
|
+
uses: actions/setup-node@v6
|
|
21
21
|
with:
|
|
22
22
|
node-version: 24
|
|
23
23
|
- run: npm install
|
|
@@ -51,7 +51,7 @@ jobs:
|
|
|
51
51
|
steps:
|
|
52
52
|
- uses: actions/checkout@v6
|
|
53
53
|
- name: Use Node.js ${{ matrix.node }}
|
|
54
|
-
uses: actions/setup-node@
|
|
54
|
+
uses: actions/setup-node@v6
|
|
55
55
|
with:
|
|
56
56
|
node-version: ${{ matrix.node }}
|
|
57
57
|
- name: Setup Redis CLI
|
package/.ncurc.js
CHANGED
|
@@ -22,6 +22,9 @@ module.exports = {
|
|
|
22
22
|
'joi',
|
|
23
23
|
|
|
24
24
|
// @asamuzakjp/css-color >=4.1.2 pulls in @csstools/* v4 which are pure ESM and break pkg bundling
|
|
25
|
-
'@asamuzakjp/css-color'
|
|
25
|
+
'@asamuzakjp/css-color',
|
|
26
|
+
|
|
27
|
+
// undici >=8.0.0 requires Node >=22.19.0; pin to last Node 20-compatible release
|
|
28
|
+
'undici'
|
|
26
29
|
]
|
|
27
30
|
};
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.67.1](https://github.com/postalsys/emailengine/compare/v2.67.0...v2.67.1) (2026-04-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* detect IMAP drift that landed during an in-progress sync ([8fece8a](https://github.com/postalsys/emailengine/commit/8fece8ad6f3acb5e7391da7a9740279099f62177))
|
|
9
|
+
* improve translation quality across 6 languages ([d7d338e](https://github.com/postalsys/emailengine/commit/d7d338e761152909d4c2ca4732fafb60529413ab))
|
|
10
|
+
* pin undici to 7.25.0 for Node 20 compatibility ([3fa80db](https://github.com/postalsys/emailengine/commit/3fa80db4d64d6fe089fa3a1a0e700a188aeb74f7))
|
|
11
|
+
|
|
12
|
+
## [2.67.0](https://github.com/postalsys/emailengine/compare/v2.66.0...v2.67.0) (2026-03-31)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* handle MS Graph 'missed' lifecycle event for Outlook notification recovery ([d55f48c](https://github.com/postalsys/emailengine/commit/d55f48c9a183f9e0108801563b1085c176dbeb06))
|
|
18
|
+
* show human-readable missing scopes on OAuth scope error page ([4ee2365](https://github.com/postalsys/emailengine/commit/4ee2365449e3cbece6f4a83d540ae63c18464535))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* add IMAP fetch retry timeout and harden passkey registration ([3a1b61b](https://github.com/postalsys/emailengine/commit/3a1b61b00e916c308cae5e7493a2207164d9d45f))
|
|
24
|
+
* close command client proactively after subconnection setup ([02d3687](https://github.com/postalsys/emailengine/commit/02d36874d8533beabe5ad0fba978851b3791cbb5))
|
|
25
|
+
* correct translation errors and remove EmailEngine branding from OAuth scope error ([9284ddd](https://github.com/postalsys/emailengine/commit/9284ddd1e28e40fd24abeea7296636b21c4b3878))
|
|
26
|
+
* use STATUS instead of NOOP as keepalive for 163.com IMAP ([3b3516f](https://github.com/postalsys/emailengine/commit/3b3516feb14e64bab5ac586378237f2160cdf010))
|
|
27
|
+
|
|
3
28
|
## [2.66.0](https://github.com/postalsys/emailengine/compare/v2.65.0...v2.66.0) (2026-03-29)
|
|
4
29
|
|
|
5
30
|
|
package/lib/consts.js
CHANGED
|
@@ -193,6 +193,8 @@ module.exports = {
|
|
|
193
193
|
FETCH_RETRY_INTERVAL: 1000,
|
|
194
194
|
FETCH_RETRY_EXPONENTIAL: 2,
|
|
195
195
|
FETCH_RETRY_MAX: 60 * 1000,
|
|
196
|
+
FETCH_RETRY_MAX_TIME: 24 * 60 * 60 * 1000, // 1 day maximum retry duration
|
|
197
|
+
FETCH_RETRY_MIN_ATTEMPTS: 20, // minimum attempts before time limit applies
|
|
196
198
|
DEFAULT_FETCH_BATCH_SIZE: 1000,
|
|
197
199
|
|
|
198
200
|
// MS Graph webhook subscription settings
|
|
@@ -706,12 +706,20 @@ class Mailbox {
|
|
|
706
706
|
|
|
707
707
|
/**
|
|
708
708
|
* Checks if partial sync should run after EXISTS event
|
|
709
|
-
* @returns {Boolean} True if
|
|
709
|
+
* @returns {Boolean} True if any of messages, uidNext or highestModseq drift from stored state
|
|
710
710
|
*/
|
|
711
711
|
async shouldRunPartialSyncAfterExists() {
|
|
712
712
|
let storedStatus = await this.getStoredStatus();
|
|
713
713
|
let mailboxStatus = this.getMailboxStatus();
|
|
714
|
-
|
|
714
|
+
// Count-only comparison misses balanced churn (an arrival paired with an
|
|
715
|
+
// expunge in the same window) and does not work at all in 'fast' indexer
|
|
716
|
+
// mode where onExpunge never updates the stored count. uidNext and
|
|
717
|
+
// highestModseq catch those cases.
|
|
718
|
+
return (
|
|
719
|
+
mailboxStatus.messages !== storedStatus.messages ||
|
|
720
|
+
mailboxStatus.uidNext !== storedStatus.uidNext ||
|
|
721
|
+
mailboxStatus.highestModseq !== storedStatus.highestModseq
|
|
722
|
+
);
|
|
715
723
|
}
|
|
716
724
|
|
|
717
725
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const { compareExisting, calculateFetchBackoff, readEnvValue } = require('../../tools');
|
|
5
5
|
const config = require('@zone-eu/wild-config');
|
|
6
|
-
const { DEFAULT_FETCH_BATCH_SIZE } = require('../../consts');
|
|
6
|
+
const { DEFAULT_FETCH_BATCH_SIZE, FETCH_RETRY_MAX_TIME, FETCH_RETRY_MIN_ATTEMPTS } = require('../../consts');
|
|
7
7
|
|
|
8
8
|
// Configurable batch size for fetching messages (default: 250)
|
|
9
9
|
const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || config.service.fetchBatchSize) || DEFAULT_FETCH_BATCH_SIZE;
|
|
@@ -11,6 +11,15 @@ const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || conf
|
|
|
11
11
|
// Do not check for flag updates using full sync more often than this value (30 minutes)
|
|
12
12
|
const FULL_SYNC_DELAY = 30 * 60 * 1000;
|
|
13
13
|
|
|
14
|
+
function checkFetchRetryTimeout(retryCount, startTime) {
|
|
15
|
+
if (retryCount >= FETCH_RETRY_MIN_ATTEMPTS && Date.now() - startTime > FETCH_RETRY_MAX_TIME) {
|
|
16
|
+
const err = new Error('FETCH retry time limit exceeded');
|
|
17
|
+
err.code = 'FetchRetryTimeout';
|
|
18
|
+
err.statusCode = 504;
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Calculates the next range of sequence numbers to fetch based on the last fetched range
|
|
16
25
|
* @param {Number} totalMessages - Total number of messages in the mailbox
|
|
@@ -234,6 +243,7 @@ class SyncOperations {
|
|
|
234
243
|
// Fetch messages with retry logic
|
|
235
244
|
let fetchCompleted = false;
|
|
236
245
|
let fetchRetryCount = 0;
|
|
246
|
+
let fetchStartTime = Date.now();
|
|
237
247
|
|
|
238
248
|
while (!fetchCompleted) {
|
|
239
249
|
try {
|
|
@@ -330,11 +340,15 @@ class SyncOperations {
|
|
|
330
340
|
connErr.statusCode = 503;
|
|
331
341
|
throw connErr;
|
|
332
342
|
}
|
|
343
|
+
|
|
344
|
+
checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
|
|
333
345
|
}
|
|
334
346
|
}
|
|
335
347
|
}
|
|
336
348
|
|
|
337
|
-
|
|
349
|
+
// Persist the pre-sync snapshot so any arrivals that landed
|
|
350
|
+
// during this FETCH are still detected as drift on the next trigger.
|
|
351
|
+
await this.mailbox.updateStoredStatus(mailboxStatus);
|
|
338
352
|
|
|
339
353
|
await this.mailbox.publishSyncedEvents(storedStatus);
|
|
340
354
|
} finally {
|
|
@@ -383,6 +397,7 @@ class SyncOperations {
|
|
|
383
397
|
// only fetch messages if there are some
|
|
384
398
|
let fetchCompleted = false;
|
|
385
399
|
let fetchRetryCount = 0;
|
|
400
|
+
let fetchStartTime = Date.now();
|
|
386
401
|
while (!fetchCompleted) {
|
|
387
402
|
// Get fresh imapClient reference inside retry loop
|
|
388
403
|
let imapClient = this.connection.imapClient;
|
|
@@ -483,11 +498,15 @@ class SyncOperations {
|
|
|
483
498
|
connErr.statusCode = 503;
|
|
484
499
|
throw connErr;
|
|
485
500
|
}
|
|
501
|
+
|
|
502
|
+
checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
|
|
486
503
|
}
|
|
487
504
|
}
|
|
488
505
|
}
|
|
489
506
|
|
|
490
|
-
|
|
507
|
+
// Persist the pre-sync snapshot so any arrivals that landed
|
|
508
|
+
// during this FETCH are still detected as drift on the next trigger.
|
|
509
|
+
await this.mailbox.updateStoredStatus(mailboxStatus);
|
|
491
510
|
|
|
492
511
|
await this.mailbox.publishSyncedEvents(storedStatus);
|
|
493
512
|
} finally {
|
|
@@ -553,6 +572,8 @@ class SyncOperations {
|
|
|
553
572
|
let range = false;
|
|
554
573
|
let lastHighestUid = 0;
|
|
555
574
|
let batchNumber = 0;
|
|
575
|
+
let fetchStartTime = Date.now();
|
|
576
|
+
let totalFetchRetryCount = 0;
|
|
556
577
|
// process messages in batches
|
|
557
578
|
while ((range = getFetchRange(mailboxStatus.messages, range))) {
|
|
558
579
|
batchNumber++;
|
|
@@ -738,6 +759,7 @@ class SyncOperations {
|
|
|
738
759
|
}
|
|
739
760
|
|
|
740
761
|
// Retry with exponential backoff
|
|
762
|
+
totalFetchRetryCount++;
|
|
741
763
|
const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
|
|
742
764
|
this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, loopId, batchNumber });
|
|
743
765
|
await new Promise(r => setTimeout(r, fetchRetryDelay));
|
|
@@ -776,6 +798,8 @@ class SyncOperations {
|
|
|
776
798
|
newMessages: mailboxStatus.messages,
|
|
777
799
|
range
|
|
778
800
|
});
|
|
801
|
+
|
|
802
|
+
checkFetchRetryTimeout(totalFetchRetryCount, fetchStartTime);
|
|
779
803
|
}
|
|
780
804
|
}
|
|
781
805
|
}
|
|
@@ -806,9 +830,10 @@ class SyncOperations {
|
|
|
806
830
|
}
|
|
807
831
|
}
|
|
808
832
|
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
833
|
+
// Persist the pre-sync snapshot (plus the full-sync timestamp) so
|
|
834
|
+
// any arrivals that landed during this FETCH are still detected as
|
|
835
|
+
// drift on the next trigger.
|
|
836
|
+
let status = Object.assign({}, mailboxStatus, { lastFullSync: new Date() });
|
|
812
837
|
|
|
813
838
|
await this.mailbox.updateStoredStatus(status);
|
|
814
839
|
let storedStatus = await this.mailbox.getStoredStatus();
|
|
@@ -299,7 +299,9 @@ class IMAPClient extends BaseClient {
|
|
|
299
299
|
|
|
300
300
|
// Set up error handling for the command connection
|
|
301
301
|
const onErr = err => {
|
|
302
|
-
|
|
302
|
+
// Idle timeout (ETIMEOUT) is expected - the command client has no keepalive
|
|
303
|
+
const logLevel = err.code === 'ETIMEOUT' ? 'debug' : 'warn';
|
|
304
|
+
commandClient?.log[logLevel]({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
|
|
303
305
|
commandClient.close();
|
|
304
306
|
this.commandClient = null;
|
|
305
307
|
};
|
|
@@ -1214,9 +1216,14 @@ class IMAPClient extends BaseClient {
|
|
|
1214
1216
|
}
|
|
1215
1217
|
|
|
1216
1218
|
// Provider-specific workarounds
|
|
1217
|
-
if (
|
|
1218
|
-
//
|
|
1219
|
+
if (/\.rambler\.ru$/i.test(imapConfig.host)) {
|
|
1220
|
+
// Rambler supports IDLE but needs it restarted frequently
|
|
1219
1221
|
imapConfig.maxIdleTime = 55 * 1000;
|
|
1222
|
+
} else if (/\.163\.com$/i.test(imapConfig.host)) {
|
|
1223
|
+
// 163.com (Coremail) does not support IDLE and ignores NOOP for idle timer reset.
|
|
1224
|
+
// Use STATUS as the keepalive command - it counts as real activity for the server.
|
|
1225
|
+
imapConfig.maxIdleTime = 55 * 1000;
|
|
1226
|
+
imapConfig.missingIdleCommand = 'STATUS';
|
|
1220
1227
|
} else if (/\.yahoo\.com$/i.test(imapConfig.host)) {
|
|
1221
1228
|
// Special case for Yahoo. Break IDLE at least once every three minutes
|
|
1222
1229
|
imapConfig.maxIdleTime = 3 * 60 * 1000;
|
|
@@ -2468,6 +2475,9 @@ class IMAPClient extends BaseClient {
|
|
|
2468
2475
|
const mailboxes = [];
|
|
2469
2476
|
|
|
2470
2477
|
const listing = await this.getCurrentListing(false, { allowSecondary: true });
|
|
2478
|
+
// Capture reference so we can close it after the await-heavy loop below
|
|
2479
|
+
// without risking closure of a different command client obtained by a concurrent caller
|
|
2480
|
+
const commandClientForListing = this.commandClient;
|
|
2471
2481
|
|
|
2472
2482
|
// Process each configured subconnection
|
|
2473
2483
|
for (const path of accountData.subconnections || []) {
|
|
@@ -2595,6 +2605,12 @@ class IMAPClient extends BaseClient {
|
|
|
2595
2605
|
await subconnection.init();
|
|
2596
2606
|
}
|
|
2597
2607
|
|
|
2608
|
+
// Close the command client if it is still the same instance we captured above
|
|
2609
|
+
if (commandClientForListing && this.commandClient === commandClientForListing) {
|
|
2610
|
+
this.commandClient.close();
|
|
2611
|
+
this.commandClient = null;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2598
2614
|
return this.subconnections.length;
|
|
2599
2615
|
}
|
|
2600
2616
|
|
|
@@ -3857,8 +3857,11 @@ class OutlookClient extends BaseClient {
|
|
|
3857
3857
|
async processHistory() {
|
|
3858
3858
|
let event;
|
|
3859
3859
|
let newMessageOptions;
|
|
3860
|
+
let processedAny = false;
|
|
3860
3861
|
|
|
3861
3862
|
while ((event = await this.accountObject.pullQueueEvent()) !== null) {
|
|
3863
|
+
processedAny = true;
|
|
3864
|
+
|
|
3862
3865
|
switch (event.type) {
|
|
3863
3866
|
case 'updated':
|
|
3864
3867
|
await this.processUpdatedMessage(event.message);
|
|
@@ -3900,6 +3903,157 @@ class OutlookClient extends BaseClient {
|
|
|
3900
3903
|
break;
|
|
3901
3904
|
}
|
|
3902
3905
|
}
|
|
3906
|
+
|
|
3907
|
+
if (processedAny) {
|
|
3908
|
+
await this._updateLastNotificationTime();
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
async _updateLastNotificationTime() {
|
|
3913
|
+
try {
|
|
3914
|
+
await this.redis.hset(this.getAccountKey(), 'outlookLastNotification', Date.now().toString());
|
|
3915
|
+
} catch (err) {
|
|
3916
|
+
this.logger.error({ msg: 'Failed to update last notification time', err });
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
/**
|
|
3921
|
+
* Recover messages missed due to failed webhook delivery.
|
|
3922
|
+
* Triggered by MS Graph 'missed' lifecycle event.
|
|
3923
|
+
* Queries recent messages and processes any not already seen via normal webhooks.
|
|
3924
|
+
* @returns {boolean} Whether recovery ran (false if debounced)
|
|
3925
|
+
*/
|
|
3926
|
+
async syncMissedMessages() {
|
|
3927
|
+
// Debounce: skip if recovery already ran recently
|
|
3928
|
+
const cooldownKey = `${REDIS_PREFIX}iam:${this.account}:missedRecovery`;
|
|
3929
|
+
const acquired = await this.redis.set(cooldownKey, '1', 'EX', 300, 'NX');
|
|
3930
|
+
if (!acquired) {
|
|
3931
|
+
this.logger.debug({ msg: 'Missed notification recovery skipped (cooldown active)', account: this.account });
|
|
3932
|
+
return false;
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
// Determine lookback window
|
|
3936
|
+
let sinceTime;
|
|
3937
|
+
const lastNotification = await this.redis.hget(this.getAccountKey(), 'outlookLastNotification');
|
|
3938
|
+
if (lastNotification) {
|
|
3939
|
+
// 2-minute safety margin before last known good notification
|
|
3940
|
+
sinceTime = new Date(Number(lastNotification) - 2 * 60 * 1000);
|
|
3941
|
+
} else {
|
|
3942
|
+
sinceTime = new Date(Date.now() - 30 * 60 * 1000);
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
this.logger.info({
|
|
3946
|
+
msg: 'Recovering missed notifications',
|
|
3947
|
+
account: this.account,
|
|
3948
|
+
since: sinceTime.toISOString()
|
|
3949
|
+
});
|
|
3950
|
+
|
|
3951
|
+
let messages = [];
|
|
3952
|
+
let reqUrl = `/${this.oauth2UserPath}/messages`;
|
|
3953
|
+
let reqPayload = {
|
|
3954
|
+
$filter: `receivedDateTime gt ${sinceTime.toISOString()}`,
|
|
3955
|
+
$select: 'id,parentFolderId',
|
|
3956
|
+
$top: 250,
|
|
3957
|
+
$orderby: 'receivedDateTime desc'
|
|
3958
|
+
};
|
|
3959
|
+
|
|
3960
|
+
try {
|
|
3961
|
+
while (reqUrl) {
|
|
3962
|
+
let pageResult = await this.request(reqUrl, 'get', reqPayload);
|
|
3963
|
+
|
|
3964
|
+
if (pageResult?.value?.length) {
|
|
3965
|
+
messages.push(...pageResult.value);
|
|
3966
|
+
if (messages.length >= 1000) {
|
|
3967
|
+
messages.length = 1000;
|
|
3968
|
+
break;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
if (pageResult['@odata.nextLink']) {
|
|
3973
|
+
reqUrl = pageResult['@odata.nextLink'];
|
|
3974
|
+
reqPayload = null; // nextLink includes all query params
|
|
3975
|
+
} else {
|
|
3976
|
+
reqUrl = null;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
} catch (err) {
|
|
3980
|
+
this.logger.error({ msg: 'Failed to query recent messages for missed notification recovery', account: this.account, err });
|
|
3981
|
+
await this.redis.del(cooldownKey);
|
|
3982
|
+
return false;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
if (!messages.length) {
|
|
3986
|
+
this.logger.debug({ msg: 'No recent messages found during missed notification recovery', account: this.account });
|
|
3987
|
+
return true;
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
this.logger.info({
|
|
3991
|
+
msg: 'Found recent messages for missed notification recovery',
|
|
3992
|
+
account: this.account,
|
|
3993
|
+
count: messages.length
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
let newMessageOptions = await this.getMessageFetchOptions();
|
|
3997
|
+
newMessageOptions.showPath = true;
|
|
3998
|
+
|
|
3999
|
+
// Pre-resolve unique folder IDs once to avoid per-message Redis lookups
|
|
4000
|
+
let folderMap = new Map();
|
|
4001
|
+
let cachedListing = await this.getCachedMailboxListing();
|
|
4002
|
+
if (cachedListing) {
|
|
4003
|
+
for (let msg of messages) {
|
|
4004
|
+
if (msg.parentFolderId && !folderMap.has(msg.parentFolderId)) {
|
|
4005
|
+
let folder = cachedListing.find(entry => entry.id === msg.parentFolderId);
|
|
4006
|
+
if (folder) {
|
|
4007
|
+
folderMap.set(msg.parentFolderId, folder);
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
let recoveredCount = 0;
|
|
4014
|
+
|
|
4015
|
+
for (const msg of messages) {
|
|
4016
|
+
try {
|
|
4017
|
+
// Pre-filter: check rollingBucketLock BEFORE the expensive getMessage() call
|
|
4018
|
+
let preFilterChecked = false;
|
|
4019
|
+
let folder = folderMap.get(msg.parentFolderId);
|
|
4020
|
+
if (folder) {
|
|
4021
|
+
let recentlySeen = await this.rollingBucketLock(`${msg.id}:created`, folder.pathName);
|
|
4022
|
+
if (recentlySeen) {
|
|
4023
|
+
continue;
|
|
4024
|
+
}
|
|
4025
|
+
preFilterChecked = true;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
let messageData = await this.prepareNewMessage(msg.id, newMessageOptions);
|
|
4029
|
+
if (!messageData) {
|
|
4030
|
+
continue;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
if (!preFilterChecked) {
|
|
4034
|
+
let recentlySeen = await this.rollingBucketLock(`${messageData.id}:created`, messageData.path);
|
|
4035
|
+
if (recentlySeen) {
|
|
4036
|
+
continue;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
await this.processNew(messageData, newMessageOptions);
|
|
4041
|
+
recoveredCount++;
|
|
4042
|
+
} catch (err) {
|
|
4043
|
+
this.logger.error({ msg: 'Failed to process message during missed notification recovery', emailId: msg.id, account: this.account, err });
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
this.logger.info({
|
|
4048
|
+
msg: 'Missed notification recovery completed',
|
|
4049
|
+
account: this.account,
|
|
4050
|
+
checked: messages.length,
|
|
4051
|
+
recovered: recoveredCount
|
|
4052
|
+
});
|
|
4053
|
+
|
|
4054
|
+
await this._updateLastNotificationTime();
|
|
4055
|
+
|
|
4056
|
+
return true;
|
|
3903
4057
|
}
|
|
3904
4058
|
|
|
3905
4059
|
/**
|
package/lib/passkeys.js
CHANGED
|
@@ -48,6 +48,47 @@ async function fetchCredentialsBySet(setKey) {
|
|
|
48
48
|
return credentials;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
redis.defineCommand('webauthnSaveIfUnderLimit', {
|
|
52
|
+
numberOfKeys: 3,
|
|
53
|
+
lua: `
|
|
54
|
+
local userSetKey = KEYS[1]
|
|
55
|
+
local credKey = KEYS[2]
|
|
56
|
+
local allSetKey = KEYS[3]
|
|
57
|
+
local maxCount = tonumber(ARGV[1])
|
|
58
|
+
local credId = ARGV[2]
|
|
59
|
+
|
|
60
|
+
local currentCount = redis.call('SCARD', userSetKey)
|
|
61
|
+
if currentCount >= maxCount then
|
|
62
|
+
return 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
redis.call('SADD', userSetKey, credId)
|
|
66
|
+
redis.call('SADD', allSetKey, credId)
|
|
67
|
+
redis.call('HSET', credKey, unpack(ARGV, 3))
|
|
68
|
+
return 1
|
|
69
|
+
`
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function serializeCredential({ id, publicKey, counter, transports, name, user }) {
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
publicKey,
|
|
76
|
+
counter: String(counter),
|
|
77
|
+
transports: JSON.stringify(transports || []),
|
|
78
|
+
name: name || 'Unnamed passkey',
|
|
79
|
+
user,
|
|
80
|
+
createdAt: new Date().toISOString()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function credentialKeys(id, user) {
|
|
85
|
+
return {
|
|
86
|
+
credKey: `${KEY_PREFIX}cred:${id}`,
|
|
87
|
+
userSetKey: `${KEY_PREFIX}creds:${user}`,
|
|
88
|
+
allSetKey: `${KEY_PREFIX}all`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
module.exports = {
|
|
52
93
|
async getRpConfig() {
|
|
53
94
|
let serviceUrl = await settings.get('serviceUrl');
|
|
@@ -73,26 +114,26 @@ module.exports = {
|
|
|
73
114
|
return challenge || null;
|
|
74
115
|
},
|
|
75
116
|
|
|
76
|
-
async saveCredential(
|
|
77
|
-
let credKey =
|
|
78
|
-
let userSetKey = `${KEY_PREFIX}creds:${user}`;
|
|
79
|
-
let allSetKey = `${KEY_PREFIX}all`;
|
|
117
|
+
async saveCredential(cred) {
|
|
118
|
+
let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
|
|
80
119
|
|
|
81
120
|
let pipeline = redis.pipeline();
|
|
82
|
-
pipeline.hset(credKey,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
counter: String(counter),
|
|
86
|
-
transports: JSON.stringify(transports || []),
|
|
87
|
-
name: name || 'Unnamed passkey',
|
|
88
|
-
user,
|
|
89
|
-
createdAt: new Date().toISOString()
|
|
90
|
-
});
|
|
91
|
-
pipeline.sadd(userSetKey, id);
|
|
92
|
-
pipeline.sadd(allSetKey, id);
|
|
121
|
+
pipeline.hset(credKey, serializeCredential(cred));
|
|
122
|
+
pipeline.sadd(userSetKey, cred.id);
|
|
123
|
+
pipeline.sadd(allSetKey, cred.id);
|
|
93
124
|
await pipeline.exec();
|
|
94
125
|
},
|
|
95
126
|
|
|
127
|
+
async saveCredentialIfUnderLimit(cred, maxCount) {
|
|
128
|
+
let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
|
|
129
|
+
let fields = serializeCredential(cred);
|
|
130
|
+
let fieldArgs = Object.entries(fields).flat();
|
|
131
|
+
|
|
132
|
+
let added = await redis.webauthnSaveIfUnderLimit(userSetKey, credKey, allSetKey, maxCount, cred.id, ...fieldArgs);
|
|
133
|
+
|
|
134
|
+
return !!added;
|
|
135
|
+
},
|
|
136
|
+
|
|
96
137
|
async getCredential(credentialId) {
|
|
97
138
|
if (!credentialId || typeof credentialId !== 'string') {
|
|
98
139
|
return null;
|
package/lib/routes-ui.js
CHANGED
|
@@ -4258,20 +4258,22 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
|
|
|
4258
4258
|
}
|
|
4259
4259
|
|
|
4260
4260
|
let authData = await settings.get('authData');
|
|
4261
|
-
if (authData
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4261
|
+
if (!authData || !authData.password) {
|
|
4262
|
+
return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
if (!request.payload.password) {
|
|
4266
|
+
return h.response({ error: 'Current password is required' }).code(403);
|
|
4267
|
+
}
|
|
4268
|
+
let valid;
|
|
4269
|
+
try {
|
|
4270
|
+
valid = await pbkdf2.verify(authData.password, request.payload.password);
|
|
4271
|
+
} catch (err) {
|
|
4272
|
+
request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
|
|
4273
|
+
valid = false;
|
|
4274
|
+
}
|
|
4275
|
+
if (!valid) {
|
|
4276
|
+
return h.response({ error: 'Invalid password' }).code(403);
|
|
4275
4277
|
}
|
|
4276
4278
|
|
|
4277
4279
|
let { rpId, origin } = await passkeys.getRpConfig();
|
|
@@ -4359,19 +4361,26 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
|
|
|
4359
4361
|
let { credential } = verification.registrationInfo;
|
|
4360
4362
|
let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
|
|
4361
4363
|
|
|
4362
|
-
let
|
|
4363
|
-
if (
|
|
4364
|
-
return h.response({ error: '
|
|
4364
|
+
let authData = await settings.get('authData');
|
|
4365
|
+
if (!authData || !authData.password) {
|
|
4366
|
+
return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
|
|
4365
4367
|
}
|
|
4366
4368
|
|
|
4367
|
-
await passkeys.
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4369
|
+
let saved = await passkeys.saveCredentialIfUnderLimit(
|
|
4370
|
+
{
|
|
4371
|
+
id: credential.id,
|
|
4372
|
+
publicKey: Buffer.from(credential.publicKey).toString('base64url'),
|
|
4373
|
+
counter: credential.counter,
|
|
4374
|
+
transports: credential.transports || [],
|
|
4375
|
+
name: request.payload.name,
|
|
4376
|
+
user
|
|
4377
|
+
},
|
|
4378
|
+
consts.MAX_PASSKEYS_PER_USER
|
|
4379
|
+
);
|
|
4380
|
+
|
|
4381
|
+
if (!saved) {
|
|
4382
|
+
return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
|
|
4383
|
+
}
|
|
4375
4384
|
|
|
4376
4385
|
request.logger.info({
|
|
4377
4386
|
msg: 'Passkey registered',
|