emailengine-app 2.61.4 → 2.62.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 (62) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account.js +20 -7
  4. package/lib/api-routes/account-routes.js +28 -5
  5. package/lib/api-routes/chat-routes.js +1 -1
  6. package/lib/api-routes/export-routes.js +316 -0
  7. package/lib/api-routes/message-routes.js +28 -23
  8. package/lib/api-routes/template-routes.js +28 -7
  9. package/lib/arf-detect.js +1 -1
  10. package/lib/consts.js +16 -0
  11. package/lib/db.js +3 -0
  12. package/lib/email-client/base-client.js +6 -4
  13. package/lib/email-client/gmail-client.js +204 -33
  14. package/lib/email-client/imap/mailbox.js +99 -8
  15. package/lib/email-client/imap/subconnection.js +5 -5
  16. package/lib/email-client/imap-client.js +76 -16
  17. package/lib/email-client/message-builder.js +3 -1
  18. package/lib/email-client/notification-handler.js +12 -9
  19. package/lib/email-client/outlook-client.js +362 -69
  20. package/lib/email-client/smtp-pool-manager.js +1 -1
  21. package/lib/export.js +528 -0
  22. package/lib/oauth/gmail.js +21 -13
  23. package/lib/oauth/mail-ru.js +23 -10
  24. package/lib/oauth/outlook.js +26 -16
  25. package/lib/oauth/pubsub/google.js +5 -0
  26. package/lib/routes-ui.js +236 -2
  27. package/lib/schemas.js +260 -80
  28. package/lib/stream-encrypt.js +263 -0
  29. package/lib/tools.js +30 -4
  30. package/lib/ui-routes/account-routes.js +24 -1
  31. package/lib/ui-routes/admin-config-routes.js +11 -4
  32. package/lib/ui-routes/admin-entities-routes.js +18 -0
  33. package/lib/webhooks.js +16 -20
  34. package/package.json +17 -17
  35. package/sbom.json +1 -1
  36. package/server.js +41 -5
  37. package/static/js/ace/ace.js +1 -1
  38. package/static/js/ace/ext-language_tools.js +1 -1
  39. package/static/licenses.html +47 -127
  40. package/translations/de.mo +0 -0
  41. package/translations/de.po +63 -36
  42. package/translations/en.mo +0 -0
  43. package/translations/en.po +64 -37
  44. package/translations/et.mo +0 -0
  45. package/translations/et.po +63 -36
  46. package/translations/fr.mo +0 -0
  47. package/translations/fr.po +63 -36
  48. package/translations/ja.mo +0 -0
  49. package/translations/ja.po +63 -36
  50. package/translations/messages.pot +88 -55
  51. package/translations/nl.mo +0 -0
  52. package/translations/nl.po +63 -36
  53. package/translations/pl.mo +0 -0
  54. package/translations/pl.po +63 -36
  55. package/views/accounts/account.hbs +375 -2
  56. package/views/config/service.hbs +35 -0
  57. package/workers/api.js +124 -45
  58. package/workers/documents.js +1 -0
  59. package/workers/export.js +926 -0
  60. package/workers/imap.js +29 -0
  61. package/workers/submit.js +25 -5
  62. package/workers/webhooks.js +11 -2
