emailengine-app 2.66.0 → 2.67.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.67.0](https://github.com/postalsys/emailengine/compare/v2.66.0...v2.67.0) (2026-03-31)
4
+
5
+
6
+ ### Features
7
+
8
+ * handle MS Graph 'missed' lifecycle event for Outlook notification recovery ([d55f48c](https://github.com/postalsys/emailengine/commit/d55f48c9a183f9e0108801563b1085c176dbeb06))
9
+ * show human-readable missing scopes on OAuth scope error page ([4ee2365](https://github.com/postalsys/emailengine/commit/4ee2365449e3cbece6f4a83d540ae63c18464535))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * add IMAP fetch retry timeout and harden passkey registration ([3a1b61b](https://github.com/postalsys/emailengine/commit/3a1b61b00e916c308cae5e7493a2207164d9d45f))
15
+ * close command client proactively after subconnection setup ([02d3687](https://github.com/postalsys/emailengine/commit/02d36874d8533beabe5ad0fba978851b3791cbb5))
16
+ * correct translation errors and remove EmailEngine branding from OAuth scope error ([9284ddd](https://github.com/postalsys/emailengine/commit/9284ddd1e28e40fd24abeea7296636b21c4b3878))
17
+ * use STATUS instead of NOOP as keepalive for 163.com IMAP ([3b3516f](https://github.com/postalsys/emailengine/commit/3b3516feb14e64bab5ac586378237f2160cdf010))
18
+
3
19
  ## [2.66.0](https://github.com/postalsys/emailengine/compare/v2.65.0...v2.66.0) (2026-03-29)
4
20
 
5
21
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2026-03-27T15:45:58.000000",
2
+ "creationTime": "2026-03-30T14:45:52.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
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
@@ -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,6 +340,8 @@ class SyncOperations {
330
340
  connErr.statusCode = 503;
331
341
  throw connErr;
332
342
  }
343
+
344
+ checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
333
345
  }
334
346
  }
335
347
  }
@@ -383,6 +395,7 @@ class SyncOperations {
383
395
  // only fetch messages if there are some
384
396
  let fetchCompleted = false;
385
397
  let fetchRetryCount = 0;
398
+ let fetchStartTime = Date.now();
386
399
  while (!fetchCompleted) {
387
400
  // Get fresh imapClient reference inside retry loop
388
401
  let imapClient = this.connection.imapClient;
@@ -483,6 +496,8 @@ class SyncOperations {
483
496
  connErr.statusCode = 503;
484
497
  throw connErr;
485
498
  }
499
+
500
+ checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
486
501
  }
487
502
  }
488
503
  }
@@ -553,6 +568,8 @@ class SyncOperations {
553
568
  let range = false;
554
569
  let lastHighestUid = 0;
555
570
  let batchNumber = 0;
571
+ let fetchStartTime = Date.now();
572
+ let totalFetchRetryCount = 0;
556
573
  // process messages in batches
557
574
  while ((range = getFetchRange(mailboxStatus.messages, range))) {
558
575
  batchNumber++;
@@ -738,6 +755,7 @@ class SyncOperations {
738
755
  }
739
756
 
740
757
  // Retry with exponential backoff
758
+ totalFetchRetryCount++;
741
759
  const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
742
760
  this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, loopId, batchNumber });
743
761
  await new Promise(r => setTimeout(r, fetchRetryDelay));
@@ -776,6 +794,8 @@ class SyncOperations {
776
794
  newMessages: mailboxStatus.messages,
777
795
  range
778
796
  });
797
+
798
+ checkFetchRetryTimeout(totalFetchRetryCount, fetchStartTime);
779
799
  }
780
800
  }
781
801
  }
@@ -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
- commandClient?.log.warn({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
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 (/(\.rambler\.ru|\.163\.com)$/i.test(imapConfig.host)) {
1218
- // Special case for Rambler and 163. Break IDLE at least once a minute
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({ id, publicKey, counter, transports, name, user }) {
77
- let credKey = `${KEY_PREFIX}cred:${id}`;
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
- id,
84
- publicKey,
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 && authData.password) {
4262
- if (!request.payload.password) {
4263
- return h.response({ error: 'Current password is required' }).code(403);
4264
- }
4265
- let valid;
4266
- try {
4267
- valid = await pbkdf2.verify(authData.password, request.payload.password);
4268
- } catch (err) {
4269
- request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
4270
- valid = false;
4271
- }
4272
- if (!valid) {
4273
- return h.response({ error: 'Invalid password' }).code(403);
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 credentialCount = await passkeys.countCredentials(user);
4363
- if (credentialCount >= consts.MAX_PASSKEYS_PER_USER) {
4364
- return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
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.saveCredential({
4368
- id: credential.id,
4369
- publicKey: Buffer.from(credential.publicKey).toString('base64url'),
4370
- counter: credential.counter,
4371
- transports: credential.transports || [],
4372
- name: request.payload.name,
4373
- user
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emailengine-app",
3
- "version": "2.66.0",
3
+ "version": "2.67.0",
4
4
  "private": false,
5
5
  "productTitle": "EmailEngine",
6
6
  "description": "Email Sync Engine",