emailengine-app 2.61.1 → 2.61.2

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 (136) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +17 -178
  5. package/lib/api-routes/account-routes.js +1006 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +1 -1
  14. package/lib/email-client/message-builder.js +566 -0
  15. package/lib/email-client/notification-handler.js +314 -0
  16. package/lib/email-client/outlook/graph-api.js +326 -0
  17. package/lib/email-client/outlook-client.js +159 -113
  18. package/lib/email-client/smtp-pool-manager.js +196 -0
  19. package/lib/imapproxy/imap-server.js +3 -12
  20. package/lib/oauth/gmail.js +4 -4
  21. package/lib/oauth/mail-ru.js +30 -5
  22. package/lib/oauth/outlook.js +57 -3
  23. package/lib/oauth/pubsub/google.js +30 -11
  24. package/lib/oauth/scope-checker.js +202 -0
  25. package/lib/oauth2-apps.js +8 -4
  26. package/lib/redis-operations.js +484 -0
  27. package/lib/routes-ui.js +283 -2582
  28. package/lib/tools.js +4 -196
  29. package/lib/ui-routes/account-routes.js +1931 -0
  30. package/lib/ui-routes/admin-config-routes.js +1233 -0
  31. package/lib/ui-routes/admin-entities-routes.js +2367 -0
  32. package/lib/ui-routes/oauth-routes.js +992 -0
  33. package/lib/utils/network.js +237 -0
  34. package/package.json +9 -9
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +78 -18
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +85 -82
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +63 -71
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +84 -82
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +85 -82
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +84 -82
  48. package/translations/messages.pot +74 -87
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +86 -82
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +84 -82
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/lib/imapproxy/imap-core/test/client.js +0 -46
  78. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  79. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  80. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  81. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  82. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  83. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  84. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  88. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  89. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  90. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  92. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  93. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  94. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  95. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  96. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  97. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  98. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  99. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  100. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  101. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  102. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  103. package/test/api-test.js +0 -899
  104. package/test/autoreply-test.js +0 -327
  105. package/test/bounce-test.js +0 -151
  106. package/test/complaint-test.js +0 -256
  107. package/test/fixtures/autoreply/LICENSE +0 -27
  108. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  109. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  110. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  111. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  112. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  113. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  114. package/test/fixtures/bounces/163.eml +0 -2521
  115. package/test/fixtures/bounces/fastmail.eml +0 -242
  116. package/test/fixtures/bounces/gmail.eml +0 -252
  117. package/test/fixtures/bounces/hotmail.eml +0 -655
  118. package/test/fixtures/bounces/mailru.eml +0 -121
  119. package/test/fixtures/bounces/outlook.eml +0 -1107
  120. package/test/fixtures/bounces/postfix.eml +0 -101
  121. package/test/fixtures/bounces/rambler.eml +0 -116
  122. package/test/fixtures/bounces/workmail.eml +0 -142
  123. package/test/fixtures/bounces/yahoo.eml +0 -139
  124. package/test/fixtures/bounces/zoho.eml +0 -83
  125. package/test/fixtures/bounces/zonemta.eml +0 -100
  126. package/test/fixtures/complaints/LICENSE +0 -27
  127. package/test/fixtures/complaints/amazonses.eml +0 -72
  128. package/test/fixtures/complaints/dmarc.eml +0 -59
  129. package/test/fixtures/complaints/hotmail.eml +0 -49
  130. package/test/fixtures/complaints/optout.eml +0 -40
  131. package/test/fixtures/complaints/standard-arf.eml +0 -68
  132. package/test/fixtures/complaints/yahoo.eml +0 -68
  133. package/test/oauth2-apps-test.js +0 -301
  134. package/test/sendonly-test.js +0 -160
  135. package/test/test-config.js +0 -34
  136. package/test/webhooks-server.js +0 -39
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { BaseClient, metricsMeta } = require('./base-client');
4
4
  const { Account } = require('../account');