@@ -99,6 +99,18 @@ class Mailbox {
99
99
 
100
100
  let mailboxInfo = connectionClient.mailbox;
101
101
 
102
+ // Log diagnostic info if mailbox object is missing or incomplete
103
+ if (!mailboxInfo) {
104
+ this.logger.warn({
105
+ msg: 'IMAP mailbox object is not available',
106
+ path: this.path,
107
+ connectionState: connectionClient.state,
108
+ usable: connectionClient.usable,
109
+ idling: connectionClient.idling
110
+ });
111
+ throw new Error('IMAP mailbox state is not available');
112
+ }
113
+
102
114
  let status = {
103
115
  path: this.path
104
116
  };
@@ -112,6 +124,21 @@ class Mailbox {
112
124
  // Total message count
113
125
  status.messages = mailboxInfo.exists ? mailboxInfo.exists : 0;
114
126
 
127
+ // Log diagnostic info if uidValidity is invalid
128
+ if (!status.uidValidity) {
129
+ this.logger.warn({
130
+ msg: 'Invalid uidValidity received from IMAP server',
131
+ path: this.path,
132
+ rawUidValidity: mailboxInfo.uidValidity,
133
+ rawUidValidityType: typeof mailboxInfo.uidValidity,
134
+ uidNext: status.uidNext,
135
+ messages: status.messages,
136
+ mailboxPath: mailboxInfo.path,
137
+ connectionState: connectionClient.state,
138
+ usable: connectionClient.usable
139
+ });
140
+ }
141
+
115
142
  return status;
116
143
  }
117
144
 
@@ -122,6 +149,20 @@ class Mailbox {
122
149
  async getStoredStatus() {
123
150
  let data = await this.connection.redis.hgetall(this.getMailboxKey());
124
151
  data = data || {};
152
+
153
+ // Log diagnostic info if stored uidValidity is invalid or missing
154
+ if (!validUidValidity(data.uidValidity)) {
155
+ this.logger.warn({
156
+ msg: 'Invalid or missing uidValidity in stored status',
157
+ path: this.path,
158
+ redisKey: this.getMailboxKey(),
159
+ rawUidValidity: data.uidValidity,
160
+ rawUidValidityType: typeof data.uidValidity,
161
+ hasData: Object.keys(data).length > 0,
162
+ storedKeys: Object.keys(data)
163
+ });
164
+ }
165
+
125
166
  return {
126
167
  path: data.path || this.path,
127
168
  uidValidity: validUidValidity(data.uidValidity) ? BigInt(data.uidValidity) : false,
@@ -135,6 +176,25 @@ class Mailbox {
135
176
  };
136
177
  }
137
178
 
179
+ /**
180
+ * Packs a UID into external reference format with validation and logging
181
+ * @param {number} uid - Message UID to pack
182
+ * @param {string} notificationType - Type of notification for logging context
183
+ * @returns {string|null} Packed UID or null if packing failed
184
+ */
185
+ async packUidWithLogging(uid, notificationType) {
186
+ let packedUid = await this.connection.packUid(this, uid);
187
+ if (!packedUid) {
188
+ this.logger.warn({
189
+ msg: `Skipping ${notificationType} notification - packUid returned invalid result`,
190
+ uid,
191
+ path: this.path
192
+ });
193
+ return null;
194
+ }
195
+ return packedUid;
196
+ }
197
+
138
198
  /**
139
199
  * Updates known mailbox state in Redis
140
200
  * @param {Object} data - Status data to store
@@ -149,10 +209,32 @@ class Mailbox {
149
209
  .map(key => {
150
210
  switch (key) {
151
211
  case 'path':
152
- case 'uidValidity':
153
212
  case 'highestModseq':
154
213
  case 'messages':
214
+ return [key, data[key].toString()];
215
+
216
+ case 'uidValidity':
217
+ // Skip storing invalid uidValidity to prevent sync state corruption
218
+ if (!validUidValidity(data[key])) {
219
+ this.logger.warn({
220
+ msg: 'Skipping invalid uidValidity value in updateStoredStatus',
221
+ path: this.path,
222
+ uidValidityValue: data[key]
223
+ });
224
+ return null;
225
+ }
226
+ return [key, data[key].toString()];
227
+
155
228
  case 'uidNext':
229
+ // Skip storing invalid uidNext to prevent sync state corruption
230
+ if (!data[key]) {
231
+ this.logger.warn({
232
+ msg: 'Skipping invalid uidNext value in updateStoredStatus',
233
+ path: this.path,
234
+ uidNextValue: data[key]
235
+ });
236
+ return null;
237
+ }
156
238
  return [key, data[key].toString()];
157
239
 
158
240
  case 'lastFullSync':
@@ -644,7 +726,10 @@ class Mailbox {
644
726
  */
645
727
 
646
728
  // Generate packed UID for external reference
647
- let packedUid = await this.connection.packUid(this, messageData.uid);
729
+ let packedUid = await this.packUidWithLogging(messageData.uid, 'delete');
730
+ if (!packedUid) {
731
+ return;
732
+ }
648
733
  await this.connection.notify(this, MESSAGE_DELETED_NOTIFY, {
649
734
  id: packedUid,
650
735
  uid: messageData.uid
@@ -707,7 +792,10 @@ class Mailbox {
707
792
 
708
793
  if (!messageInfo) {
709
794
  // Message not found after retries - send missing notification
710
- let packedUid = await this.connection.packUid(this, messageData.uid);
795
+ let packedUid = await this.packUidWithLogging(messageData.uid, 'missing');
796
+ if (!packedUid) {
797
+ return;
798
+ }
711
799
  await this.connection.notify(this, MESSAGE_MISSING_NOTIFY, {
712
800
  id: packedUid,
713
801
  uid: messageData.uid,
@@ -766,7 +854,7 @@ class Mailbox {
766
854
  if (this.mightBeAComplaint(messageInfo)) {
767
855
  try {
768
856
  // Download relevant attachments for ARF parsing
769
- for (let attachment of messageInfo.attachments) {
857
+ for (let attachment of messageInfo.attachments || []) {
770
858
  if (!['message/feedback-report', 'message/rfc822-headers', 'message/rfc822'].includes(attachment.contentType)) {
771
859
  continue;
772
860
  }
@@ -1074,7 +1162,7 @@ class Mailbox {
1074
1162
  // Fetch inline attachments referenced in HTML
1075
1163
  if (messageInfo.attachments?.length && messageInfo.text?.html) {
1076
1164
  // fetch inline attachments
1077
- for (let attachment of messageInfo.attachments) {
1165
+ for (let attachment of messageInfo.attachments || []) {
1078
1166
  if (attachment.encodedSize && attachment.encodedSize > MAX_INLINE_ATTACHMENT_SIZE) {
1079
1167
  // skip large attachments
1080
1168
  continue;
@@ -1366,7 +1454,7 @@ class Mailbox {
1366
1454
  let partList = [];
1367
1455
 
1368
1456
  // Collect CID-referenced attachments
1369
- for (let attachment of messageInfo.attachments) {
1457
+ for (let attachment of messageInfo.attachments || []) {
1370
1458
  let contentId = attachment.contentId && attachment.contentId.replace(/^<|>$/g, '');
1371
1459
  if (contentId && messageInfo.text.html.indexOf(contentId) >= 0) {
1372
1460
  attachmentList.set(contentId, {
@@ -1786,7 +1874,10 @@ class Mailbox {
1786
1874
  * @param {Object} changes - What changed
1787
1875
  */
1788
1876
  async processChanges(messageData, changes) {
1789
- let packedUid = await this.connection.packUid(this, messageData.uid);
1877
+ let packedUid = await this.packUidWithLogging(messageData.uid, 'update');
1878
+ if (!packedUid) {
1879
+ return;
1880
+ }
1790
1881
  await this.connection.notify(this, MESSAGE_UPDATED_NOTIFY, {
1791
1882
  id: packedUid,
1792
1883
  uid: messageData.uid,
@@ -2303,7 +2394,7 @@ class Mailbox {
2303
2394
  let partList = [];
2304
2395
 
2305
2396
  // Find images referenced by CID
2306
- for (let attachment of messageInfo.attachments) {
2397
+ for (let attachment of messageInfo.attachments || []) {
2307
2398
  let contentId = attachment.contentId && attachment.contentId.replace(/^<|>$/g, '');
2308
2399
  if (contentId && messageInfo.text.html.indexOf(contentId) >= 0) {
2309
2400
  attachmentList.set(contentId, { attachment, content: null });
@@ -112,7 +112,7 @@ class Subconnection extends EventEmitter {
112
112
  this.disabledReason = false;
113
113
  } catch (err) {
114
114
  // ended in an unconncted state
115
- this.logger.error({ msg: 'Failed to set up subconnection', err });
115
+ this.logger.warn({ msg: 'Failed to set up subconnection', err });
116
116
  }
117
117
  }
118
118
 
@@ -124,11 +124,11 @@ class Subconnection extends EventEmitter {
124
124
  try {
125
125
  prevImapClient.removeAllListeners();
126
126
  prevImapClient.once('error', err => {
127
- this.logger.error({ msg: 'IMAP connection error', previous: true, account: this.account, err });
127
+ this.logger.warn({ msg: 'IMAP connection error', previous: true, account: this.account, err });
128
128
  });
129
129
  prevImapClient.close();
130
130
  } catch (err) {
131
- this.logger.error({ msg: 'IMAP close error', err });
131
+ this.logger.warn({ msg: 'IMAP close error', err });
132
132
  } finally {
133
133
  if (prevImapClient === this.imapClient) {
134
134
  this.imapClient = null;
@@ -153,13 +153,13 @@ class Subconnection extends EventEmitter {
153
153
  imapClient.subConnection = true;
154
154
 
155
155
  imapClient.on('error', err => {
156
- imapClient?.log.error({ msg: 'IMAP connection error', account: this.account, err });
156
+ imapClient?.log.warn({ msg: 'IMAP connection error', account: this.account, err });
157
157
  if (imapClient !== this.imapClient || this._connecting) {
158
158
  return;
159
159
  }
160
160
  imapClient.close();
161
161
  this.reconnect().catch(err => {
162
- this.logger.error({ msg: 'IMAP reconnection error', account: this.account, err });
162
+ this.logger.warn({ msg: 'IMAP reconnection error', account: this.account, err });
163
163
  });
164
164
  });
165
165
 
@@ -288,7 +288,7 @@ class IMAPClient extends BaseClient {
288
288
 
289
289
  // Set up error handling for the command connection
290
290
  const onErr = err => {
291
- commandClient?.log.error({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
291
+ commandClient?.log.warn({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
292
292
  commandClient.close();
293
293
  this.commandClient = null;
294
294
  };
@@ -301,7 +301,7 @@ class IMAPClient extends BaseClient {
301
301
  if (this.connections.delete(commandClient)) {
302
302
  await this.redis.hSetExists(this.getAccountKey(), 'connections', this.connections.size.toString());
303
303
  }
304
- commandClient.log.error({ msg: 'Failed to connect command client', cid: commandCid, channel: 'command', account: this.account, err });
304
+ commandClient.log.warn({ msg: 'Failed to connect command client', cid: commandCid, channel: 'command', account: this.account, err });
305
305
  throw err;
306
306
  }
307
307
 
@@ -371,8 +371,47 @@ class IMAPClient extends BaseClient {
371
371
  }
372
372
 
373
373
  // Get stored mailbox status including UID validity
374
- const storedStatus = await mailbox.getStoredStatus();
374
+ let storedStatus = await mailbox.getStoredStatus();
375
+
376
+ // If stored status missing uidValidity, try live IMAP mailbox status
377
+ if (!validUidValidity(storedStatus.uidValidity)) {
378
+ try {
379
+ const liveStatus = mailbox.getMailboxStatus();
380
+ if (validUidValidity(liveStatus.uidValidity)) {
381
+ this.logger.debug({
382
+ msg: 'packUid using live mailbox status (stored uidValidity missing)',
383
+ path: mailbox.path,
384
+ liveUidValidity: liveStatus.uidValidity.toString()
385
+ });
386
+ storedStatus = {
387
+ ...storedStatus,
388
+ uidValidity: liveStatus.uidValidity,
389
+ path: liveStatus.path || mailbox.path
390
+ };
391
+ }
392
+ } catch (err) {
393
+ // Live status not available (mailbox not selected)
394
+ this.logger.debug({
395
+ msg: 'packUid live status fallback unavailable',
396
+ path: mailbox.path,
397
+ error: err.message
398
+ });
399
+ }
400
+ }
401
+
402
+ // Final validation
375
403
  if (!validUidValidity(storedStatus.uidValidity) || !storedStatus.path) {
404
+ this.logger.warn({
405
+ msg: 'packUid failed due to invalid stored status',
406
+ uid,
407
+ mailboxPath: mailbox.path,
408
+ storedUidValidity: storedStatus.uidValidity,
409
+ storedUidValidityType: typeof storedStatus.uidValidity,
410
+ storedPath: storedStatus.path,
411
+ storedUidNext: storedStatus.uidNext,
412
+ storedMessages: storedStatus.messages,
413
+ syncing: mailbox.syncing || false
414
+ });
376
415
  return false;
377
416
  }
378
417
 
@@ -498,7 +537,9 @@ class IMAPClient extends BaseClient {
498
537
 
499
538
  const connectionClient = await this.getImapConnection(connectionOptions, 'getCurrentListing');
500
539
  if (!connectionClient) {
501
- this.imapClient.close();
540
+ if (this.imapClient) {
541
+ this.imapClient.close();
542
+ }
502
543
  let error = new Error('Failed to get connection');
503
544
  error.code = 'ConnectionError';
504
545
  throw error;
@@ -522,7 +563,9 @@ class IMAPClient extends BaseClient {
522
563
  let listing = await connectionClient.list(options);
523
564
  if (!listing.length) {
524
565
  // server bug, the list can never be empty
525
- this.imapClient.close();
566
+ if (this.imapClient) {
567
+ this.imapClient.close();
568
+ }
526
569
  let error = new Error('Server bug: empty mailbox listing');
527
570
  error.code = 'ServerBug';
528
571
  throw error;
@@ -879,7 +922,7 @@ class IMAPClient extends BaseClient {
879
922
  }
880
923
  } catch (err) {
881
924
  // ended in an unconncted state
882
- this.logger.error({ msg: 'Failed to set up connection, will retry', err });
925
+ this.logger.warn({ msg: 'Failed to set up connection, will retry', err });
883
926
 
884
927
  // Calculate delay with capped exponential backoff
885
928
  const retryDelay = Math.min(this.reconnectMaxDelay, 1000 * Math.pow(1.5, Math.min(this.reconnectRetries, 10)));
@@ -898,7 +941,7 @@ class IMAPClient extends BaseClient {
898
941
  this.reconnectRetries = 0;
899
942
  })
900
943
  .catch(err => {
901
- this.logger.error({
944
+ this.logger.warn({
902
945
  msg: 'Connection retry failed',
903
946
  err,
904
947
  attempt: this.reconnectRetries
@@ -935,6 +978,10 @@ class IMAPClient extends BaseClient {
935
978
 
936
979
  // Sync existing folders that weren't just synced
937
980
  for (let mailbox of this.mailboxes.values()) {
981
+ if (!this.imapClient || !this.imapClient.usable) {
982
+ this.logger.debug({ msg: 'IMAP client disconnected during mailbox sync' });
983
+ return;
984
+ }
938
985
  if (!synced || !synced.has(mailbox)) {
939
986
  await mailbox.sync();
940
987
  }
@@ -949,12 +996,19 @@ class IMAPClient extends BaseClient {
949
996
  this.state = 'connected';
950
997
  await this.setStateVal();
951
998
 
999
+ // Capture local reference to avoid race conditions during async operations
1000
+ const imapClient = this.imapClient;
1001
+ if (!imapClient || !imapClient.usable) {
1002
+ this.logger.debug({ msg: 'IMAP client disconnected or not usable after state update' });
1003
+ return;
1004
+ }
1005
+
952
1006
  // Store IMAP server capabilities for reference
953
- const capabilities = (this.imapClient.rawCapabilities || []).map(entry => entry && entry.value).filter(entry => entry);
1007
+ const capabilities = (imapClient.rawCapabilities || []).map(entry => entry && entry.value).filter(entry => entry);
954
1008
  const authCapabilities = [];
955
1009
  let lastUsedAuthCapability = null;
956
- if (this.imapClient.authCapabilities) {
957
- for (let [authCapa, usedAuth] of this.imapClient.authCapabilities) {
1010
+ if (imapClient.authCapabilities) {
1011
+ for (let [authCapa, usedAuth] of imapClient.authCapabilities) {
958
1012
  authCapabilities.push(authCapa);
959
1013
  if (usedAuth) {
960
1014
  lastUsedAuthCapability = authCapa;
@@ -962,7 +1016,7 @@ class IMAPClient extends BaseClient {
962
1016
  }
963
1017
  }
964
1018
 
965
- const serverInfo = Object.assign({}, this.imapClient.serverInfo || {}, {
1019
+ const serverInfo = Object.assign({}, imapClient.serverInfo || {}, {
966
1020
  capabilities,
967
1021
  authCapabilities,
968
1022
  lastUsedAuthCapability
@@ -980,6 +1034,12 @@ class IMAPClient extends BaseClient {
980
1034
  return;
981
1035
  }
982
1036
 
1037
+ // Check if client disconnected before mailbox selection
1038
+ if (!this.imapClient || !this.imapClient.usable) {
1039
+ this.logger.debug({ msg: 'IMAP client disconnected before mailbox selection' });
1040
+ return;
1041
+ }
1042
+
983
1043
  this.logger.debug({ msg: 'Syncing completed, selecting main path', path: mainPath });
984
1044
  // start waiting for changes
985
1045
  await this.select(mainPath);
@@ -1195,7 +1255,7 @@ class IMAPClient extends BaseClient {
1195
1255
  prevImapClient.removeAllListeners();
1196
1256
 
1197
1257
  const prevImapErrorHandler = err => {
1198
- this.logger.error({ msg: 'IMAP connection error', type: 'imapClient', previous: true, account: this.account, err });
1258
+ this.logger.warn({ msg: 'IMAP connection error', type: 'imapClient', previous: true, account: this.account, err });
1199
1259
  };
1200
1260
 
1201
1261
  prevImapClient.once('error', prevImapErrorHandler);
@@ -1207,7 +1267,7 @@ class IMAPClient extends BaseClient {
1207
1267
  this.commandClient.close();
1208
1268
  }
1209
1269
  } catch (err) {
1210
- this.logger.error({ msg: 'IMAP close error', err });
1270
+ this.logger.warn({ msg: 'IMAP close error', err });
1211
1271
  } finally {
1212
1272
  if (prevImapClient === this.imapClient) {
1213
1273
  this.imapClient = null;
@@ -1267,7 +1327,7 @@ class IMAPClient extends BaseClient {
1267
1327
 
1268
1328
  // Handle connection errors
1269
1329
  imapClient.on('error', err => {
1270
- imapClient?.log.error({ msg: 'IMAP connection error', type: 'imapClient', account: this.account, err });
1330
+ imapClient?.log.warn({ msg: 'IMAP connection error', type: 'imapClient', account: this.account, err });
1271
1331
  if (imapClient !== this.imapClient || this._connecting) {
1272
1332
  return;
1273
1333
  }
@@ -1291,7 +1351,7 @@ class IMAPClient extends BaseClient {
1291
1351
  this.errorReconnectDelay = 2000;
1292
1352
  })
1293
1353
  .catch(err => {
1294
- this.logger.error({
1354
+ this.logger.warn({
1295
1355
  msg: 'IMAP reconnection error',
1296
1356
  account: this.account,
1297
1357
  err,
@@ -2303,7 +2363,7 @@ class IMAPClient extends BaseClient {
2303
2363
  let uploadResponse = await connectionClient.append(data.path, raw, data.flags, data.internalDate);
2304
2364
 
2305
2365
  // Return to IDLE if using primary connection
2306
- if (connectionClient === this.imapClient && this.imapClient.mailbox && !this.imapClient.idling) {
2366
+ if (this.imapClient && connectionClient === this.imapClient && this.imapClient.mailbox && !this.imapClient.idling) {
2307
2367
  // force back to IDLE
2308
2368
  this.imapClient.idle().catch(err => {
2309
2369
  this.logger.error({ msg: 'IDLE error', err });
@@ -467,7 +467,9 @@ const SMTP_ERROR_DESCRIPTIONS = {
467
467
  EDNS: settings => `EmailEngine failed to resolve DNS record for ${settings.host}`,
468
468
  ECONNECTION: settings => `EmailEngine failed to establish TCP connection against ${settings.host}`,
469
469
  EPROTOCOL: settings => `Unexpected response from ${settings.host}`,
470
- EAUTH: () => 'Authentication failed'
470
+ EAUTH: () => 'Authentication failed',
471
+ ENOAUTH: () => 'Authentication credentials were not provided',
472
+ EOAUTH2: () => 'OAuth2 token generation or refresh failed'
471
473
  };
472
474
 
473
475
  /**
@@ -19,12 +19,13 @@ const DOCUMENT_SYNC_EVENTS = [MESSAGE_NEW_NOTIFY, MESSAGE_DELETED_NOTIFY, MESSAG
19
19
  * Default job options for notification queue
20
20
  */
21
21
  const DEFAULT_JOB_OPTIONS = {
22
- removeOnComplete: true,
23
- removeOnFail: true,
22
+ removeOnComplete: { age: 24 * 3600, count: 1000 },
23
+ removeOnFail: { age: 24 * 3600, count: 1000 },
24
24
  attempts: 10,
25
25
  backoff: {
26
26
  type: 'exponential',
27
- delay: 5000
27
+ delay: 5000,
28
+ jitter: 0.2 // 20% randomization to prevent thundering herd
28
29
  }
29
30
  };
30
31
 
@@ -236,18 +237,20 @@ class NotificationHandler {
236
237
 
237
238
  /**
238
239
  * Builds job options based on queue keep setting
239
- * @param {boolean} queueKeep - Whether to keep completed/failed jobs
240
+ * @param {boolean|number} queueKeep - Whether/how many jobs to keep
240
241
  * @returns {Object} Job options for notify and documents queues
241
242
  */
242
243
  buildJobOptions(queueKeep) {
244
+ const retention = typeof queueKeep === 'number' ? { age: 24 * 3600, count: queueKeep } : queueKeep;
245
+
243
246
  const notifyOptions = Object.assign({}, DEFAULT_JOB_OPTIONS, {
244
- removeOnComplete: queueKeep,
245
- removeOnFail: queueKeep
247
+ removeOnComplete: retention,
248
+ removeOnFail: retention
246
249
  });
247
250
 
248
251
  const documentOptions = Object.assign({}, DOCUMENT_JOB_OPTIONS, {
249
- removeOnComplete: queueKeep,
250
- removeOnFail: queueKeep
252
+ removeOnComplete: retention,
253
+ removeOnFail: retention
251
254
  });
252
255
 
253
256
  return {
@@ -289,7 +292,7 @@ class NotificationHandler {
289
292
  }
290
293
 
291
294
  // Get queue retention setting
292
- const queueKeep = (await settings.get('queueKeep')) || true;
295
+ const queueKeep = (await settings.get('queueKeep')) ?? true;
293
296
 
294
297
  // Process notification based on required destinations
295
298
  if (!skipWebhook && addDocumentQueueJob) {