emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
@@ -41,6 +41,27 @@ const MAX_BATCH_SIZE = graphApi.MAX_BATCH_SIZE;
41
41
  // Subscription is renewed automatically. But just in case, check once in an hour
42
42
  const RENEW_WATCH_TTL = 60 * 60 * 1000; // 1h
43
43
 
44
+ // MS Graph folder hierarchy is defined by id/parentFolderId, not by "/" in folder
45
+ // names. To build an unambiguous, reversible pathName we percent-encode "%" and "/"
46
+ // inside a single displayName segment, keeping the real "/" as the delimiter BETWEEN
47
+ // segments. Encode "%" first then "/"; decode "%2F" first then "%25" so that a folder
48
+ // name literally containing "%2F" or "%25" round-trips correctly.
49
+ // Both helpers are pure and exception-safe: the typeof guard returns non-string input
50
+ // unchanged, and replaceAll on a string uses literal (not regex) replacement.
51
+ function encodeFolderSegment(name) {
52
+ if (typeof name !== 'string') {
53
+ return name;
54
+ }
55
+ return name.replaceAll('%', '%25').replaceAll('/', '%2F');
56
+ }
57
+
58
+ function decodeFolderSegment(segment) {
59
+ if (typeof segment !== 'string') {
60
+ return segment;
61
+ }
62
+ return segment.replaceAll('%2F', '/').replaceAll('%25', '%');
63
+ }
64
+
44
65
  /*
45
66
  Supported operations status:
46
67
  ✅ listMessages - with paging (cursor + page nr) and search queries (no support for to/cc/bcc queries)
@@ -625,7 +646,8 @@ class OutlookClient extends BaseClient {
625
646
  'ccRecipients',
626
647
  'bccRecipients',
627
648
  'internetMessageId',
628
- 'bodyPreview'
649
+ 'bodyPreview',
650
+ 'categories'
629
651
  ];
630
652
  expandFields = 'attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)';
631
653
  }
@@ -647,6 +669,7 @@ class OutlookClient extends BaseClient {
647
669
 
648
670
  let useOutlookSearch = false;
649
671
  let skipToken = null;
672
+ let labelFilterActive = false;
650
673
 
651
674
  // Handle search queries
652
675
  if (query.search) {
@@ -668,15 +691,48 @@ class OutlookClient extends BaseClient {
668
691
  // we need to have receivedDateTime as the first filtering property, otherwise ordering will fail
669
692
  requestQuery.$filter = `receivedDateTime gt 1970-01-01T00:00:00.000Z and ${$filter}`;
670
693
  }
694
+ // Category (label) filters compile to a categories/any() lambda. Some mailboxes refuse
695
+ // to combine that with $orderBy; remember it so we can retry without ordering below.
696
+ labelFilterActive = !!(query.search.labels && [].concat(query.search.labels.has || [], query.search.labels.not || []).some(Boolean));
671
697
  }
672
698
  }
673
699
 
674
700
  let messages;
675
701
  let totalMessages;
676
702
 
703
+ let messagesUrl = `/${this.oauth2UserPath}/${folder ? `mailFolders/${folder.id}/` : ''}messages`;
704
+ // The "not" form of a category filter may require advanced query capabilities; the header is
705
+ // harmless for the "has" form and for queries without a label filter we omit it entirely.
706
+ let requestOptions = labelFilterActive ? { headers: { ConsistencyLevel: 'eventual' } } : undefined;
707
+
677
708
  // Execute the message list request
678
709
  try {
679
- let listing = await this.request(`/${this.oauth2UserPath}/${folder ? `mailFolders/${folder.id}/` : ''}messages`, 'get', requestQuery);
710
+ let listing;
711
+ try {
712
+ listing = await this.request(messagesUrl, 'get', requestQuery, requestOptions);
713
+ } catch (err) {
714
+ // A categories/any() filter combined with $orderBy can be rejected as too complex
715
+ // ("InefficientFilter"). Retry once without server-side ordering and sort the page locally.
716
+ let graphCode = err?.oauthRequest?.response?.error?.code || '';
717
+ let graphMessage = err?.oauthRequest?.response?.error?.message || '';
718
+ let inefficientFilter = /inefficientfilter/i.test(graphCode) || /restriction or sort order is too complex/i.test(graphMessage);
719
+
720
+ if (labelFilterActive && requestQuery.$orderBy && inefficientFilter) {
721
+ this.logger.warn({
722
+ msg: 'Graph rejected ordered category filter, retrying without server-side ordering',
723
+ account: this.account,
724
+ path
725
+ });
726
+ delete requestQuery.$orderBy;
727
+ listing = await this.request(messagesUrl, 'get', requestQuery, requestOptions);
728
+ // Without $orderBy the server no longer guarantees order; sort this page newest-first
729
+ if (listing && Array.isArray(listing.value)) {
730
+ listing.value.sort((a, b) => new Date(b.receivedDateTime) - new Date(a.receivedDateTime));
731
+ }
732
+ } else {
733
+ throw err;
734
+ }
735
+ }
680
736
 
681
737
  totalMessages = !isNaN(listing['@odata.count']) ? Number(listing['@odata.count']) : undefined;
682
738
 
@@ -854,23 +910,35 @@ class OutlookClient extends BaseClient {
854
910
 
855
911
  let folder;
856
912
  if (!force) {
857
- try {
858
- folder = await this.resolveFolder('\\Trash');
859
-
860
- // If we're already in trash, force delete
861
- if (!force && folder?.specialUse === '\\Trash') {
862
- force = true;
863
- }
913
+ // Fetch the listing once for both folder resolutions
914
+ let mailboxListing = (await this.getCachedMailboxListing()) || (await this.getMailboxListing());
864
915
 
865
- if (!force && !folder) {
866
- throw new Error('Trash folder was not found');
867
- }
916
+ // If the source folder is already the Trash folder, delete permanently
917
+ let sourceFolder;
918
+ try {
919
+ sourceFolder = await this.resolveFolder(path, { mailboxListing });
868
920
  } catch (err) {
869
921
  this.logger.error({
870
- msg: 'Failed to resolve folder for Trash',
922
+ msg: 'Failed to resolve source folder',
923
+ path,
871
924
  err
872
925
  });
873
926
  }
927
+
928
+ if (sourceFolder?.specialUse === '\\Trash') {
929
+ force = true;
930
+ } else {
931
+ folder = await this.resolveFolder('\\Trash', { mailboxListing });
932
+ if (!folder) {
933
+ let error = new Error('Trash folder was not found');
934
+ error.info = {
935
+ response: 'Failed to resolve the Trash folder for the account'
936
+ };
937
+ error.code = 'TrashNotFound';
938
+ error.statusCode = 404;
939
+ throw error;
940
+ }
941
+ }
874
942
  }
875
943
 
876
944
  // Step 1. Resolve matching messages
@@ -993,27 +1061,15 @@ class OutlookClient extends BaseClient {
993
1061
  }
994
1062
 
995
1063
  /**
996
- * Update message flags (Graph API only supports \Seen and \Flagged)
1064
+ * Converts an IMAP-style flag update request into Graph API message properties.
1065
+ * Only \Seen and \Flagged can be represented in the Graph API. Set precedence is
1066
+ * handled by BaseClient#normalizeFlagUpdates.
1067
+ * @param {Object} [flags] - Flag update request ({ add, delete, set })
1068
+ * @returns {Object} Graph API PATCH properties ({ isRead, flag })
997
1069
  */
