emailengine-app 2.63.4 → 2.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +4 -0
- package/CHANGELOG.md +70 -0
- package/copy-static-files.sh +1 -1
- package/data/google-crawlers.json +1 -1
- package/eslint.config.js +2 -0
- package/lib/account.js +13 -9
- package/lib/api-routes/account-routes.js +7 -1
- package/lib/consts.js +17 -1
- package/lib/email-client/gmail/gmail-api.js +1 -12
- package/lib/email-client/imap-client.js +5 -3
- package/lib/email-client/outlook/graph-api.js +9 -15
- package/lib/email-client/outlook-client.js +406 -177
- package/lib/export.js +17 -0
- package/lib/imapproxy/imap-server.js +3 -2
- package/lib/oauth/gmail.js +12 -1
- package/lib/oauth/outlook.js +99 -1
- package/lib/oauth/pubsub/google.js +253 -85
- package/lib/oauth2-apps.js +620 -389
- package/lib/outbox.js +1 -1
- package/lib/routes-ui.js +193 -238
- package/lib/schemas.js +189 -12
- package/lib/ui-routes/account-routes.js +7 -2
- package/lib/ui-routes/admin-entities-routes.js +3 -3
- package/lib/ui-routes/oauth-routes.js +27 -175
- package/package.json +21 -21
- package/sbom.json +1 -1
- package/server.js +54 -22
- package/static/licenses.html +30 -90
- package/translations/de.mo +0 -0
- package/translations/de.po +54 -42
- package/translations/en.mo +0 -0
- package/translations/en.po +55 -43
- package/translations/et.mo +0 -0
- package/translations/et.po +54 -42
- package/translations/fr.mo +0 -0
- package/translations/fr.po +54 -42
- package/translations/ja.mo +0 -0
- package/translations/ja.po +54 -42
- package/translations/messages.pot +93 -71
- package/translations/nl.mo +0 -0
- package/translations/nl.po +54 -42
- package/translations/pl.mo +0 -0
- package/translations/pl.po +54 -42
- package/views/config/oauth/app.hbs +12 -0
- package/views/config/oauth/edit.hbs +2 -0
- package/views/config/oauth/index.hbs +4 -1
- package/views/config/oauth/new.hbs +2 -0
- package/views/config/oauth/subscriptions.hbs +175 -0
- package/views/error.hbs +4 -4
- package/views/partials/oauth_form.hbs +179 -4
- package/views/partials/oauth_tabs.hbs +8 -0
- package/views/partials/scope_info.hbs +10 -0
- package/workers/api.js +174 -96
- package/workers/documents.js +1 -0
- package/workers/export.js +6 -2
- package/workers/imap.js +33 -49
- package/workers/smtp.js +1 -0
- package/workers/submit.js +1 -0
- package/workers/webhooks.js +42 -30
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
OUTLOOK_MAX_RETRY_ATTEMPTS,
|
|
29
29
|
OUTLOOK_RETRY_BASE_DELAY,
|
|
30
30
|
OUTLOOK_RETRY_MAX_DELAY,
|
|
31
|
+
OUTLOOK_CLOCK_SKEW_BUFFER,
|
|
31
32
|
MESSAGE_UPDATED_NOTIFY,
|
|
32
33
|
MESSAGE_DELETED_NOTIFY,
|
|
33
34
|
MESSAGE_MISSING_NOTIFY,
|
|
@@ -85,6 +86,9 @@ class OutlookClient extends BaseClient {
|
|
|
85
86
|
// Flags for background processing
|
|
86
87
|
this.processingHistory = null;
|
|
87
88
|
this.renewWatchTimer = null;
|
|
89
|
+
// Shared retry timer for subscription operations (creation, renewal, lock errors).
|
|
90
|
+
// Only one retry can be pending at a time; the lock serializes these operations.
|
|
91
|
+
this.renewRetryTimer = null;
|
|
88
92
|
this.renewalInProgress = false; // In-memory flag to prevent concurrent subscription renewals
|
|
89
93
|
|
|
90
94
|
// MS Graph webhook subscription state (for metrics)
|
|
@@ -92,6 +96,90 @@ class OutlookClient extends BaseClient {
|
|
|
92
96
|
this.subscriptionState = 'unset';
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Get the lock ID used for subscription operations.
|
|
101
|
+
* Both renewSubscription and ensureSubscription coordinate on this lock.
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
getSubscriptionLockId() {
|
|
105
|
+
return ['outlook', 'subscription', this.account].join(':');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load and parse the outlookSubscription data from Redis.
|
|
110
|
+
* Returns a parsed object, or an empty object if missing/invalid.
|
|
111
|
+
* @returns {Promise<Object>}
|
|
112
|
+
*/
|
|
113
|
+
async getStoredSubscription() {
|
|
114
|
+
let raw = await this.redis.hget(this.getAccountKey(), 'outlookSubscription');
|
|
115
|
+
if (!raw) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
this.logger.warn({ msg: 'Failed to parse stored subscription data', account: this.account, err });
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Persist subscription data back to Redis (only if the key already exists).
|
|
128
|
+
* @param {Object} subscriptionData
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
async saveStoredSubscription(subscriptionData) {
|
|
132
|
+
await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(subscriptionData));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse an expiration date string, returning null for missing or invalid values.
|
|
137
|
+
* @param {string} dateStr
|
|
138
|
+
* @returns {Date|null}
|
|
139
|
+
*/
|
|
140
|
+
parseExpirationDate(dateStr) {
|
|
141
|
+
if (!dateStr) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
let date = new Date(dateStr);
|
|
145
|
+
if (date.toString() === 'Invalid Date') {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return date;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Schedule a subscription retry on the shared retry timer.
|
|
153
|
+
* Replaces any previously pending retry. The timer is unref'd so it
|
|
154
|
+
* does not keep the process alive.
|
|
155
|
+
* @param {Object} opts
|
|
156
|
+
* @param {number} opts.delayMs - Delay before firing the retry
|
|
157
|
+
* @param {string} opts.reason - Short label for log messages (e.g. 'renewal_failure')
|
|
158
|
+
* @param {string} opts.errorMsg - Log message if the retry callback throws
|
|
159
|
+
* @param {Function} opts.fn - Async function to call on retry
|
|
160
|
+
* @param {number} [opts.retryAttempt] - Current attempt number (for logging)
|
|
161
|
+
*/
|
|
162
|
+
scheduleSubscriptionRetry({ delayMs, reason, errorMsg, fn, retryAttempt }) {
|
|
163
|
+
if (this.renewRetryTimer) {
|
|
164
|
+
this.logger.debug({ msg: 'Replacing pending subscription retry timer', account: this.account, reason });
|
|
165
|
+
}
|
|
166
|
+
clearTimeout(this.renewRetryTimer);
|
|
167
|
+
this.renewRetryTimer = setTimeout(() => {
|
|
168
|
+
if (this.closed) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
fn().catch(err => {
|
|
172
|
+
this.logger.error({
|
|
173
|
+
msg: errorMsg,
|
|
174
|
+
account: this.account,
|
|
175
|
+
retryAttempt,
|
|
176
|
+
err
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}, delayMs);
|
|
180
|
+
this.renewRetryTimer.unref();
|
|
181
|
+
}
|
|
182
|
+
|
|
95
183
|
/**
|
|
96
184
|
* Makes authenticated requests to Microsoft Graph API
|
|
97
185
|
* Handles token management and error responses
|
|
@@ -122,21 +210,29 @@ class OutlookClient extends BaseClient {
|
|
|
122
210
|
*/
|
|
123
211
|
async init() {
|
|
124
212
|
this.state = 'connecting';
|
|
213
|
+
clearTimeout(this.renewWatchTimer);
|
|
214
|
+
clearTimeout(this.renewRetryTimer);
|
|
125
215
|
await this.setStateVal();
|
|
126
216
|
|
|
127
217
|
await this.getAccount();
|
|
128
|
-
await this.prepareDelegatedAccount();
|
|
129
218
|
await this.getClient(true);
|
|
219
|
+
await this.prepareDelegatedAccount();
|
|
130
220
|
|
|
131
221
|
let accountData = await this.accountObject.loadAccountData();
|
|
132
222
|
|
|
133
223
|
// Check if send-only mode
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// scope changes become common.
|
|
224
|
+
// For outlookService (client_credentials), app permissions are configured in Entra.
|
|
225
|
+
// The .default scope doesn't enumerate individual permissions, so assume full access.
|
|
226
|
+
// For delegated accounts, scopes are checked at initialization and after account updates.
|
|
138
227
|
const scopes = accountData.oauth2?.accessToken?.scope || accountData.oauth2?.scope || [];
|
|
139
|
-
|
|
228
|
+
let hasSendScope, hasReadScope;
|
|
229
|
+
if (this.oAuth2Client?.useClientCredentials) {
|
|
230
|
+
hasSendScope = true;
|
|
231
|
+
hasReadScope = true;
|
|
232
|
+
} else {
|
|
233
|
+
({ hasSendScope, hasReadScope } = checkAccountScopes('outlook', scopes, this.logger));
|
|
234
|
+
}
|
|
235
|
+
|
|
140
236
|
const isSendOnly = hasSendScope && !hasReadScope;
|
|
141
237
|
|
|
142
238
|
this.logger.debug({
|
|
@@ -180,7 +276,12 @@ class OutlookClient extends BaseClient {
|
|
|
180
276
|
}
|
|
181
277
|
|
|
182
278
|
// Update username if it has changed (e.g., after email address change)
|
|
183
|
-
if (
|
|
279
|
+
if (
|
|
280
|
+
profileRes.userPrincipalName &&
|
|
281
|
+
accountData.oauth2.auth?.user !== profileRes.userPrincipalName &&
|
|
282
|
+
!accountData.oauth2.auth?.delegatedUser &&
|
|
283
|
+
!this.oAuth2Client?.useClientCredentials
|
|
284
|
+
) {
|
|
184
285
|
updates.oauth2 = {
|
|
185
286
|
partial: true,
|
|
186
287
|
auth: Object.assign(accountData.oauth2.auth || {}, {
|
|
@@ -245,6 +346,15 @@ class OutlookClient extends BaseClient {
|
|
|
245
346
|
if (!isSendOnly) {
|
|
246
347
|
// additional operations for full access accounts
|
|
247
348
|
|
|
349
|
+
// Reset subscription retry counts on reconnect — fresh auth = fresh slate
|
|
350
|
+
let storedSub = await this.getStoredSubscription();
|
|
351
|
+
if (storedSub.state && (storedSub.state.createRetryCount > 0 || storedSub.state.retryCount > 0)) {
|
|
352
|
+
storedSub.state.createRetryCount = 0;
|
|
353
|
+
storedSub.state.retryCount = 0;
|
|
354
|
+
storedSub.state.error = null;
|
|
355
|
+
await this.saveStoredSubscription(storedSub);
|
|
356
|
+
}
|
|
357
|
+
|
|
248
358
|
// Set up webhook subscription for real-time updates
|
|
249
359
|
await this.ensureSubscription();
|
|
250
360
|
this.setupRenewWatchTimer();
|
|
@@ -266,6 +376,7 @@ class OutlookClient extends BaseClient {
|
|
|
266
376
|
*/
|
|
267
377
|
async close() {
|
|
268
378
|
clearTimeout(this.renewWatchTimer);
|
|
379
|
+
clearTimeout(this.renewRetryTimer);
|
|
269
380
|
this.closed = true;
|
|
270
381
|
|
|
271
382
|
if (['init', 'connecting', 'syncing', 'connected'].includes(this.state)) {
|
|
@@ -289,8 +400,19 @@ class OutlookClient extends BaseClient {
|
|
|
289
400
|
*/
|
|
290
401
|
async delete() {
|
|
291
402
|
clearTimeout(this.renewWatchTimer);
|
|
403
|
+
clearTimeout(this.renewRetryTimer);
|
|
292
404
|
this.closed = true;
|
|
293
405
|
|
|
406
|
+
// Best-effort: delete MS Graph subscription
|
|
407
|
+
try {
|
|
408
|
+
let outlookSubscription = await this.getStoredSubscription();
|
|
409
|
+
if (outlookSubscription.id) {
|
|
410
|
+
await this.request(`/subscriptions/${outlookSubscription.id}`, 'DELETE');
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
this.logger.error({ msg: 'Failed to delete MS Graph subscription', account: this.account, err });
|
|
414
|
+
}
|
|
415
|
+
|
|
294
416
|
if (['init', 'connecting', 'syncing', 'connected'].includes(this.state)) {
|
|
295
417
|
this.state = 'disconnected';
|
|
296
418
|
await this.setStateVal();
|
|
@@ -2665,6 +2787,20 @@ class OutlookClient extends BaseClient {
|
|
|
2665
2787
|
|
|
2666
2788
|
let accountData = await this.accountObject.loadAccountData();
|
|
2667
2789
|
|
|
2790
|
+
// For outlookService (client_credentials), always use /users/{email} path
|
|
2791
|
+
if (this.oAuth2Client?.useClientCredentials) {
|
|
2792
|
+
let email = accountData.oauth2?.auth?.delegatedUser || accountData.oauth2?.auth?.user;
|
|
2793
|
+
if (email) {
|
|
2794
|
+
this.oauth2UserPath = `users/${encodeURIComponent(email)}`;
|
|
2795
|
+
} else {
|
|
2796
|
+
let err = new Error('Application credentials require a target user email address');
|
|
2797
|
+
err.code = 'MissingTargetUser';
|
|
2798
|
+
err.authenticationFailed = true;
|
|
2799
|
+
throw err;
|
|
2800
|
+
}
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2668
2804
|
if (accountData?.oauth2?.auth?.delegatedUser && accountData?.oauth2?.auth?.delegatedAccount) {
|
|
2669
2805
|
await this.getDelegatedAccount(accountData);
|
|
2670
2806
|
if (this.delegatedAccountObject) {
|
|
@@ -2694,13 +2830,21 @@ class OutlookClient extends BaseClient {
|
|
|
2694
2830
|
|
|
2695
2831
|
// Track successful token refresh (only if token was actually refreshed, not cached)
|
|
2696
2832
|
if (!tokenData.cached) {
|
|
2697
|
-
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2833
|
+
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2834
|
+
status: 'success',
|
|
2835
|
+
provider: this.oAuth2Client?.provider || 'outlook',
|
|
2836
|
+
statusCode: '200'
|
|
2837
|
+
});
|
|
2698
2838
|
}
|
|
2699
2839
|
} catch (E) {
|
|
2700
2840
|
if (E.code === 'ETokenRefresh') {
|
|
2701
2841
|
// Track failed token refresh
|
|
2702
2842
|
const statusCode = String(E.statusCode || 0);
|
|
2703
|
-
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2843
|
+
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2844
|
+
status: 'failure',
|
|
2845
|
+
provider: this.oAuth2Client?.provider || 'outlook',
|
|
2846
|
+
statusCode
|
|
2847
|
+
});
|
|
2704
2848
|
|
|
2705
2849
|
// treat as authentication failure
|
|
2706
2850
|
this.state = 'authenticationError';
|
|
@@ -2743,8 +2887,8 @@ class OutlookClient extends BaseClient {
|
|
|
2743
2887
|
*/
|
|
2744
2888
|
async prepare() {
|
|
2745
2889
|
await this.getAccount();
|
|
2746
|
-
await this.prepareDelegatedAccount();
|
|
2747
2890
|
await this.getClient();
|
|
2891
|
+
await this.prepareDelegatedAccount();
|
|
2748
2892
|
}
|
|
2749
2893
|
|
|
2750
2894
|
/**
|
|
@@ -2762,11 +2906,20 @@ class OutlookClient extends BaseClient {
|
|
|
2762
2906
|
|
|
2763
2907
|
try {
|
|
2764
2908
|
// First try to renew existing subscription
|
|
2765
|
-
const renewalResult = await this.renewSubscription(false);
|
|
2909
|
+
const renewalResult = await this.renewSubscription({ force: false });
|
|
2766
2910
|
|
|
2767
2911
|
if (!renewalResult.success && (renewalResult.reason === 'expired' || renewalResult.reason === 'no_subscription')) {
|
|
2768
|
-
//
|
|
2769
|
-
await this.
|
|
2912
|
+
// Check if we've exceeded max creation retries
|
|
2913
|
+
let subscriptionData = await this.getStoredSubscription();
|
|
2914
|
+
if (subscriptionData?.state?.createRetryCount >= OUTLOOK_MAX_RETRY_ATTEMPTS) {
|
|
2915
|
+
this.logger.error({
|
|
2916
|
+
msg: 'Max subscription creation retries exceeded, waiting for reconnect',
|
|
2917
|
+
account: this.account,
|
|
2918
|
+
createRetryCount: subscriptionData.state.createRetryCount
|
|
2919
|
+
});
|
|
2920
|
+
} else {
|
|
2921
|
+
await this.ensureSubscription();
|
|
2922
|
+
}
|
|
2770
2923
|
}
|
|
2771
2924
|
} catch (err) {
|
|
2772
2925
|
this.logger.error({ msg: 'Failed to renew MS Graph change subscription', account: this.account, err });
|
|
@@ -3019,13 +3172,19 @@ class OutlookClient extends BaseClient {
|
|
|
3019
3172
|
/**
|
|
3020
3173
|
* Unified method to renew MS Graph webhook subscription with proper locking
|
|
3021
3174
|
* Can be called from timer or lifecycle notification handler
|
|
3022
|
-
* @param {
|
|
3175
|
+
* @param {Object} [opts] - Options
|
|
3176
|
+
* @param {boolean} [opts.force=false] - Force renewal even if not expired soon
|
|
3177
|
+
* @param {boolean} [opts.skipLock=false] - Skip lock acquisition (when caller already holds the subscription lock)
|
|
3023
3178
|
* @returns {Object} Result with success status and details
|
|
3024
3179
|
*/
|
|
3025
|
-
async renewSubscription(force = false) {
|
|
3180
|
+
async renewSubscription({ force = false, skipLock = false } = {}) {
|
|
3181
|
+
if (this.closed) {
|
|
3182
|
+
return { success: false, reason: 'closed' };
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3026
3185
|
// In-memory check to prevent concurrent renewals from lifecycle events
|
|
3027
3186
|
// This is faster than Redis lock and reduces noise from duplicate requests
|
|
3028
|
-
if (this.renewalInProgress) {
|
|
3187
|
+
if (this.renewalInProgress && !force) {
|
|
3029
3188
|
this.logger.debug({
|
|
3030
3189
|
msg: 'Subscription renewal skipped',
|
|
3031
3190
|
reason: 'renewal_in_progress',
|
|
@@ -3034,20 +3193,31 @@ class OutlookClient extends BaseClient {
|
|
|
3034
3193
|
return { success: false, reason: 'renewal_in_progress' };
|
|
3035
3194
|
}
|
|
3036
3195
|
|
|
3037
|
-
const
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3196
|
+
const lock = this.accountObject.getLock();
|
|
3197
|
+
const lockId = this.getSubscriptionLockId();
|
|
3198
|
+
let renewLock;
|
|
3199
|
+
if (!skipLock) {
|
|
3200
|
+
try {
|
|
3201
|
+
renewLock = await lock.acquireLock(lockId, OUTLOOK_SUBSCRIPTION_LOCK_TTL);
|
|
3202
|
+
} catch (err) {
|
|
3203
|
+
this.logger.error({ msg: 'Failed to acquire subscription renew lock', account: this.account, err });
|
|
3204
|
+
this.scheduleSubscriptionRetry({
|
|
3205
|
+
delayMs: OUTLOOK_RETRY_BASE_DELAY * 1000,
|
|
3206
|
+
reason: 'lock_error',
|
|
3207
|
+
errorMsg: 'Subscription renew retry after lock error failed',
|
|
3208
|
+
fn: () => this.renewSubscription({ force: false })
|
|
3209
|
+
});
|
|
3210
|
+
return { success: false, reason: 'lock_error' };
|
|
3211
|
+
}
|
|
3043
3212
|
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3213
|
+
if (!renewLock.success) {
|
|
3214
|
+
this.logger.info({
|
|
3215
|
+
msg: 'Subscription renewal skipped',
|
|
3216
|
+
reason: 'lock_exists',
|
|
3217
|
+
account: this.account
|
|
3218
|
+
});
|
|
3219
|
+
return { success: false, reason: 'lock_exists' };
|
|
3220
|
+
}
|
|
3051
3221
|
}
|
|
3052
3222
|
|
|
3053
3223
|
// Set in-memory flag to prevent concurrent calls
|
|
@@ -3055,16 +3225,7 @@ class OutlookClient extends BaseClient {
|
|
|
3055
3225
|
|
|
3056
3226
|
try {
|
|
3057
3227
|
// Get current subscription
|
|
3058
|
-
let outlookSubscription = await this.
|
|
3059
|
-
if (outlookSubscription) {
|
|
3060
|
-
try {
|
|
3061
|
-
outlookSubscription = JSON.parse(outlookSubscription);
|
|
3062
|
-
} catch (err) {
|
|
3063
|
-
outlookSubscription = {};
|
|
3064
|
-
}
|
|
3065
|
-
} else {
|
|
3066
|
-
outlookSubscription = {};
|
|
3067
|
-
}
|
|
3228
|
+
let outlookSubscription = await this.getStoredSubscription();
|
|
3068
3229
|
|
|
3069
3230
|
// Check if subscription exists and is valid
|
|
3070
3231
|
if (!outlookSubscription.id) {
|
|
@@ -3072,17 +3233,12 @@ class OutlookClient extends BaseClient {
|
|
|
3072
3233
|
return { success: false, reason: 'no_subscription' };
|
|
3073
3234
|
}
|
|
3074
3235
|
|
|
3075
|
-
let existingExpirationDateTime =
|
|
3076
|
-
if (existingExpirationDateTime.toString() === 'Invalid Date') {
|
|
3077
|
-
existingExpirationDateTime = null;
|
|
3078
|
-
}
|
|
3236
|
+
let existingExpirationDateTime = this.parseExpirationDate(outlookSubscription.expirationDateTime);
|
|
3079
3237
|
|
|
3080
3238
|
const now = Date.now();
|
|
3081
|
-
// Add 60 second buffer for clock skew between EmailEngine and MS Graph servers
|
|
3082
|
-
const CLOCK_SKEW_BUFFER = 60 * 1000;
|
|
3083
3239
|
|
|
3084
3240
|
// Check if already expired (with buffer for clock skew)
|
|
3085
|
-
if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now +
|
|
3241
|
+
if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_CLOCK_SKEW_BUFFER) {
|
|
3086
3242
|
this.logger.warn({
|
|
3087
3243
|
msg: 'Subscription already expired or expiring imminently',
|
|
3088
3244
|
subscriptionId: outlookSubscription.id,
|
|
@@ -3116,41 +3272,39 @@ class OutlookClient extends BaseClient {
|
|
|
3116
3272
|
expirationDateTime: expirationDateTime.toISOString()
|
|
3117
3273
|
};
|
|
3118
3274
|
|
|
3275
|
+
// Capture previous retry counts before overwriting state
|
|
3276
|
+
const previousRetryCount = outlookSubscription.state?.retryCount || 0;
|
|
3277
|
+
const previousCreateRetryCount = outlookSubscription.state?.createRetryCount || 0;
|
|
3278
|
+
|
|
3119
3279
|
// Update state to renewing
|
|
3120
3280
|
outlookSubscription.state = {
|
|
3121
3281
|
state: 'renewing',
|
|
3122
3282
|
time: Date.now()
|
|
3123
3283
|
};
|
|
3124
|
-
await this.
|
|
3284
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3125
3285
|
this.subscriptionState = 'pending';
|
|
3126
3286
|
|
|
3127
3287
|
try {
|
|
3128
3288
|
const subscriptionRes = await this.request(`/subscriptions/${outlookSubscription.id}`, 'PATCH', subscriptionPayload);
|
|
3129
3289
|
|
|
3130
3290
|
if (subscriptionRes?.expirationDateTime) {
|
|
3131
|
-
// Check if this was a retry before clearing state
|
|
3132
|
-
const wasRetry = outlookSubscription.state?.retryCount > 0;
|
|
3133
|
-
const previousRetryCount = outlookSubscription.state?.retryCount || 0;
|
|
3134
|
-
|
|
3135
3291
|
outlookSubscription.expirationDateTime = subscriptionRes.expirationDateTime;
|
|
3136
3292
|
outlookSubscription.state = {
|
|
3137
3293
|
state: 'created',
|
|
3138
3294
|
time: Date.now(),
|
|
3139
|
-
// Clear any previous error state and retry count
|
|
3140
3295
|
retryCount: 0,
|
|
3296
|
+
createRetryCount: 0,
|
|
3141
3297
|
error: null
|
|
3142
3298
|
};
|
|
3143
3299
|
|
|
3144
|
-
await this.
|
|
3300
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3145
3301
|
this.subscriptionState = 'valid';
|
|
3146
3302
|
|
|
3147
|
-
// Log if this was a successful retry after errors
|
|
3148
3303
|
this.logger.info({
|
|
3149
|
-
msg:
|
|
3304
|
+
msg: previousRetryCount > 0 ? 'Subscription renewed successfully after retry' : 'Subscription renewed successfully',
|
|
3150
3305
|
subscriptionId: outlookSubscription.id,
|
|
3151
3306
|
newExpirationDateTime: subscriptionRes.expirationDateTime,
|
|
3152
3307
|
account: this.account,
|
|
3153
|
-
wasRetry,
|
|
3154
3308
|
previousRetryCount
|
|
3155
3309
|
});
|
|
3156
3310
|
|
|
@@ -3170,28 +3324,24 @@ class OutlookClient extends BaseClient {
|
|
|
3170
3324
|
state: 'error',
|
|
3171
3325
|
error: `Subscription renewal failed: ${err.oauthRequest?.response?.error?.message || err.message}`,
|
|
3172
3326
|
time: Date.now(),
|
|
3173
|
-
retryCount:
|
|
3327
|
+
retryCount: previousRetryCount + 1,
|
|
3328
|
+
createRetryCount: previousCreateRetryCount
|
|
3174
3329
|
};
|
|
3175
|
-
await this.
|
|
3330
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3176
3331
|
this.subscriptionState = 'failed';
|
|
3177
3332
|
|
|
3178
3333
|
// Schedule retry with exponential backoff
|
|
3179
3334
|
const retryCount = outlookSubscription.state.retryCount;
|
|
3180
|
-
if (retryCount
|
|
3181
|
-
// Exponential backoff: 30s, 60s, 120s (capped)
|
|
3335
|
+
if (retryCount < OUTLOOK_MAX_RETRY_ATTEMPTS) {
|
|
3182
3336
|
const retryDelay = Math.min(OUTLOOK_RETRY_BASE_DELAY * Math.pow(2, retryCount - 1), OUTLOOK_RETRY_MAX_DELAY) * 1000;
|
|
3183
3337
|
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
err
|
|
3192
|
-
});
|
|
3193
|
-
});
|
|
3194
|
-
}, retryDelay);
|
|
3338
|
+
this.scheduleSubscriptionRetry({
|
|
3339
|
+
delayMs: retryDelay,
|
|
3340
|
+
reason: 'renewal_failure',
|
|
3341
|
+
errorMsg: 'Subscription renewal retry failed',
|
|
3342
|
+
fn: () => this.renewSubscription({ force }),
|
|
3343
|
+
retryAttempt: retryCount
|
|
3344
|
+
});
|
|
3195
3345
|
|
|
3196
3346
|
this.logger.info({
|
|
3197
3347
|
msg: 'Scheduling subscription renewal retry',
|
|
@@ -3212,7 +3362,13 @@ class OutlookClient extends BaseClient {
|
|
|
3212
3362
|
} finally {
|
|
3213
3363
|
// Always release the lock and clear in-memory flag
|
|
3214
3364
|
this.renewalInProgress = false;
|
|
3215
|
-
|
|
3365
|
+
if (!skipLock && renewLock?.success) {
|
|
3366
|
+
try {
|
|
3367
|
+
await lock.releaseLock(renewLock);
|
|
3368
|
+
} catch (err) {
|
|
3369
|
+
this.logger.error({ msg: 'Failed to release subscription renew lock', account: this.account, err });
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3216
3372
|
}
|
|
3217
3373
|
}
|
|
3218
3374
|
|
|
@@ -3220,7 +3376,11 @@ class OutlookClient extends BaseClient {
|
|
|
3220
3376
|
* Ensure webhook subscription exists and is valid
|
|
3221
3377
|
* Creates new subscription or renews existing one
|
|
3222
3378
|
*/
|
|
3223
|
-
async ensureSubscription() {
|
|
3379
|
+
async ensureSubscription({ clearExisting = false } = {}) {
|
|
3380
|
+
if (this.closed) {
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3224
3384
|
let serviceUrl = (await settings.get('serviceUrl')) || null;
|
|
3225
3385
|
let notificationBaseUrl = (await settings.get('notificationBaseUrl')) || serviceUrl || '';
|
|
3226
3386
|
|
|
@@ -3229,131 +3389,200 @@ class OutlookClient extends BaseClient {
|
|
|
3229
3389
|
return false;
|
|
3230
3390
|
}
|
|
3231
3391
|
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3392
|
+
// Acquire distributed lock to prevent concurrent subscription operations
|
|
3393
|
+
const lock = this.accountObject.getLock();
|
|
3394
|
+
const lockId = this.getSubscriptionLockId();
|
|
3395
|
+
let ensureLock;
|
|
3396
|
+
try {
|
|
3397
|
+
ensureLock = await lock.acquireLock(lockId, OUTLOOK_SUBSCRIPTION_LOCK_TTL);
|
|
3398
|
+
} catch (err) {
|
|
3399
|
+
this.logger.error({ msg: 'Failed to acquire subscription ensure lock', account: this.account, err });
|
|
3400
|
+
// Schedule retry for transient lock errors (e.g., Redis connectivity).
|
|
3401
|
+
// Do not forward clearExisting on retry: by the time the retry fires,
|
|
3402
|
+
// another path may have created a valid subscription that should not be cleared.
|
|
3403
|
+
this.scheduleSubscriptionRetry({
|
|
3404
|
+
delayMs: OUTLOOK_RETRY_BASE_DELAY * 1000,
|
|
3405
|
+
reason: 'lock_error',
|
|
3406
|
+
errorMsg: 'Subscription ensure retry after lock error failed',
|
|
3407
|
+
fn: () => this.ensureSubscription()
|
|
3408
|
+
});
|
|
3409
|
+
return;
|
|
3243
3410
|
}
|
|
3244
|
-
|
|
3245
|
-
// Check if subscription is being created/renewed
|
|
3246
|
-
if (['creating', 'renewing'].includes(outlookSubscription.state?.state) && outlookSubscription.state.time > Date.now() - 30 * 60 * 1000) {
|
|
3247
|
-
// allow previous operation to finish (30 minute timeout)
|
|
3411
|
+
if (!ensureLock.success) {
|
|
3248
3412
|
this.logger.info({
|
|
3249
|
-
msg: 'Subscription
|
|
3250
|
-
reason: '
|
|
3251
|
-
|
|
3252
|
-
state: outlookSubscription.state?.state,
|
|
3253
|
-
created: outlookSubscription.state?.time,
|
|
3254
|
-
expected: Date.now() - 30 * 60 * 1000
|
|
3413
|
+
msg: 'Subscription creation skipped',
|
|
3414
|
+
reason: 'lock_exists',
|
|
3415
|
+
account: this.account
|
|
3255
3416
|
});
|
|
3256
|
-
this.subscriptionState = 'pending';
|
|
3257
3417
|
return;
|
|
3258
3418
|
}
|
|
3259
3419
|
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
existingExpirationDateTime = null;
|
|
3420
|
+
try {
|
|
3421
|
+
if (clearExisting) {
|
|
3422
|
+
await this.redis.hdel(this.getAccountKey(), 'outlookSubscription');
|
|
3423
|
+
this.logger.info({
|
|
3424
|
+
msg: 'Cleared existing subscription data for re-creation',
|
|
3425
|
+
account: this.account
|
|
3426
|
+
});
|
|
3268
3427
|
}
|
|
3269
3428
|
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
// Subscription expired, clear it to create a new one
|
|
3278
|
-
outlookSubscription = {};
|
|
3279
|
-
} else {
|
|
3280
|
-
return renewalResult.success;
|
|
3281
|
-
}
|
|
3282
|
-
} else {
|
|
3283
|
-
// Subscription is valid, do nothing
|
|
3284
|
-
this.logger.info({
|
|
3285
|
-
msg: 'Subscription renewal skipped',
|
|
3286
|
-
reason: 'valid',
|
|
3429
|
+
let outlookSubscription = await this.getStoredSubscription();
|
|
3430
|
+
|
|
3431
|
+
// If state is creating/renewing, the previous lock holder crashed before completing.
|
|
3432
|
+
// Since we hold the lock, no other operation is in progress — reset to a clean state.
|
|
3433
|
+
if (['creating', 'renewing'].includes(outlookSubscription.state?.state)) {
|
|
3434
|
+
this.logger.warn({
|
|
3435
|
+
msg: 'Found stale subscription state from crashed operation, resetting state',
|
|
3287
3436
|
subscriptionId: outlookSubscription?.id,
|
|
3288
3437
|
state: outlookSubscription.state?.state,
|
|
3289
|
-
|
|
3290
|
-
|
|
3438
|
+
stateTime: outlookSubscription.state?.time,
|
|
3439
|
+
account: this.account
|
|
3291
3440
|
});
|
|
3292
|
-
this.subscriptionState = 'valid';
|
|
3293
|
-
return;
|
|
3294
|
-
}
|
|
3295
|
-
}
|
|
3296
3441
|
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
}
|
|
3302
|
-
|
|
3303
|
-
const lifecycleNotificationUrl = prepareUrl('/oauth/msg/lifecycle', notificationBaseUrl, {
|
|
3304
|
-
account: this.account
|
|
3305
|
-
});
|
|
3442
|
+
// Reset transient state so downstream logic sees a clean state
|
|
3443
|
+
outlookSubscription.state.state = outlookSubscription.id ? 'created' : 'error';
|
|
3444
|
+
outlookSubscription.state.time = Date.now();
|
|
3445
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3446
|
+
}
|
|
3306
3447
|
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
notificationUrl,
|
|
3310
|
-
lifecycleNotificationUrl,
|
|
3311
|
-
resource: `/${this.oauth2UserPath}/messages`,
|
|
3312
|
-
expirationDateTime: expirationDateTime.toISOString(),
|
|
3313
|
-
clientState: crypto.randomUUID()
|
|
3314
|
-
};
|
|
3448
|
+
let now = Date.now();
|
|
3449
|
+
let expirationDateTime = new Date(now + OUTLOOK_EXPIRATION_TIME);
|
|
3315
3450
|
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
};
|
|
3320
|
-
await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
|
|
3321
|
-
this.subscriptionState = 'pending';
|
|
3451
|
+
// Renew existing subscription if needed
|
|
3452
|
+
if (outlookSubscription.id) {
|
|
3453
|
+
let existingExpirationDateTime = this.parseExpirationDate(outlookSubscription.expirationDateTime);
|
|
3322
3454
|
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
if (
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
};
|
|
3336
|
-
this.subscriptionState = 'valid';
|
|
3455
|
+
if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_CLOCK_SKEW_BUFFER) {
|
|
3456
|
+
// already expired (with buffer for clock skew)
|
|
3457
|
+
outlookSubscription = {};
|
|
3458
|
+
} else if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_EXPIRATION_RENEW_TIME) {
|
|
3459
|
+
// Use the unified renewal method (skip lock since we already hold it)
|
|
3460
|
+
const renewalResult = await this.renewSubscription({ force: false, skipLock: true });
|
|
3461
|
+
if (!renewalResult.success && renewalResult.reason === 'expired') {
|
|
3462
|
+
// Subscription expired, clear it to create a new one
|
|
3463
|
+
outlookSubscription = {};
|
|
3464
|
+
} else {
|
|
3465
|
+
return renewalResult.success;
|
|
3466
|
+
}
|
|
3337
3467
|
} else {
|
|
3338
|
-
|
|
3468
|
+
// Subscription is valid, do nothing
|
|
3469
|
+
this.logger.info({
|
|
3470
|
+
msg: 'Subscription renewal skipped',
|
|
3471
|
+
reason: 'valid',
|
|
3472
|
+
subscriptionId: outlookSubscription?.id,
|
|
3473
|
+
state: outlookSubscription.state?.state,
|
|
3474
|
+
created: outlookSubscription.state?.time,
|
|
3475
|
+
expirationDateTime: outlookSubscription.expirationDateTime
|
|
3476
|
+
});
|
|
3477
|
+
this.subscriptionState = 'valid';
|
|
3478
|
+
return;
|
|
3339
3479
|
}
|
|
3340
|
-
}
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
// Create new subscription if needed
|
|
3483
|
+
if (!outlookSubscription.id) {
|
|
3484
|
+
const notificationUrl = prepareUrl('/oauth/msg/notification', notificationBaseUrl, {
|
|
3485
|
+
account: this.account
|
|
3486
|
+
});
|
|
3487
|
+
|
|
3488
|
+
const lifecycleNotificationUrl = prepareUrl('/oauth/msg/lifecycle', notificationBaseUrl, {
|
|
3489
|
+
account: this.account
|
|
3348
3490
|
});
|
|
3491
|
+
|
|
3492
|
+
const subscriptionPayload = {
|
|
3493
|
+
changeType: 'created,updated,deleted',
|
|
3494
|
+
notificationUrl,
|
|
3495
|
+
lifecycleNotificationUrl,
|
|
3496
|
+
resource: `/${this.oauth2UserPath}/messages`,
|
|
3497
|
+
expirationDateTime: expirationDateTime.toISOString(),
|
|
3498
|
+
clientState: crypto.randomUUID()
|
|
3499
|
+
};
|
|
3500
|
+
|
|
3501
|
+
// Capture previous create retry count before overwriting state
|
|
3502
|
+
const previousCreateRetryCount = outlookSubscription.state?.createRetryCount || 0;
|
|
3503
|
+
|
|
3349
3504
|
outlookSubscription.state = {
|
|
3350
|
-
state: '
|
|
3351
|
-
error: `Subscription failed: ${errorMessage}`,
|
|
3505
|
+
state: 'creating',
|
|
3352
3506
|
time: Date.now()
|
|
3353
3507
|
};
|
|
3354
|
-
this.
|
|
3355
|
-
|
|
3356
|
-
|
|
3508
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3509
|
+
this.subscriptionState = 'pending';
|
|
3510
|
+
|
|
3511
|
+
let subscriptionRes;
|
|
3512
|
+
try {
|
|
3513
|
+
subscriptionRes = await this.request(`/subscriptions`, 'post', subscriptionPayload);
|
|
3514
|
+
if (subscriptionRes?.expirationDateTime) {
|
|
3515
|
+
outlookSubscription = {
|
|
3516
|
+
id: subscriptionRes.id,
|
|
3517
|
+
expirationDateTime: subscriptionRes.expirationDateTime,
|
|
3518
|
+
clientState: subscriptionRes.clientState,
|
|
3519
|
+
state: {
|
|
3520
|
+
state: 'created',
|
|
3521
|
+
time: Date.now(),
|
|
3522
|
+
createRetryCount: 0,
|
|
3523
|
+
retryCount: 0
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
3526
|
+
this.subscriptionState = 'valid';
|
|
3527
|
+
} else {
|
|
3528
|
+
throw new Error('Empty server response');
|
|
3529
|
+
}
|
|
3530
|
+
} catch (err) {
|
|
3531
|
+
const errorMessage = err.oauthRequest?.response?.error?.message || err.message;
|
|
3532
|
+
this.logger.error({
|
|
3533
|
+
msg: 'Failed to create MS Graph subscription',
|
|
3534
|
+
account: this.account,
|
|
3535
|
+
error: errorMessage,
|
|
3536
|
+
code: err.oauthRequest?.response?.error?.code,
|
|
3537
|
+
statusCode: err.oauthRequest?.status
|
|
3538
|
+
});
|
|
3539
|
+
outlookSubscription.state = {
|
|
3540
|
+
state: 'error',
|
|
3541
|
+
error: `Subscription failed: ${errorMessage}`,
|
|
3542
|
+
time: Date.now(),
|
|
3543
|
+
createRetryCount: previousCreateRetryCount + 1
|
|
3544
|
+
};
|
|
3545
|
+
this.subscriptionState = 'failed';
|
|
3546
|
+
} finally {
|
|
3547
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
// Schedule retry with exponential backoff if creation failed
|
|
3551
|
+
if (outlookSubscription.state?.state === 'error') {
|
|
3552
|
+
const createRetryCount = outlookSubscription.state.createRetryCount || 0;
|
|
3553
|
+
if (createRetryCount < OUTLOOK_MAX_RETRY_ATTEMPTS) {
|
|
3554
|
+
const retryDelay = Math.min(OUTLOOK_RETRY_BASE_DELAY * Math.pow(2, createRetryCount - 1), OUTLOOK_RETRY_MAX_DELAY) * 1000;
|
|
3555
|
+
|
|
3556
|
+
this.scheduleSubscriptionRetry({
|
|
3557
|
+
delayMs: retryDelay,
|
|
3558
|
+
reason: 'creation_failure',
|
|
3559
|
+
errorMsg: 'Subscription creation retry failed',
|
|
3560
|
+
fn: () => this.ensureSubscription(),
|
|
3561
|
+
retryAttempt: createRetryCount
|
|
3562
|
+
});
|
|
3563
|
+
|
|
3564
|
+
this.logger.info({
|
|
3565
|
+
msg: 'Scheduling subscription creation retry',
|
|
3566
|
+
account: this.account,
|
|
3567
|
+
retryAttempt: createRetryCount,
|
|
3568
|
+
retryDelayMs: retryDelay
|
|
3569
|
+
});
|
|
3570
|
+
} else {
|
|
3571
|
+
this.logger.error({
|
|
3572
|
+
msg: 'Max subscription creation retries exceeded',
|
|
3573
|
+
account: this.account,
|
|
3574
|
+
createRetryCount
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
} finally {
|
|
3580
|
+
if (ensureLock?.success) {
|
|
3581
|
+
try {
|
|
3582
|
+
await lock.releaseLock(ensureLock);
|
|
3583
|
+
} catch (err) {
|
|
3584
|
+
this.logger.error({ msg: 'Failed to release subscription ensure lock', account: this.account, err });
|
|
3585
|
+
}
|
|
3357
3586
|
}
|
|
3358
3587
|
}
|
|
3359
3588
|
}
|