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.
- package/CHANGELOG.md +9 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account/account-state.js +248 -0
- package/lib/account.js +17 -178
- package/lib/api-routes/account-routes.js +1006 -0
- package/lib/api-routes/message-routes.js +1377 -0
- package/lib/consts.js +12 -2
- package/lib/email-client/base-client.js +282 -771
- package/lib/email-client/gmail/gmail-api.js +243 -0
- package/lib/email-client/gmail-client.js +145 -53
- package/lib/email-client/imap/mailbox.js +24 -698
- package/lib/email-client/imap/sync-operations.js +812 -0
- package/lib/email-client/imap-client.js +1 -1
- package/lib/email-client/message-builder.js +566 -0
- package/lib/email-client/notification-handler.js +314 -0
- package/lib/email-client/outlook/graph-api.js +326 -0
- package/lib/email-client/outlook-client.js +159 -113
- package/lib/email-client/smtp-pool-manager.js +196 -0
- package/lib/imapproxy/imap-server.js +3 -12
- package/lib/oauth/gmail.js +4 -4
- package/lib/oauth/mail-ru.js +30 -5
- package/lib/oauth/outlook.js +57 -3
- package/lib/oauth/pubsub/google.js +30 -11
- package/lib/oauth/scope-checker.js +202 -0
- package/lib/oauth2-apps.js +8 -4
- package/lib/redis-operations.js +484 -0
- package/lib/routes-ui.js +283 -2582
- package/lib/tools.js +4 -196
- package/lib/ui-routes/account-routes.js +1931 -0
- package/lib/ui-routes/admin-config-routes.js +1233 -0
- package/lib/ui-routes/admin-entities-routes.js +2367 -0
- package/lib/ui-routes/oauth-routes.js +992 -0
- package/lib/utils/network.js +237 -0
- package/package.json +9 -9
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +78 -18
- package/translations/de.mo +0 -0
- package/translations/de.po +85 -82
- package/translations/en.mo +0 -0
- package/translations/en.po +63 -71
- package/translations/et.mo +0 -0
- package/translations/et.po +84 -82
- package/translations/fr.mo +0 -0
- package/translations/fr.po +85 -82
- package/translations/ja.mo +0 -0
- package/translations/ja.po +84 -82
- package/translations/messages.pot +74 -87
- package/translations/nl.mo +0 -0
- package/translations/nl.po +86 -82
- package/translations/pl.mo +0 -0
- package/translations/pl.po +84 -82
- package/views/account/security.hbs +4 -4
- package/views/accounts/account.hbs +13 -13
- package/views/accounts/register/imap-server.hbs +12 -12
- package/views/config/document-store/pre-processing/index.hbs +4 -2
- package/views/config/oauth/app.hbs +6 -7
- package/views/config/oauth/index.hbs +2 -2
- package/views/config/service.hbs +3 -4
- package/views/dashboard.hbs +5 -7
- package/views/error.hbs +22 -7
- package/views/gateways/gateway.hbs +2 -2
- package/views/partials/add_account_modal.hbs +7 -10
- package/views/partials/document_store_header.hbs +1 -1
- package/views/partials/editor_scope_info.hbs +0 -1
- package/views/partials/oauth_config_header.hbs +1 -1
- package/views/partials/side_menu.hbs +3 -3
- package/views/partials/webhook_form.hbs +2 -2
- package/views/templates/index.hbs +1 -1
- package/views/templates/template.hbs +8 -8
- package/views/tokens/index.hbs +6 -6
- package/views/tokens/new.hbs +1 -1
- package/views/webhooks/index.hbs +4 -4
- package/views/webhooks/webhook.hbs +7 -7
- package/workers/api.js +148 -2436
- package/workers/smtp.js +2 -1
- package/lib/imapproxy/imap-core/test/client.js +0 -46
- package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
- package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
- package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
- package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
- package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
- package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
- package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
- package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
- package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
- package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
- package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
- package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
- package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
- package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
- package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
- package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
- package/lib/imapproxy/imap-core/test/test-client.js +0 -152
- package/lib/imapproxy/imap-core/test/test-server.js +0 -623
- package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
- package/test/api-test.js +0 -899
- package/test/autoreply-test.js +0 -327
- package/test/bounce-test.js +0 -151
- package/test/complaint-test.js +0 -256
- package/test/fixtures/autoreply/LICENSE +0 -27
- package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
- package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
- package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
- package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
- package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
- package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
- package/test/fixtures/bounces/163.eml +0 -2521
- package/test/fixtures/bounces/fastmail.eml +0 -242
- package/test/fixtures/bounces/gmail.eml +0 -252
- package/test/fixtures/bounces/hotmail.eml +0 -655
- package/test/fixtures/bounces/mailru.eml +0 -121
- package/test/fixtures/bounces/outlook.eml +0 -1107
- package/test/fixtures/bounces/postfix.eml +0 -101
- package/test/fixtures/bounces/rambler.eml +0 -116
- package/test/fixtures/bounces/workmail.eml +0 -142
- package/test/fixtures/bounces/yahoo.eml +0 -139
- package/test/fixtures/bounces/zoho.eml +0 -83
- package/test/fixtures/bounces/zonemta.eml +0 -100
- package/test/fixtures/complaints/LICENSE +0 -27
- package/test/fixtures/complaints/amazonses.eml +0 -72
- package/test/fixtures/complaints/dmarc.eml +0 -59
- package/test/fixtures/complaints/hotmail.eml +0 -49
- package/test/fixtures/complaints/optout.eml +0 -40
- package/test/fixtures/complaints/standard-arf.eml +0 -68
- package/test/fixtures/complaints/yahoo.eml +0 -68
- package/test/oauth2-apps-test.js +0 -301
- package/test/sendonly-test.js +0 -160
- package/test/test-config.js +0 -34
- 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 =
|
|
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(
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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 } =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2526
|
-
|
|
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
|
-
|
|
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
|
|
2907
|
+
// Schedule retry with exponential backoff
|
|
2872
2908
|
const retryCount = outlookSubscription.state.retryCount;
|
|
2873
|
-
if (retryCount <=
|
|
2874
|
-
// Exponential backoff: 30s, 60s, 120s
|
|
2875
|
-
const retryDelay = Math.min(
|
|
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: ${
|
|
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
|
-
|
|
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 =
|
|
3540
|
+
messages.length = maxMessages; // Truncate in place
|
|
3495
3541
|
notDone = false;
|
|
3496
3542
|
break;
|
|
3497
3543
|
}
|