5
+ const { checkAccountScopes } = require('../oauth/scope-checker');
5
6
  const settings = require('../settings');
6
7
  const { oauth2Apps } = require('../oauth2-apps');
7
8
  const getSecret = require('../get-secret');
@@ -13,6 +14,9 @@ const crypto = require('crypto');
13
14
  const { Gateway } = require('../gateway');
14
15
  const { detectMimeType, detectExtension } = require('nodemailer/lib/mime-funcs/mime-types');
15
16
 
17
+ // Import Graph API module for MS Graph API handling
18
+ const graphApi = require('./outlook/graph-api');
19
+
16
20
  const {
17
21
  REDIS_PREFIX,
18
22
  AUTH_ERROR_NOTIFY,
@@ -20,13 +24,17 @@ const {
20
24
  EMAIL_SENT_NOTIFY,
21
25
  OUTLOOK_EXPIRATION_TIME,
22
26
  OUTLOOK_EXPIRATION_RENEW_TIME,
27
+ OUTLOOK_SUBSCRIPTION_LOCK_TTL,
28
+ OUTLOOK_MAX_RETRY_ATTEMPTS,
29
+ OUTLOOK_RETRY_BASE_DELAY,
30
+ OUTLOOK_RETRY_MAX_DELAY,
23
31
  MESSAGE_UPDATED_NOTIFY,
24
32
  MESSAGE_DELETED_NOTIFY,
25
33
  MESSAGE_MISSING_NOTIFY
26
34
  } = require('../consts');
27
35
 
28
36
  // Maximum number of operations in a single batch request to Microsoft Graph API
29
- const MAX_BATCH_SIZE = 20;
37
+ const MAX_BATCH_SIZE = graphApi.MAX_BATCH_SIZE;
30
38
 
31
39
  // Subscription is renewed automatically. But just in case, check once in an hour
32
40
  const RENEW_WATCH_TTL = 60 * 60 * 1000; // 1h
@@ -76,6 +84,7 @@ class OutlookClient extends BaseClient {
76
84
  // Flags for background processing
77
85
  this.processingHistory = null;
78
86
  this.renewWatchTimer = null;
87
+ this.renewalInProgress = false; // In-memory flag to prevent concurrent subscription renewals
79
88
 
80
89
  // MS Graph webhook subscription state (for metrics)
81
90
  // Possible values: 'unset', 'valid', 'expired', 'failed', 'pending'
@@ -86,87 +95,22 @@ class OutlookClient extends BaseClient {
86
95
  * Makes authenticated requests to Microsoft Graph API
87
96
  * Handles token management and error responses
88
97
  */
89
- async request(...args) {
90
- let result, accessToken;
91
- try {
92
- accessToken = await this.getToken();
93
- } catch (err) {
94
- this.logger.error({ msg: 'Failed to load access token', account: this.account, err });
95
- throw err;
96
- }
97
-
98
- let [url, method, payload, options = {}] = args;
99
-
100
- try {
101
- if (!this.oAuth2Client) {
102
- await this.getClient();
103
- }
104
-
105
- options.headers = options.headers || {};
106
-
107
- // Build Prefer header with multiple preferences
108
- // Request immutable IDs that don't change when messages are moved between folders
109
- // https://learn.microsoft.com/en-us/graph/outlook-immutable-id
110
- let preferValues = ['IdType="ImmutableId"'];
111
-
112
- // If caller already set a Prefer header, merge it
113
- if (options.headers.Prefer) {
114
- preferValues.push(options.headers.Prefer);
115
- }
116
-
117
- options.headers.Prefer = preferValues.join(', ');
118
-
119
- // Construct full API URL if not already absolute
120
- let apiUrl = /^https:/.test(url) ? url : new URL(`/v1.0${url}`, this.oAuth2Client.apiBase).href;
121
-
122
- result = await this.oAuth2Client.request(accessToken, apiUrl, method, payload, options);
123
-
124
- // Track successful API request
125
- metricsMeta({ account: this.account }, this.logger, 'oauth2ApiRequest', 'inc', { status: 'success', provider: 'outlook', statusCode: '200' });
126
- } catch (err) {
127
- // Track failed API request
128
- const statusCode = String(err.oauthRequest?.status || 0);
129
- metricsMeta({ account: this.account }, this.logger, 'oauth2ApiRequest', 'inc', { status: 'failure', provider: 'outlook', statusCode });
130
-
131
- // Handle specific Graph API error codes
132
- switch (err.oauthRequest?.response?.error?.code) {
133
- case 'ErrorExecuteSearchStaleData': {
134
- // Search cursor has expired
135
- this.logger.error({ msg: 'Invalid or expired paging cursor', account: this.account, err });
136
- let error = new Error('Invalid or expired paging cursor');
137
- error.code = 'InvalidPagingCursor';
138
- error.statusCode = err.oauthRequest?.status || 500;
139
- throw error;
140
- }
141
- }
142
-
143
- // Handle HTTP status codes
144
- const status = err.oauthRequest?.status;
145
- const isClientError = status >= 400 && status < 500;
146
-
147
- switch (status) {
148
- case 401:
149
- this.logger.error({ msg: 'Failed to authenticate API request', account: this.account, accessToken, err });
150
- throw err;
151
-
152
- case 429:
153
- // Rate limiting
154
- this.logger.error({ msg: 'API request was throttled', account: this.account, err });
155
- throw err;
156
-
157
- default:
158
- // Log client errors (4xx) at debug level - these are expected operational errors
159
- // Log server errors (5xx) and other failures at error level
160
- if (isClientError) {
161
- this.logger.debug({ msg: 'API request failed with client error', account: this.account, err });
162
- } else {
163
- this.logger.error({ msg: 'Failed to run API request', account: this.account, err });
164
- }
165
- throw err;
166
- }
167
- }
98
+ async request(url, method, payload, options = {}) {
99
+ return graphApi.request(this, url, method, payload, options);
100
+ }
168
101
 
169
- return result;
102
+ /**
103
+ * Makes authenticated requests to Microsoft Graph API with automatic retry on rate limiting
104
+ * Implements exponential backoff using Retry-After header or default delays
105
+ * @param {string} url - API endpoint URL
106
+ * @param {string} method - HTTP method
107
+ * @param {object} payload - Request payload
108
+ * @param {object} options - Request options
109
+ * @param {number} options.maxRetries - Maximum number of retries (default: 3)
110
+ * @returns {Promise<object>} API response
111
+ */
112
+ async requestWithRetry(url, method, payload, options = {}) {
113
+ return graphApi.requestWithRetry(this, url, method, payload, options);
170
114
  }
171
115
 
172
116
  // PUBLIC METHODS
@@ -191,7 +135,7 @@ class OutlookClient extends BaseClient {
191
135
  // reinitialized to detect the change. Consider checking scopes periodically if dynamic
192
136
  // scope changes become common.
193
137
  const scopes = accountData.oauth2?.accessToken?.scope || accountData.oauth2?.scope || [];
194
- const { hasSendScope, hasReadScope } = this.accountObject.checkAccountScopes('outlook', scopes);
138
+ const { hasSendScope, hasReadScope } = checkAccountScopes('outlook', scopes, this.logger);
195
139
  const isSendOnly = hasSendScope && !hasReadScope;
196
140
 
197
141
  this.logger.debug({
@@ -776,24 +720,37 @@ class OutlookClient extends BaseClient {
776
720
 
777
721
  /**
778
722
  * Submit a batch of delete operations to Graph API
723
+ * Uses requestWithRetry for automatic retry on rate limiting
779
724
  */
780
725
  let submitBatch = async () => {
781
726
  let responseData;
782
727
  try {
783
- responseData = await this.request(`/$batch`, 'post', {
728
+ responseData = await this.requestWithRetry(`/$batch`, 'post', {
784
729
  requests: batch
785
730
  });
786
731
  for (let response of responseData?.responses || []) {
732
+ let emailId = messageMap.get(response.id);
787
733
  if (response?.status >= 200 && response?.status < 300) {
788
- let emailId = messageMap.get(response.id);
789
734
  if (emailId) {
790
735
  updatedEmailIds.push(emailId);
791
736
  }
737
+ } else {
738
+ // Log individual batch item failures for debugging
739
+ this.logger.warn({
740
+ msg: 'Batch item failed',
741
+ account: this.account,
742
+ operation: 'delete',
743
+ emailId,
744
+ status: response?.status,
745
+ error: response?.body?.error
746
+ });
792
747
  }
793
748
  }
794
749
  } catch (err) {
795
750
  this.logger.error({
796
751
  msg: 'Failed to run batch operation',
752
+ account: this.account,
753
+ operation: 'delete',
797
754
  err
798
755
  });
799
756
  throw err;
@@ -810,12 +767,19 @@ class OutlookClient extends BaseClient {
810
767
  let reqId = `msg_${++idGen}`;
811
768
  messageMap.set(reqId, emailId);
812
769
 
770
+ // Common headers for batch requests - include ImmutableId preference
771
+ const batchHeaders = {
772
+ 'Content-Type': 'application/json',
773
+ Prefer: 'IdType="ImmutableId"'
774
+ };
775
+
813
776
  if (force) {
814
777
  // Permanent delete
815
778
  return {
816
779
  id: reqId,
817
780
  method: 'DELETE',
818
- url: `/${this.oauth2UserPath}/messages/${emailId}`
781
+ url: `/${this.oauth2UserPath}/messages/${emailId}`,
782
+ headers: batchHeaders
819
783
  };
820
784
  } else {
821
785
  // Move to trash
@@ -824,9 +788,7 @@ class OutlookClient extends BaseClient {
824
788
  method: 'POST',
825
789
  url: `/${this.oauth2UserPath}/messages/${emailId}/move`,
826
790
  body: { destinationId: 'deleteditems' },
827
- headers: {
828
- 'Content-Type': 'application/json'
829
- }
791
+ headers: batchHeaders
830
792
  };
831
793
  }
832
794
  };
@@ -1044,20 +1006,31 @@ class OutlookClient extends BaseClient {
1044
1006
  let submitFetchBatch = async () => {
1045
1007
  let responseData;
1046
1008
  try {
1047
- responseData = await this.request(`/$batch`, 'post', {
1009
+ responseData = await this.requestWithRetry(`/$batch`, 'post', {
1048
1010
  requests: fetchBatch
1049
1011
  });
1050
1012
  for (let response of responseData?.responses || []) {
1013
+ let emailId = fetchMessageMap.get(response.id);
1051
1014
  if (response?.status >= 200 && response?.status < 300 && response.body) {
1052
- let emailId = fetchMessageMap.get(response.id);
1053
1015
  if (emailId) {
1054
1016
  currentCategories.set(emailId, response.body.categories || []);
1055
1017
  }
1018
+ } else if (emailId) {
1019
+ // Log individual batch item failures
1020
+ this.logger.warn({
1021
+ msg: 'Batch item failed',
1022
+ account: this.account,
1023
+ operation: 'fetchCategories',
1024
+ emailId,
1025
+ status: response?.status,
1026
+ error: response?.body?.error
1027
+ });
1056
1028
  }
1057
1029
  }
1058
1030
  } catch (err) {
1059
1031
  this.logger.error({
1060
1032
  msg: 'Failed to fetch current categories',
1033
+ account: this.account,
1061
1034
  err
1062
1035
  });
1063
1036
  }
@@ -1071,7 +1044,10 @@ class OutlookClient extends BaseClient {
1071
1044
  fetchBatch.push({
1072
1045
  id: reqId,
1073
1046
  method: 'GET',
1074
- url: `/${this.oauth2UserPath}/messages/${emailId}?$select=categories`
1047
+ url: `/${this.oauth2UserPath}/messages/${emailId}?$select=categories`,
1048
+ headers: {
1049
+ Prefer: 'IdType="ImmutableId"'
1050
+ }
1075
1051
  });
1076
1052
 
1077
1053
  if (fetchBatch.length >= MAX_BATCH_SIZE) {
@@ -1092,20 +1068,32 @@ class OutlookClient extends BaseClient {
1092
1068
  let submitBatch = async () => {
1093
1069
  let responseData;
1094
1070
  try {
1095
- responseData = await this.request(`/$batch`, 'post', {
1071
+ responseData = await this.requestWithRetry(`/$batch`, 'post', {
1096
1072
  requests: batch
1097
1073
  });
1098
1074
  for (let response of responseData?.responses || []) {
1075
+ let emailId = messageMap.get(response.id);
1099
1076
  if (response?.status >= 200 && response?.status < 300) {
1100
- let emailId = messageMap.get(response.id);
1101
1077
  if (emailId) {
1102
1078
  updatedEmailIds.push(emailId);
1103
1079
  }
1080
+ } else {
1081
+ // Log individual batch item failures for debugging
1082
+ this.logger.warn({
1083
+ msg: 'Batch item failed',
1084
+ account: this.account,
1085
+ operation: 'update',
1086
+ emailId,
1087
+ status: response?.status,
1088
+ error: response?.body?.error
1089
+ });
1104
1090
  }
1105
1091
  }
1106
1092
  } catch (err) {
1107
1093
  this.logger.error({
1108
1094
  msg: 'Failed to run batch operation',
1095
+ account: this.account,
1096
+ operation: 'update',
1109
1097
  err
1110
1098
  });
1111
1099
  throw err;
@@ -1150,7 +1138,8 @@ class OutlookClient extends BaseClient {
1150
1138
  url: `/${this.oauth2UserPath}/messages/${emailId}`,
1151
1139
  body: bodyUpdates,
1152
1140
  headers: {
1153
- 'Content-Type': 'application/json'
1141
+ 'Content-Type': 'application/json',
1142
+ Prefer: 'IdType="ImmutableId"'
1154
1143
  }
1155
1144
  };
1156
1145
  };
@@ -1272,20 +1261,32 @@ class OutlookClient extends BaseClient {
1272
1261
  let submitBatch = async () => {
1273
1262
  let responseData;
1274
1263
  try {
1275
- responseData = await this.request(`/$batch`, 'post', {
1264
+ responseData = await this.requestWithRetry(`/$batch`, 'post', {
1276
1265
  requests: batch
1277
1266
  });
1278
1267
  for (let response of responseData?.responses || []) {
1268
+ let emailId = messageMap.get(response.id);
1279
1269
  if (response?.status >= 200 && response?.status < 300) {
1280
- let emailId = messageMap.get(response.id);
1281
1270
  if (emailId) {
1282
1271
  updatedEmailIds.push(emailId);
1283
1272
  }
1273
+ } else {
1274
+ // Log individual batch item failures for debugging
1275
+ this.logger.warn({
1276
+ msg: 'Batch item failed',
1277
+ account: this.account,
1278
+ operation: 'move',
1279
+ emailId,
1280
+ status: response?.status,
1281
+ error: response?.body?.error
1282
+ });
1284
1283
  }
1285
1284
  }
1286
1285
  } catch (err) {
1287
1286
  this.logger.error({
1288
1287
  msg: 'Failed to run batch operation',
1288
+ account: this.account,
1289
+ operation: 'move',
1289
1290
  err
1290
1291
  });
1291
1292
  throw err;
@@ -1304,7 +1305,8 @@ class OutlookClient extends BaseClient {
1304
1305
  url: `/${this.oauth2UserPath}/messages/${emailId}/move`,
1305
1306
  body: { destinationId: targetFolder.id },
1306
1307
  headers: {
1307
- 'Content-Type': 'application/json'
1308
+ 'Content-Type': 'application/json',
1309
+ Prefer: 'IdType="ImmutableId"'
1308
1310
  }
1309
1311
  };
1310
1312
  };
@@ -2522,8 +2524,24 @@ class OutlookClient extends BaseClient {
2522
2524
  try {
2523
2525
  deltaRes = await this.request(deltaReqUrl);
2524
2526
  } catch (err) {
2525
- this.logger.error({ msg: 'Failed to check mailbox folder delta', err });
2526
- // might be faulty entry, so clear it
2527
+ const errorCode = err.oauthRequest?.response?.error?.code;
2528
+
2529
+ // Handle delta token expiration - MS Graph returns these codes when delta is stale
2530
+ if (errorCode === 'resyncRequired' || errorCode === 'syncStateNotFound' || errorCode === 'InvalidSyncStateData') {
2531
+ this.logger.info({
2532
+ msg: 'Delta token expired, starting fresh sync',
2533
+ account: this.account,
2534
+ errorCode
2535
+ });
2536
+ } else {
2537
+ this.logger.error({
2538
+ msg: 'Failed to check mailbox folder delta',
2539
+ account: this.account,
2540
+ err
2541
+ });
2542
+ }
2543
+
2544
+ // Clear delta URL to start fresh
2527
2545
  await this.redis.hdel(this.getAccountKey(), 'outlookMailFoldersDeltaUrl');
2528
2546
 
2529
2547
  return true;
@@ -2734,8 +2752,20 @@ class OutlookClient extends BaseClient {
2734
2752
  * @returns {Object} Result with success status and details
2735
2753
  */
2736
2754
  async renewSubscription(force = false) {
2755
+ // In-memory check to prevent concurrent renewals from lifecycle events
2756
+ // This is faster than Redis lock and reduces noise from duplicate requests
2757
+ if (this.renewalInProgress) {
2758
+ this.logger.debug({
2759
+ msg: 'Subscription renewal skipped',
2760
+ reason: 'renewal_in_progress',
2761
+ account: this.account
2762
+ });
2763
+ return { success: false, reason: 'renewal_in_progress' };
2764
+ }
2765
+
2737
2766
  const lockKey = `${this.getAccountKey()}:subscription-renew-lock`;
2738
- const lockTTL = 30; // 30 seconds TTL for the lock to prevent blocking on crash
2767
+ // Use constant for lock TTL (60 seconds) to handle slow network conditions
2768
+ const lockTTL = OUTLOOK_SUBSCRIPTION_LOCK_TTL;
2739
2769
 
2740
2770
  // Try to acquire lock using Redis SET NX with expiry
2741
2771
  const lockAcquired = await this.redis.set(lockKey, Date.now(), 'EX', lockTTL, 'NX');
@@ -2749,6 +2779,9 @@ class OutlookClient extends BaseClient {
2749
2779
  return { success: false, reason: 'lock_exists' };
2750
2780
  }
2751
2781
 
2782
+ // Set in-memory flag to prevent concurrent calls
2783
+ this.renewalInProgress = true;
2784
+
2752
2785
  try {
2753
2786
  // Get current subscription
2754
2787
  let outlookSubscription = await this.redis.hget(this.getAccountKey(), 'outlookSubscription');
@@ -2774,14 +2807,17 @@ class OutlookClient extends BaseClient {
2774
2807
  }
2775
2808
 
2776
2809
  const now = Date.now();
2810
+ // Add 60 second buffer for clock skew between EmailEngine and MS Graph servers
2811
+ const CLOCK_SKEW_BUFFER = 60 * 1000;
2777
2812
 
2778
- // Check if already expired
2779
- if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now) {
2813
+ // Check if already expired (with buffer for clock skew)
2814
+ if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + CLOCK_SKEW_BUFFER) {
2780
2815
  this.logger.warn({
2781
- msg: 'Subscription already expired',
2816
+ msg: 'Subscription already expired or expiring imminently',
2782
2817
  subscriptionId: outlookSubscription.id,
2783
2818
  expirationDateTime: outlookSubscription.expirationDateTime,
2784
- account: this.account
2819
+ account: this.account,
2820
+ msUntilExpiry: existingExpirationDateTime.getTime() - now
2785
2821
  });
2786
2822
  // Clear the expired subscription
2787
2823
  await this.redis.hdel(this.getAccountKey(), 'outlookSubscription');
@@ -2868,11 +2904,11 @@ class OutlookClient extends BaseClient {
2868
2904
  await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
2869
2905
  this.subscriptionState = 'failed';
2870
2906
 
2871
- // Schedule retry with exponential backoff (max 3 retries)
2907
+ // Schedule retry with exponential backoff
2872
2908
  const retryCount = outlookSubscription.state.retryCount;
2873
- if (retryCount <= 3) {
2874
- // Exponential backoff: 30s, 60s, 120s
2875
- const retryDelay = Math.min(30 * Math.pow(2, retryCount - 1), 120) * 1000;
2909
+ if (retryCount <= OUTLOOK_MAX_RETRY_ATTEMPTS) {
2910
+ // Exponential backoff: 30s, 60s, 120s (capped)
2911
+ const retryDelay = Math.min(OUTLOOK_RETRY_BASE_DELAY * Math.pow(2, retryCount - 1), OUTLOOK_RETRY_MAX_DELAY) * 1000;
2876
2912
 
2877
2913
  setTimeout(() => {
2878
2914
  // Retry will also acquire lock
@@ -2903,7 +2939,8 @@ class OutlookClient extends BaseClient {
2903
2939
  return { success: false, reason: 'renewal_failed', error: err.message };
2904
2940
  }
2905
2941
  } finally {
2906
- // Always release the lock
2942
+ // Always release the lock and clear in-memory flag
2943
+ this.renewalInProgress = false;
2907
2944
  await this.redis.del(lockKey);
2908
2945
  }
2909
2946
  }
@@ -3030,9 +3067,17 @@ class OutlookClient extends BaseClient {
3030
3067
  throw new Error('Empty server response');
3031
3068
  }
3032
3069
  } catch (err) {
3070
+ const errorMessage = err.oauthRequest?.response?.error?.message || err.message;
3071
+ this.logger.error({
3072
+ msg: 'Failed to create MS Graph subscription',
3073
+ account: this.account,
3074
+ error: errorMessage,
3075
+ code: err.oauthRequest?.response?.error?.code,
3076
+ statusCode: err.oauthRequest?.status
3077
+ });
3033
3078
  outlookSubscription.state = {
3034
3079
  state: 'error',
3035
- error: `Subscription failed: ${err.oauthRequest?.response?.error?.message || err.message}`,
3080
+ error: `Subscription failed: ${errorMessage}`,
3036
3081
  time: Date.now()
3037
3082
  };
3038
3083
  this.subscriptionState = 'failed';
@@ -3488,10 +3533,11 @@ class OutlookClient extends BaseClient {
3488
3533
  { metadataOnly: true }
3489
3534
  );
3490
3535
 
3491
- if (messageListResult?.messages) {
3492
- messages = messages.concat(messageListResult?.messages);
3536
+ if (messageListResult?.messages?.length) {
3537
+ // Use push instead of concat to avoid O(n^2) memory allocation
3538
+ messages.push(...messageListResult.messages);
3493
3539
  if (messages.length >= maxMessages) {
3494
- messages = messages.slice(0, maxMessages);
3540
+ messages.length = maxMessages; // Truncate in place
3495
3541
  notDone = false;
3496
3542
  break;
3497
3543
  }