998
- async updateMessage(emailId, updates) {
999
- await this.prepare();
1000
- updates = updates || {};
1001
-
1002
- let addFlags = updates.flags?.add || [];
1003
- let deleteFlags = updates.flags?.delete || [];
1004
-
1005
- // Handle flag set operations
1006
- if (updates.flags?.set) {
1007
- for (let flag of ['\\Seen', '\\Flagged']) {
1008
- if (updates.flags.set.includes(flag)) {
1009
- addFlags.push(flag);
1010
- } else {
1011
- deleteFlags.push(flag);
1012
- }
1013
- }
1014
- }
1070
+ buildFlagUpdates(flags) {
1071
+ let { add: addFlags, delete: deleteFlags } = this.normalizeFlagUpdates(flags, ['\\Seen', '\\Flagged']);
1015
1072
 
1016
- // Map IMAP flags to Graph API properties
1017
1073
  let flagUpdates = {};
1018
1074
 
1019
1075
  if (addFlags.includes('\\Seen')) {
@@ -1030,6 +1086,19 @@ class OutlookClient extends BaseClient {
1030
1086
  flagUpdates.flag = { flagStatus: 'notFlagged' };
1031
1087
  }
1032
1088
 
1089
+ return flagUpdates;
1090
+ }
1091
+
1092
+ /**
1093
+ * Update message flags (Graph API only supports \Seen and \Flagged)
1094
+ */
1095
+ async updateMessage(emailId, updates) {
1096
+ await this.prepare();
1097
+ updates = updates || {};
1098
+
1099
+ // Map IMAP flags to Graph API properties
1100
+ let flagUpdates = this.buildFlagUpdates(updates.flags);
1101
+
1033
1102
  // Handle label (category) operations
1034
1103
  if (updates.labels) {
1035
1104
  let categories;
@@ -1122,36 +1191,8 @@ class OutlookClient extends BaseClient {
1122
1191
 
1123
1192
  updates = updates || {};
1124
1193
 
1125
- let addFlags = updates.flags?.add || [];
1126
- let deleteFlags = updates.flags?.delete || [];
1127
-
1128
- // Handle flag set operations
1129
- if (updates.flags?.set) {
1130
- for (let flag of ['\\Seen', '\\Flagged']) {
1131
- if (updates.flags.set.includes(flag)) {
1132
- addFlags.push(flag);
1133
- } else {
1134
- deleteFlags.push(flag);
1135
- }
1136
- }
1137
- }
1138
-
1139
1194
  // Map IMAP flags to Graph API properties
1140
- let flagUpdates = {};
1141
-
1142
- if (addFlags.includes('\\Seen')) {
1143
- flagUpdates.isRead = true;
1144
- }
1145
- if (deleteFlags.includes('\\Seen')) {
1146
- flagUpdates.isRead = false;
1147
- }
1148
-
1149
- if (addFlags.includes('\\Flagged')) {
1150
- flagUpdates.flag = { flagStatus: 'flagged' };
1151
- }
1152
- if (deleteFlags.includes('\\Flagged')) {
1153
- flagUpdates.flag = { flagStatus: 'notFlagged' };
1154
- }
1195
+ let flagUpdates = this.buildFlagUpdates(updates.flags);
1155
1196
 
1156
1197
  // Step 1. Resolve matching messages
1157
1198
  let emailIds = search.emailIds || (await this.searchEmailIds(path, search));
@@ -1523,7 +1564,7 @@ class OutlookClient extends BaseClient {
1523
1564
 
1524
1565
  const contentResponse = {
1525
1566
  headers: {
1526
- 'content-type': attachmentData.mimeType || 'application/octet-stream',
1567
+ 'content-type': attachmentData.contentType || 'application/octet-stream',
1527
1568
  'content-disposition': 'attachment' + filenameParam
1528
1569
  },
1529
1570
  contentType: attachmentData.contentType,
@@ -2500,7 +2541,9 @@ class OutlookClient extends BaseClient {
2500
2541
 
2501
2542
  let subPaths = path.split('/');
2502
2543
 
2503
- let displayName = subPaths.pop();
2544
+ // Decode the leaf so the literal folder name is sent to Graph; parent segments
2545
+ // stay encoded for resolveFolder (which matches against the encoded pathName).
2546
+ let displayName = decodeFolderSegment(subPaths.pop());
2504
2547
  let parentPath = subPaths.join('/');
2505
2548
 
2506
2549
  // Resolve parent folder if specified
@@ -2570,7 +2613,7 @@ class OutlookClient extends BaseClient {
2570
2613
  mailboxId: mailbox.id,
2571
2614
  path: []
2572
2615
  .concat(parentFolder?.pathName || [])
2573
- .concat(mailbox.displayName)
2616
+ .concat(encodeFolderSegment(mailbox.displayName))
2574
2617
  .join('/'),
2575
2618
  created: true
2576
2619
  };
@@ -2637,8 +2680,10 @@ class OutlookClient extends BaseClient {
2637
2680
  if (sourceName !== destinationName) {
2638
2681
  let mailbox;
2639
2682
  try {
2683
+ // sourceName/destinationName stay encoded for the comparison above;
2684
+ // decode only here so Graph receives the literal folder name.
2640
2685
  mailbox = await this.request(`/${this.oauth2UserPath}/mailFolders/${sourceFolder.id}`, 'patch', {
2641
- displayName: destinationName
2686
+ displayName: decodeFolderSegment(destinationName)
2642
2687
  });
2643
2688
  if (!mailbox) {
2644
2689
  throw new Error('Failed to rename mailbox');
@@ -3077,7 +3122,7 @@ class OutlookClient extends BaseClient {
3077
3122
  if (pathNamePrefix) {
3078
3123
  entry.parentPath = pathNamePrefix;
3079
3124
  }
3080
- entry.pathName = `${pathNamePrefix ? `${pathNamePrefix}/` : ''}${entry.displayName}`;
3125
+ entry.pathName = `${pathNamePrefix ? `${pathNamePrefix}/` : ''}${encodeFolderSegment(entry.displayName)}`;
3081
3126
  return entry;
3082
3127
  })
3083
3128
  );
@@ -3094,8 +3139,8 @@ class OutlookClient extends BaseClient {
3094
3139
 
3095
3140
  // Traverse child folders
3096
3141
  for (let entry of list) {
3097
- // do not traverse subfolders for folders with a slash in the name (would create ambiguous paths)
3098
- if (entry.childFolderCount && entry.displayName.indexOf('/') < 0) {
3142
+ // pathName percent-encodes any literal "/" in the segment, so child paths stay unambiguous
3143
+ if (entry.childFolderCount) {
3099
3144
  await traverse(entry.pathName, entry.id);
3100
3145
  }
3101
3146
  }
@@ -3103,10 +3148,8 @@ class OutlookClient extends BaseClient {
3103
3148
 
3104
3149
  await traverse();
3105
3150
 
3106
- // keep only real folders and folders that do not contain slash in the name
3107
- mailboxListing = mailboxListing.filter(
3108
- entry => (!entry['@odata.type'] || /^#?microsoft\.graph\.mailFolder$/.test(entry['@odata.type'])) && entry.displayName.indexOf('/') < 0
3109
- );
3151
+ // keep only real mail folders (drop search folders and other non-mailFolder types)
3152
+ mailboxListing = mailboxListing.filter(entry => !entry['@odata.type'] || /^#?microsoft\.graph\.mailFolder$/.test(entry['@odata.type']));
3110
3153
 
3111
3154
  return mailboxListing;
3112
3155
  }
@@ -3120,8 +3163,9 @@ class OutlookClient extends BaseClient {
3120
3163
 
3121
3164
  path = [].concat(path || []).join('/');
3122
3165
 
3123
- let cachedListing = await this.getCachedMailboxListing();
3124
- let mailboxListing = cachedListing || (await this.getMailboxListing());
3166
+ // Callers that resolve multiple folders can pass a pre-fetched listing to avoid
3167
+ // repeated cache reads or Graph API folder crawls
3168
+ let mailboxListing = options.mailboxListing || (await this.getCachedMailboxListing()) || (await this.getMailboxListing());
3125
3169
 
3126
3170
  if (options.byId) {
3127
3171
  return mailboxListing.find(entry => entry.id === path);
@@ -3691,6 +3735,7 @@ class OutlookClient extends BaseClient {
3691
3735
  bcc: extended && messageData.bccRecipients?.length ? messageData.bccRecipients.map(entry => entry.emailAddress).filter(entry => entry) : undefined,
3692
3736
 
3693
3737
  messageId: messageData.internetMessageId,
3738
+ inReplyTo: messageData.inReplyTo || undefined,
3694
3739
 
3695
3740
  headers: (extended && messageData.headers) || undefined,
3696
3741
 
@@ -4385,6 +4430,20 @@ class OutlookClient extends BaseClient {
4385
4430
  filterParts.push(`receivedDateTime gt ${this.formatSearchTerm(search.since || search.sentSince, false)}`);
4386
4431
  }
4387
4432
 
4433
+ // Category (label) filters - "has" matches messages tagged with the category, "not" excludes them
4434
+ if (search.labels && typeof search.labels === 'object') {
4435
+ for (let category of [].concat(search.labels.has || [])) {
4436
+ if (category) {
4437
+ filterParts.push(`categories/any(c:c eq ${this.formatSearchTerm(category)})`);
4438
+ }
4439
+ }
4440
+ for (let category of [].concat(search.labels.not || [])) {
4441
+ if (category) {
4442
+ filterParts.push(`not (categories/any(c:c eq ${this.formatSearchTerm(category)}))`);
4443
+ }
4444
+ }
4445
+ }
4446
+
4388
4447
  // Limited header search support
4389
4448
  for (let headerKey of Object.keys(search.header || {})) {
4390
4449
  switch (headerKey.toLowerCase().trim()) {
@@ -4522,4 +4581,4 @@ class OutlookClient extends BaseClient {
4522
4581
  }
4523
4582
  }
4524
4583
 
4525
- module.exports = { OutlookClient };
4584
+ module.exports = { OutlookClient, encodeFolderSegment, decodeFolderSegment };
package/lib/export.js CHANGED
@@ -66,6 +66,39 @@ function createError(message, code, statusCode) {
66
66
  return err;
67
67
  }
68
68
 
69
+ // Error classifiers used by the export worker to decide between retrying, skipping a single
70
+ // message, or failing the whole export job.
71
+
72
+ function isTransientError(err) {
73
+ if (['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH'].includes(err.code)) {
74
+ return true;
75
+ }
76
+ if (err.statusCode >= 500 && err.statusCode < 600) {
77
+ return true;
78
+ }
79
+ if (err.code === 'Timeout' || err.message?.includes('timeout')) {
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ // Account.assertMessageFound reports a missing message as a Boom 404 that carries the
86
+ // machine-readable code and status only in err.output, while client/RPC errors set plain
87
+ // err.code/err.statusCode - check both shapes
88
+ function isSkippableError(err) {
89
+ let errCode = err.output?.payload?.code || err.code;
90
+ let statusCode = err.statusCode || err.output?.statusCode;
91
+ return errCode === 'MessageNotFound' || statusCode === 404 || err.message?.includes('Failed to generate message ID');
92
+ }
93
+
94
+ // Account.listMessages reports unknown folders as FolderNotFound (Boom error with the code in
95
+ // the payload), the Gmail and Outlook backends throw NotFound directly. Outlook bulk delete can
96
+ // also throw TrashNotFound, but the export worker never deletes messages, so it is not matched.
97
+ function isFolderMissingError(err) {
98
+ let errCode = err.output?.payload?.code || err.code;
99
+ return ['FolderNotFound', 'NotFound'].includes(errCode);
100
+ }
101
+
69
102
  async function tryAddToActiveSet(account, exportId) {
70
103
  const maxConcurrent = (await settings.get('exportMaxConcurrent')) || DEFAULT_EXPORT_MAX_CONCURRENT;
71
104
  const maxGlobalConcurrent = (await settings.get('exportMaxGlobalConcurrent')) || DEFAULT_EXPORT_MAX_GLOBAL_CONCURRENT;
@@ -114,6 +147,13 @@ async function getExportMaxAge() {
114
147
  return DEFAULT_EXPORT_MAX_AGE;
115
148
  }
116
149
 
150
+ // Resolve the retention window into the Redis `expire` TTL (seconds) and an absolute `expiresAt`
151
+ // timestamp (ms), used wherever an export key's expiry is (re)set.
152
+ async function getExportExpiry() {
153
+ const maxAge = await getExportMaxAge();
154
+ return { ttl: Math.ceil(maxAge / 1000), expiresAt: Date.now() + maxAge };
155
+ }
156
+
117
157
  function toTimestamp(date) {
118
158
  const ts = new Date(date).getTime();
119
159
  if (isNaN(ts)) {
@@ -168,7 +208,8 @@ class Export {
168
208
  startDate,
169
209
  endDate,
170
210
  textType: options.textType || '*',
171
- maxBytes: options.maxBytes || 5 * 1024 * 1024,
211
+ // Preserve an explicit 0 ("unlimited" per the API contract); only fall back when unset.
212
+ maxBytes: Number.isInteger(options.maxBytes) ? options.maxBytes : 5 * 1024 * 1024,
172
213
  includeAttachments: options.includeAttachments ? '1' : '0',
173
214
  isEncrypted: isEncrypted ? '1' : '0',
174
215
  foldersScanned: 0,
@@ -178,7 +219,6 @@ class Export {
178
219
  messagesSkipped: 0,
179
220
  bytesWritten: 0,
180
221
  filePath,
181
- lastProcessedScore: 0,
182
222
  created: now,
183
223
  expiresAt,
184
224
  error: ''
@@ -325,11 +365,39 @@ class Export {
325
365
 
326
366
  static async startProcessing(account, exportId) {
327
367
  const exportKey = getExportKey(account, exportId);
328
- const maxAge = await getExportMaxAge();
329
- const ttl = Math.ceil(maxAge / 1000);
330
- const newExpiresAt = Date.now() + maxAge;
368
+ const queueKey = getExportQueueKey(account, exportId);
369
+ const { ttl, expiresAt } = await getExportExpiry();
370
+
371
+ // Reset progress and clear any previously indexed queue so that each (re)run -- including a
372
+ // BullMQ stalled-job reprocess -- rebuilds the queue and rewrites the (truncated) output file
373
+ // from scratch, keeping counters and file content consistent.
374
+ await redis
375
+ .multi()
376
+ .hmset(exportKey, {
377
+ status: 'processing',
378
+ phase: 'indexing',
379
+ expiresAt,
380
+ foldersScanned: 0,
381
+ foldersTotal: 0,
382
+ messagesQueued: 0,
383
+ messagesExported: 0,
384
+ messagesSkipped: 0,
385
+ bytesWritten: 0,
386
+ truncated: '0'
387
+ })
388
+ .del(queueKey)
389
+ .expire(exportKey, ttl)
390
+ .exec();
391
+ }
392
+
393
+ static async extendExpiry(account, exportId) {
394
+ const exportKey = getExportKey(account, exportId);
395
+ const queueKey = getExportQueueKey(account, exportId);
396
+ const { ttl, expiresAt } = await getExportExpiry();
331
397
 
332
- await redis.multi().hmset(exportKey, { status: 'processing', phase: 'indexing', expiresAt: newExpiresAt }).expire(exportKey, ttl).exec();
398
+ // Keep both the export hash and the pending-message queue alive for the full retention window
399
+ // while a long export is still running, and surface the refreshed expiry to status readers.
400
+ await redis.multi().hset(exportKey, 'expiresAt', expiresAt).expire(exportKey, ttl).expire(queueKey, ttl).exec();
333
401
  }
334
402
 
335
403
  static async queueMessage(account, exportId, messageInfo) {
@@ -355,19 +423,23 @@ class Export {
355
423
  await multi.exec();
356
424
  }
357
425
 
358
- static async getNextBatch(account, exportId, lastScore, limit) {
426
+ static async getNextBatch(account, exportId, limit) {
359
427
  const queueKey = getExportQueueKey(account, exportId);
360
- // Use exclusive lower bound to avoid re-processing messages at batch boundaries
361
- const minScore = lastScore > 0 ? '(' + lastScore : lastScore;
362
- const results = await redis.zrangebyscore(queueKey, minScore, '+inf', 'WITHSCORES', 'LIMIT', 0, limit);
428
+ // Atomically pop the lowest-scored (oldest) messages so each is processed exactly once.
429
+ // This avoids cursor-based reprocessing and the boundary drops that an exclusive score bound
430
+ // caused when two messages shared the same score.
431
+ const results = await redis.zpopmin(queueKey, limit);
363
432
 
433
+ // ZPOPMIN returns [member, score, member, score, ...]; only the decoded member is needed.
364
434
  const messages = [];
365
435
  for (let i = 0; i < results.length; i += 2) {
366
436
  try {
367
- const info = msgpack.decode(Buffer.from(results[i], 'base64url'));
368
- messages.push({ ...info, score: Number(results[i + 1]) });
437
+ messages.push(msgpack.decode(Buffer.from(results[i], 'base64url')));
369
438
  } catch (err) {
370
439
  logger.error({ msg: 'Failed to decode message info', account, exportId, err });
440
+ // ZPOPMIN already removed the entry, so it will never be exported;
441
+ // count it as skipped to keep messagesQueued === exported + skipped
442
+ await Export.incrementSkipped(account, exportId);
371
443
  }
372
444
  }
373
445
 
@@ -383,19 +455,20 @@ class Export {
383
455
  await redis.hincrby(getExportKey(account, exportId), 'messagesSkipped', 1);
384
456
  }
385
457
 
386
- static async updateLastProcessedScore(account, exportId, score) {
387
- await redis.hset(getExportKey(account, exportId), 'lastProcessedScore', score);
388
- }
389
-
390
458
  static async complete(account, exportId) {
391
459
  const exportKey = getExportKey(account, exportId);
392
460
  const queueKey = getExportQueueKey(account, exportId);
393
461
 
462
+ // Start the retention window at completion time so a long-running export stays downloadable for
463
+ // the full retention period rather than the time left over from when processing started.
464
+ const { ttl, expiresAt } = await getExportExpiry();
465
+
394
466
  await redis
395
467
  .multi()
396
- .hmset(exportKey, { status: 'completed', phase: 'complete' })
468
+ .hmset(exportKey, { status: 'completed', phase: 'complete', expiresAt })
397
469
  .del(queueKey)
398
470
  .srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`)
471
+ .expire(exportKey, ttl)
399
472
  .exec();
400
473
 
401
474
  logger.info({ msg: 'Export completed', account, exportId });
@@ -437,8 +510,9 @@ class Export {
437
510
 
438
511
  for (const entry of activeExports) {
439
512
  try {
440
- // Find ':exp_' as separator since account IDs may contain colons
441
- const separatorIndex = entry.indexOf(':exp_');
513
+ // Find ':exp_' as separator since account IDs may contain colons. The export id is
514
+ // always the trailing ':exp_<hex>' segment, so match the last occurrence.
515
+ const separatorIndex = entry.lastIndexOf(':exp_');
442
516
  if (separatorIndex === -1) continue;
443
517
  const account = entry.substring(0, separatorIndex);
444
518
  const exportId = entry.substring(separatorIndex + 1);
@@ -452,7 +526,7 @@ class Export {
452
526
  continue;
453
527
  }
454
528
 
455
- if (data && (data.status === 'processing' || data.status === 'queued' || data.status === 'indexing' || data.status === 'cancelled')) {
529
+ if (data && (data.status === 'processing' || data.status === 'queued' || data.status === 'cancelled')) {
456
530
  const job = await exportQueue.getJob(exportId).catch(() => null);
457
531
  if (job) {
458
532
  await job.remove().catch(() => {});
@@ -541,5 +615,8 @@ module.exports = {
541
615
  getExportQueueKey,
542
616
  getExportPath,
543
617
  getExportMaxAge,
618
+ isTransientError,
619
+ isSkippableError,
620
+ isFolderMissingError,
544
621
  DEFAULT_EXPORT_MAX_MESSAGE_SIZE
545
622
  };
@@ -21,11 +21,11 @@ for (let key of Object.keys(process.env)) {
21
21
  if (/^EENGINE_FEATURE_/i.test(key)) {
22
22
  let feature = formatFeatureKey(key.substring('EENGINE_FEATURE_'.length));
23
23
  if (feature) {
24
- let value = /^y|1|t/i.test((process.env[key] || '').toString().trim());
24
+ let value = /^(y|yes|true|t|1)$/i.test((process.env[key] || '').toString().trim());
25
25
  if (value) {
26
26
  FEATURES.add(feature);
27
27
  } else {
28
- FEATURES.remove(feature);
28
+ FEATURES.delete(feature);
29
29
  }
30
30
  }
31
31
  }
package/lib/gateway.js CHANGED
@@ -179,13 +179,8 @@ class Gateway {
179
179
  throw error;
180
180
  }
181
181
 
182
- let state = false;
183
- if (result[0][1] && result[0][1].gateway) {
184
- // existing user
185
- state = 'existing';
186
- } else {
187
- state = 'new';
188
- }
182
+ // HGET returns the previous value if the gateway entry already existed
183
+ let state = result[0][1] ? 'existing' : 'new';
189
184
 
190
185
  return { gateway: this.gateway, state };
191
186
  }
@@ -243,7 +238,7 @@ class Gateway {
243
238
  let result = await this.redis.multi().del(this.getGatewayKey()).srem(`${REDIS_PREFIX}gateways`, this.gateway).exec();
244
239
  if (!result) {
245
240
  return {
246
- account: this.account,
241
+ gateway: this.gateway,
247
242
  deleted: false
248
243
  };
249
244
  }
@@ -256,7 +251,7 @@ class Gateway {
256
251
 
257
252
  if (!result[0] || !result[0][1]) {
258
253
  return {
259
- account: this.account,
254
+ gateway: this.gateway,
260
255
  deleted: false
261
256
  };
262
257
  }
@@ -229,15 +229,15 @@ async function processMessage(data, licenseInfo) {
229
229
  }
230
230
 
231
231
  if (data.headers) {
232
- for (let key of Object.keys(headers)) {
232
+ for (let key of Object.keys(data.headers)) {
233
233
  let casedKey = key.replace(/^.|-./g, c => c.toUpperCase());
234
- switch (key) {
234
+ switch (key.toLowerCase()) {
235
235
  case 'in-reply-to':
236
236
  case 'references':
237
- headers.update(casedKey, headers[key]);
237
+ headers.update(casedKey, data.headers[key]);
238
238
  break;
239
239
  default:
240
- headers.add(casedKey, headers[key]);
240
+ headers.add(casedKey, data.headers[key]);
241
241
  break;
242
242
  }
243
243
  }
@@ -266,7 +266,7 @@ async function processMessage(data, licenseInfo) {
266
266
  });
267
267
 
268
268
  let trackClicks = typeof data.trackClicks === 'boolean' ? data.trackClicks : trackingEnabled;
269
- let trackOpens = typeof data.trackOpens === 'boolean' ? data.trackClicks : trackingEnabled;
269
+ let trackOpens = typeof data.trackOpens === 'boolean' ? data.trackOpens : trackingEnabled;
270
270
 
271
271
  return {
272
272
  raw: message,
@@ -15,6 +15,20 @@ module.exports = {
15
15
  });
16
16
  }
17
17
 
18
+ if (this._server.options.disableSTARTTLS) {
19
+ // STARTTLS is not advertised in this mode (e.g. the EmailEngine proxy);
20
+ // refuse it instead of upgrading against the built-in fallback cert.
21
+ return callback(null, {
22
+ response: 'NO',
23
+ message: 'STARTTLS not available'
24
+ });
25
+ }
26
+
27
+ // Block any further (possibly pipelined / injected) commands until the TLS
28
+ // handshake completes. _onCommand ignores commands while _upgrading is set,
29
+ // so a command sent in the same chunk as STARTTLS cannot be executed.
30
+ this._upgrading = true;
31
+
18
32
  setImmediate(upgrade.bind(null, this));
19
33
 
20
34
  callback(null, {
@@ -94,6 +108,10 @@ function upgrade(connection) {
94
108
  (cipher && cipher.name) || 'N/A'
95
109
  );
96
110
 
111
+ // Discard any plaintext the parser buffered before the handshake so it
112
+ // cannot be injected into the now-encrypted session.
113
+ connection._parser.reset();
114
+
97
115
  connection._socket.pipe(connection._parser);
98
116
  connection.writeStream.pipe(connection._socket);
99
117
  });