emailengine-app 2.63.4 → 2.64.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/CHANGELOG.md +56 -0
- package/data/google-crawlers.json +1 -1
- package/eslint.config.js +2 -0
- package/lib/account.js +6 -2
- 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 +7 -13
- package/lib/email-client/outlook-client.js +363 -167
- package/lib/imapproxy/imap-server.js +1 -0
- package/lib/oauth/gmail.js +12 -1
- package/lib/oauth/pubsub/google.js +253 -85
- package/lib/oauth2-apps.js +554 -377
- package/lib/routes-ui.js +186 -91
- package/lib/schemas.js +18 -1
- package/lib/ui-routes/account-routes.js +1 -1
- package/lib/ui-routes/admin-entities-routes.js +3 -3
- package/lib/ui-routes/oauth-routes.js +9 -3
- package/package.json +9 -9
- package/sbom.json +1 -1
- package/server.js +54 -22
- package/static/licenses.html +27 -27
- 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 +74 -52
- 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/index.hbs +2 -0
- package/views/config/oauth/subscriptions.hbs +175 -0
- package/views/error.hbs +4 -4
- package/views/partials/oauth_tabs.hbs +8 -0
- package/workers/api.js +174 -96
- package/workers/documents.js +1 -0
- package/workers/imap.js +30 -47
- 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,6 +210,8 @@ 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();
|
|
@@ -245,6 +335,15 @@ class OutlookClient extends BaseClient {
|
|
|
245
335
|
if (!isSendOnly) {
|
|
246
336
|
// additional operations for full access accounts
|
|
247
337
|
|
|
338
|
+
// Reset subscription retry counts on reconnect — fresh auth = fresh slate
|
|
339
|
+
let storedSub = await this.getStoredSubscription();
|
|
340
|
+
if (storedSub.state && (storedSub.state.createRetryCount > 0 || storedSub.state.retryCount > 0)) {
|
|
341
|
+
storedSub.state.createRetryCount = 0;
|
|
342
|
+
storedSub.state.retryCount = 0;
|
|
343
|
+
storedSub.state.error = null;
|
|
344
|
+
await this.saveStoredSubscription(storedSub);
|
|
345
|
+
}
|
|
346
|
+
|
|
248
347
|
// Set up webhook subscription for real-time updates
|
|
249
348
|
await this.ensureSubscription();
|
|
250
349
|
this.setupRenewWatchTimer();
|
|
@@ -266,6 +365,7 @@ class OutlookClient extends BaseClient {
|
|
|
266
365
|
*/
|
|
267
366
|
async close() {
|
|
268
367
|
clearTimeout(this.renewWatchTimer);
|
|
368
|
+
clearTimeout(this.renewRetryTimer);
|
|
269
369
|
this.closed = true;
|
|
270
370
|
|
|
271
371
|
if (['init', 'connecting', 'syncing', 'connected'].includes(this.state)) {
|
|
@@ -289,8 +389,19 @@ class OutlookClient extends BaseClient {
|
|
|
289
389
|
*/
|
|
290
390
|
async delete() {
|
|
291
391
|
clearTimeout(this.renewWatchTimer);
|
|
392
|
+
clearTimeout(this.renewRetryTimer);
|
|
292
393
|
this.closed = true;
|
|
293
394
|
|
|
395
|
+
// Best-effort: delete MS Graph subscription
|
|
396
|
+
try {
|
|
397
|
+
let outlookSubscription = await this.getStoredSubscription();
|
|
398
|
+
if (outlookSubscription.id) {
|
|
399
|
+
await this.request(`/subscriptions/${outlookSubscription.id}`, 'DELETE');
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
this.logger.error({ msg: 'Failed to delete MS Graph subscription', account: this.account, err });
|
|
403
|
+
}
|
|
404
|
+
|
|
294
405
|
if (['init', 'connecting', 'syncing', 'connected'].includes(this.state)) {
|
|
295
406
|
this.state = 'disconnected';
|
|
296
407
|
await this.setStateVal();
|
|
@@ -2762,11 +2873,20 @@ class OutlookClient extends BaseClient {
|
|
|
2762
2873
|
|
|
2763
2874
|
try {
|
|
2764
2875
|
// First try to renew existing subscription
|
|
2765
|
-
const renewalResult = await this.renewSubscription(false);
|
|
2876
|
+
const renewalResult = await this.renewSubscription({ force: false });
|
|
2766
2877
|
|
|
2767
2878
|
if (!renewalResult.success && (renewalResult.reason === 'expired' || renewalResult.reason === 'no_subscription')) {
|
|
2768
|
-
//
|
|
2769
|
-
await this.
|
|
2879
|
+
// Check if we've exceeded max creation retries
|
|
2880
|
+
let subscriptionData = await this.getStoredSubscription();
|
|
2881
|
+
if (subscriptionData?.state?.createRetryCount >= OUTLOOK_MAX_RETRY_ATTEMPTS) {
|
|
2882
|
+
this.logger.error({
|
|
2883
|
+
msg: 'Max subscription creation retries exceeded, waiting for reconnect',
|
|
2884
|
+
account: this.account,
|
|
2885
|
+
createRetryCount: subscriptionData.state.createRetryCount
|
|
2886
|
+
});
|
|
2887
|
+
} else {
|
|
2888
|
+
await this.ensureSubscription();
|
|
2889
|
+
}
|
|
2770
2890
|
}
|
|
2771
2891
|
} catch (err) {
|
|
2772
2892
|
this.logger.error({ msg: 'Failed to renew MS Graph change subscription', account: this.account, err });
|
|
@@ -3019,13 +3139,19 @@ class OutlookClient extends BaseClient {
|
|
|
3019
3139
|
/**
|
|
3020
3140
|
* Unified method to renew MS Graph webhook subscription with proper locking
|
|
3021
3141
|
* Can be called from timer or lifecycle notification handler
|
|
3022
|
-
* @param {
|
|
3142
|
+
* @param {Object} [opts] - Options
|
|
3143
|
+
* @param {boolean} [opts.force=false] - Force renewal even if not expired soon
|
|
3144
|
+
* @param {boolean} [opts.skipLock=false] - Skip lock acquisition (when caller already holds the subscription lock)
|
|
3023
3145
|
* @returns {Object} Result with success status and details
|
|
3024
3146
|
*/
|
|
3025
|
-
async renewSubscription(force = false) {
|
|
3147
|
+
async renewSubscription({ force = false, skipLock = false } = {}) {
|
|
3148
|
+
if (this.closed) {
|
|
3149
|
+
return { success: false, reason: 'closed' };
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3026
3152
|
// In-memory check to prevent concurrent renewals from lifecycle events
|
|
3027
3153
|
// This is faster than Redis lock and reduces noise from duplicate requests
|
|
3028
|
-
if (this.renewalInProgress) {
|
|
3154
|
+
if (this.renewalInProgress && !force) {
|
|
3029
3155
|
this.logger.debug({
|
|
3030
3156
|
msg: 'Subscription renewal skipped',
|
|
3031
3157
|
reason: 'renewal_in_progress',
|
|
@@ -3034,20 +3160,31 @@ class OutlookClient extends BaseClient {
|
|
|
3034
3160
|
return { success: false, reason: 'renewal_in_progress' };
|
|
3035
3161
|
}
|
|
3036
3162
|
|
|
3037
|
-
const
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3163
|
+
const lock = this.accountObject.getLock();
|
|
3164
|
+
const lockId = this.getSubscriptionLockId();
|
|
3165
|
+
let renewLock;
|
|
3166
|
+
if (!skipLock) {
|
|
3167
|
+
try {
|
|
3168
|
+
renewLock = await lock.acquireLock(lockId, OUTLOOK_SUBSCRIPTION_LOCK_TTL);
|
|
3169
|
+
} catch (err) {
|
|
3170
|
+
this.logger.error({ msg: 'Failed to acquire subscription renew lock', account: this.account, err });
|
|
3171
|
+
this.scheduleSubscriptionRetry({
|
|
3172
|
+
delayMs: OUTLOOK_RETRY_BASE_DELAY * 1000,
|
|
3173
|
+
reason: 'lock_error',
|
|
3174
|
+
errorMsg: 'Subscription renew retry after lock error failed',
|
|
3175
|
+
fn: () => this.renewSubscription({ force: false })
|
|
3176
|
+
});
|
|
3177
|
+
return { success: false, reason: 'lock_error' };
|
|
3178
|
+
}
|
|
3043
3179
|
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3180
|
+
if (!renewLock.success) {
|
|
3181
|
+
this.logger.info({
|
|
3182
|
+
msg: 'Subscription renewal skipped',
|
|
3183
|
+
reason: 'lock_exists',
|
|
3184
|
+
account: this.account
|
|
3185
|
+
});
|
|
3186
|
+
return { success: false, reason: 'lock_exists' };
|
|
3187
|
+
}
|
|
3051
3188
|
}
|
|
3052
3189
|
|
|
3053
3190
|
// Set in-memory flag to prevent concurrent calls
|
|
@@ -3055,16 +3192,7 @@ class OutlookClient extends BaseClient {
|
|
|
3055
3192
|
|
|
3056
3193
|
try {
|
|
3057
3194
|
// 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
|
-
}
|
|
3195
|
+
let outlookSubscription = await this.getStoredSubscription();
|
|
3068
3196
|
|
|
3069
3197
|
// Check if subscription exists and is valid
|
|
3070
3198
|
if (!outlookSubscription.id) {
|
|
@@ -3072,17 +3200,12 @@ class OutlookClient extends BaseClient {
|
|
|
3072
3200
|
return { success: false, reason: 'no_subscription' };
|
|
3073
3201
|
}
|
|
3074
3202
|
|
|
3075
|
-
let existingExpirationDateTime =
|
|
3076
|
-
if (existingExpirationDateTime.toString() === 'Invalid Date') {
|
|
3077
|
-
existingExpirationDateTime = null;
|
|
3078
|
-
}
|
|
3203
|
+
let existingExpirationDateTime = this.parseExpirationDate(outlookSubscription.expirationDateTime);
|
|
3079
3204
|
|
|
3080
3205
|
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
3206
|
|
|
3084
3207
|
// Check if already expired (with buffer for clock skew)
|
|
3085
|
-
if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now +
|
|
3208
|
+
if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_CLOCK_SKEW_BUFFER) {
|
|
3086
3209
|
this.logger.warn({
|
|
3087
3210
|
msg: 'Subscription already expired or expiring imminently',
|
|
3088
3211
|
subscriptionId: outlookSubscription.id,
|
|
@@ -3116,41 +3239,39 @@ class OutlookClient extends BaseClient {
|
|
|
3116
3239
|
expirationDateTime: expirationDateTime.toISOString()
|
|
3117
3240
|
};
|
|
3118
3241
|
|
|
3242
|
+
// Capture previous retry counts before overwriting state
|
|
3243
|
+
const previousRetryCount = outlookSubscription.state?.retryCount || 0;
|
|
3244
|
+
const previousCreateRetryCount = outlookSubscription.state?.createRetryCount || 0;
|
|
3245
|
+
|
|
3119
3246
|
// Update state to renewing
|
|
3120
3247
|
outlookSubscription.state = {
|
|
3121
3248
|
state: 'renewing',
|
|
3122
3249
|
time: Date.now()
|
|
3123
3250
|
};
|
|
3124
|
-
await this.
|
|
3251
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3125
3252
|
this.subscriptionState = 'pending';
|
|
3126
3253
|
|
|
3127
3254
|
try {
|
|
3128
3255
|
const subscriptionRes = await this.request(`/subscriptions/${outlookSubscription.id}`, 'PATCH', subscriptionPayload);
|
|
3129
3256
|
|
|
3130
3257
|
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
3258
|
outlookSubscription.expirationDateTime = subscriptionRes.expirationDateTime;
|
|
3136
3259
|
outlookSubscription.state = {
|
|
3137
3260
|
state: 'created',
|
|
3138
3261
|
time: Date.now(),
|
|
3139
|
-
// Clear any previous error state and retry count
|
|
3140
3262
|
retryCount: 0,
|
|
3263
|
+
createRetryCount: 0,
|
|
3141
3264
|
error: null
|
|
3142
3265
|
};
|
|
3143
3266
|
|
|
3144
|
-
await this.
|
|
3267
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3145
3268
|
this.subscriptionState = 'valid';
|
|
3146
3269
|
|
|
3147
|
-
// Log if this was a successful retry after errors
|
|
3148
3270
|
this.logger.info({
|
|
3149
|
-
msg:
|
|
3271
|
+
msg: previousRetryCount > 0 ? 'Subscription renewed successfully after retry' : 'Subscription renewed successfully',
|
|
3150
3272
|
subscriptionId: outlookSubscription.id,
|
|
3151
3273
|
newExpirationDateTime: subscriptionRes.expirationDateTime,
|
|
3152
3274
|
account: this.account,
|
|
3153
|
-
wasRetry,
|
|
3154
3275
|
previousRetryCount
|
|
3155
3276
|
});
|
|
3156
3277
|
|
|
@@ -3170,28 +3291,24 @@ class OutlookClient extends BaseClient {
|
|
|
3170
3291
|
state: 'error',
|
|
3171
3292
|
error: `Subscription renewal failed: ${err.oauthRequest?.response?.error?.message || err.message}`,
|
|
3172
3293
|
time: Date.now(),
|
|
3173
|
-
retryCount:
|
|
3294
|
+
retryCount: previousRetryCount + 1,
|
|
3295
|
+
createRetryCount: previousCreateRetryCount
|
|
3174
3296
|
};
|
|
3175
|
-
await this.
|
|
3297
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3176
3298
|
this.subscriptionState = 'failed';
|
|
3177
3299
|
|
|
3178
3300
|
// Schedule retry with exponential backoff
|
|
3179
3301
|
const retryCount = outlookSubscription.state.retryCount;
|
|
3180
|
-
if (retryCount
|
|
3181
|
-
// Exponential backoff: 30s, 60s, 120s (capped)
|
|
3302
|
+
if (retryCount < OUTLOOK_MAX_RETRY_ATTEMPTS) {
|
|
3182
3303
|
const retryDelay = Math.min(OUTLOOK_RETRY_BASE_DELAY * Math.pow(2, retryCount - 1), OUTLOOK_RETRY_MAX_DELAY) * 1000;
|
|
3183
3304
|
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
err
|
|
3192
|
-
});
|
|
3193
|
-
});
|
|
3194
|
-
}, retryDelay);
|
|
3305
|
+
this.scheduleSubscriptionRetry({
|
|
3306
|
+
delayMs: retryDelay,
|
|
3307
|
+
reason: 'renewal_failure',
|
|
3308
|
+
errorMsg: 'Subscription renewal retry failed',
|
|
3309
|
+
fn: () => this.renewSubscription({ force }),
|
|
3310
|
+
retryAttempt: retryCount
|
|
3311
|
+
});
|
|
3195
3312
|
|
|
3196
3313
|
this.logger.info({
|
|
3197
3314
|
msg: 'Scheduling subscription renewal retry',
|
|
@@ -3212,7 +3329,13 @@ class OutlookClient extends BaseClient {
|
|
|
3212
3329
|
} finally {
|
|
3213
3330
|
// Always release the lock and clear in-memory flag
|
|
3214
3331
|
this.renewalInProgress = false;
|
|
3215
|
-
|
|
3332
|
+
if (!skipLock && renewLock?.success) {
|
|
3333
|
+
try {
|
|
3334
|
+
await lock.releaseLock(renewLock);
|
|
3335
|
+
} catch (err) {
|
|
3336
|
+
this.logger.error({ msg: 'Failed to release subscription renew lock', account: this.account, err });
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3216
3339
|
}
|
|
3217
3340
|
}
|
|
3218
3341
|
|
|
@@ -3220,7 +3343,11 @@ class OutlookClient extends BaseClient {
|
|
|
3220
3343
|
* Ensure webhook subscription exists and is valid
|
|
3221
3344
|
* Creates new subscription or renews existing one
|
|
3222
3345
|
*/
|
|
3223
|
-
async ensureSubscription() {
|
|
3346
|
+
async ensureSubscription({ clearExisting = false } = {}) {
|
|
3347
|
+
if (this.closed) {
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3224
3351
|
let serviceUrl = (await settings.get('serviceUrl')) || null;
|
|
3225
3352
|
let notificationBaseUrl = (await settings.get('notificationBaseUrl')) || serviceUrl || '';
|
|
3226
3353
|
|
|
@@ -3229,131 +3356,200 @@ class OutlookClient extends BaseClient {
|
|
|
3229
3356
|
return false;
|
|
3230
3357
|
}
|
|
3231
3358
|
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3359
|
+
// Acquire distributed lock to prevent concurrent subscription operations
|
|
3360
|
+
const lock = this.accountObject.getLock();
|
|
3361
|
+
const lockId = this.getSubscriptionLockId();
|
|
3362
|
+
let ensureLock;
|
|
3363
|
+
try {
|
|
3364
|
+
ensureLock = await lock.acquireLock(lockId, OUTLOOK_SUBSCRIPTION_LOCK_TTL);
|
|
3365
|
+
} catch (err) {
|
|
3366
|
+
this.logger.error({ msg: 'Failed to acquire subscription ensure lock', account: this.account, err });
|
|
3367
|
+
// Schedule retry for transient lock errors (e.g., Redis connectivity).
|
|
3368
|
+
// Do not forward clearExisting on retry: by the time the retry fires,
|
|
3369
|
+
// another path may have created a valid subscription that should not be cleared.
|
|
3370
|
+
this.scheduleSubscriptionRetry({
|
|
3371
|
+
delayMs: OUTLOOK_RETRY_BASE_DELAY * 1000,
|
|
3372
|
+
reason: 'lock_error',
|
|
3373
|
+
errorMsg: 'Subscription ensure retry after lock error failed',
|
|
3374
|
+
fn: () => this.ensureSubscription()
|
|
3375
|
+
});
|
|
3376
|
+
return;
|
|
3243
3377
|
}
|
|
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)
|
|
3378
|
+
if (!ensureLock.success) {
|
|
3248
3379
|
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
|
|
3380
|
+
msg: 'Subscription creation skipped',
|
|
3381
|
+
reason: 'lock_exists',
|
|
3382
|
+
account: this.account
|
|
3255
3383
|
});
|
|
3256
|
-
this.subscriptionState = 'pending';
|
|
3257
3384
|
return;
|
|
3258
3385
|
}
|
|
3259
3386
|
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
existingExpirationDateTime = null;
|
|
3387
|
+
try {
|
|
3388
|
+
if (clearExisting) {
|
|
3389
|
+
await this.redis.hdel(this.getAccountKey(), 'outlookSubscription');
|
|
3390
|
+
this.logger.info({
|
|
3391
|
+
msg: 'Cleared existing subscription data for re-creation',
|
|
3392
|
+
account: this.account
|
|
3393
|
+
});
|
|
3268
3394
|
}
|
|
3269
3395
|
|
|
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',
|
|
3396
|
+
let outlookSubscription = await this.getStoredSubscription();
|
|
3397
|
+
|
|
3398
|
+
// If state is creating/renewing, the previous lock holder crashed before completing.
|
|
3399
|
+
// Since we hold the lock, no other operation is in progress — reset to a clean state.
|
|
3400
|
+
if (['creating', 'renewing'].includes(outlookSubscription.state?.state)) {
|
|
3401
|
+
this.logger.warn({
|
|
3402
|
+
msg: 'Found stale subscription state from crashed operation, resetting state',
|
|
3287
3403
|
subscriptionId: outlookSubscription?.id,
|
|
3288
3404
|
state: outlookSubscription.state?.state,
|
|
3289
|
-
|
|
3290
|
-
|
|
3405
|
+
stateTime: outlookSubscription.state?.time,
|
|
3406
|
+
account: this.account
|
|
3291
3407
|
});
|
|
3292
|
-
this.subscriptionState = 'valid';
|
|
3293
|
-
return;
|
|
3294
|
-
}
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
// Create new subscription if needed
|
|
3298
|
-
if (!outlookSubscription.id) {
|
|
3299
|
-
const notificationUrl = prepareUrl('/oauth/msg/notification', notificationBaseUrl, {
|
|
3300
|
-
account: this.account
|
|
3301
|
-
});
|
|
3302
3408
|
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3409
|
+
// Reset transient state so downstream logic sees a clean state
|
|
3410
|
+
outlookSubscription.state.state = outlookSubscription.id ? 'created' : 'error';
|
|
3411
|
+
outlookSubscription.state.time = Date.now();
|
|
3412
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3413
|
+
}
|
|
3306
3414
|
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
notificationUrl,
|
|
3310
|
-
lifecycleNotificationUrl,
|
|
3311
|
-
resource: `/${this.oauth2UserPath}/messages`,
|
|
3312
|
-
expirationDateTime: expirationDateTime.toISOString(),
|
|
3313
|
-
clientState: crypto.randomUUID()
|
|
3314
|
-
};
|
|
3415
|
+
let now = Date.now();
|
|
3416
|
+
let expirationDateTime = new Date(now + OUTLOOK_EXPIRATION_TIME);
|
|
3315
3417
|
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
};
|
|
3320
|
-
await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
|
|
3321
|
-
this.subscriptionState = 'pending';
|
|
3418
|
+
// Renew existing subscription if needed
|
|
3419
|
+
if (outlookSubscription.id) {
|
|
3420
|
+
let existingExpirationDateTime = this.parseExpirationDate(outlookSubscription.expirationDateTime);
|
|
3322
3421
|
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
if (
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
};
|
|
3336
|
-
this.subscriptionState = 'valid';
|
|
3422
|
+
if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_CLOCK_SKEW_BUFFER) {
|
|
3423
|
+
// already expired (with buffer for clock skew)
|
|
3424
|
+
outlookSubscription = {};
|
|
3425
|
+
} else if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_EXPIRATION_RENEW_TIME) {
|
|
3426
|
+
// Use the unified renewal method (skip lock since we already hold it)
|
|
3427
|
+
const renewalResult = await this.renewSubscription({ force: false, skipLock: true });
|
|
3428
|
+
if (!renewalResult.success && renewalResult.reason === 'expired') {
|
|
3429
|
+
// Subscription expired, clear it to create a new one
|
|
3430
|
+
outlookSubscription = {};
|
|
3431
|
+
} else {
|
|
3432
|
+
return renewalResult.success;
|
|
3433
|
+
}
|
|
3337
3434
|
} else {
|
|
3338
|
-
|
|
3435
|
+
// Subscription is valid, do nothing
|
|
3436
|
+
this.logger.info({
|
|
3437
|
+
msg: 'Subscription renewal skipped',
|
|
3438
|
+
reason: 'valid',
|
|
3439
|
+
subscriptionId: outlookSubscription?.id,
|
|
3440
|
+
state: outlookSubscription.state?.state,
|
|
3441
|
+
created: outlookSubscription.state?.time,
|
|
3442
|
+
expirationDateTime: outlookSubscription.expirationDateTime
|
|
3443
|
+
});
|
|
3444
|
+
this.subscriptionState = 'valid';
|
|
3445
|
+
return;
|
|
3339
3446
|
}
|
|
3340
|
-
}
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
// Create new subscription if needed
|
|
3450
|
+
if (!outlookSubscription.id) {
|
|
3451
|
+
const notificationUrl = prepareUrl('/oauth/msg/notification', notificationBaseUrl, {
|
|
3452
|
+
account: this.account
|
|
3453
|
+
});
|
|
3454
|
+
|
|
3455
|
+
const lifecycleNotificationUrl = prepareUrl('/oauth/msg/lifecycle', notificationBaseUrl, {
|
|
3456
|
+
account: this.account
|
|
3348
3457
|
});
|
|
3458
|
+
|
|
3459
|
+
const subscriptionPayload = {
|
|
3460
|
+
changeType: 'created,updated,deleted',
|
|
3461
|
+
notificationUrl,
|
|
3462
|
+
lifecycleNotificationUrl,
|
|
3463
|
+
resource: `/${this.oauth2UserPath}/messages`,
|
|
3464
|
+
expirationDateTime: expirationDateTime.toISOString(),
|
|
3465
|
+
clientState: crypto.randomUUID()
|
|
3466
|
+
};
|
|
3467
|
+
|
|
3468
|
+
// Capture previous create retry count before overwriting state
|
|
3469
|
+
const previousCreateRetryCount = outlookSubscription.state?.createRetryCount || 0;
|
|
3470
|
+
|
|
3349
3471
|
outlookSubscription.state = {
|
|
3350
|
-
state: '
|
|
3351
|
-
error: `Subscription failed: ${errorMessage}`,
|
|
3472
|
+
state: 'creating',
|
|
3352
3473
|
time: Date.now()
|
|
3353
3474
|
};
|
|
3354
|
-
this.
|
|
3355
|
-
|
|
3356
|
-
|
|
3475
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3476
|
+
this.subscriptionState = 'pending';
|
|
3477
|
+
|
|
3478
|
+
let subscriptionRes;
|
|
3479
|
+
try {
|
|
3480
|
+
subscriptionRes = await this.request(`/subscriptions`, 'post', subscriptionPayload);
|
|
3481
|
+
if (subscriptionRes?.expirationDateTime) {
|
|
3482
|
+
outlookSubscription = {
|
|
3483
|
+
id: subscriptionRes.id,
|
|
3484
|
+
expirationDateTime: subscriptionRes.expirationDateTime,
|
|
3485
|
+
clientState: subscriptionRes.clientState,
|
|
3486
|
+
state: {
|
|
3487
|
+
state: 'created',
|
|
3488
|
+
time: Date.now(),
|
|
3489
|
+
createRetryCount: 0,
|
|
3490
|
+
retryCount: 0
|
|
3491
|
+
}
|
|
3492
|
+
};
|
|
3493
|
+
this.subscriptionState = 'valid';
|
|
3494
|
+
} else {
|
|
3495
|
+
throw new Error('Empty server response');
|
|
3496
|
+
}
|
|
3497
|
+
} catch (err) {
|
|
3498
|
+
const errorMessage = err.oauthRequest?.response?.error?.message || err.message;
|
|
3499
|
+
this.logger.error({
|
|
3500
|
+
msg: 'Failed to create MS Graph subscription',
|
|
3501
|
+
account: this.account,
|
|
3502
|
+
error: errorMessage,
|
|
3503
|
+
code: err.oauthRequest?.response?.error?.code,
|
|
3504
|
+
statusCode: err.oauthRequest?.status
|
|
3505
|
+
});
|
|
3506
|
+
outlookSubscription.state = {
|
|
3507
|
+
state: 'error',
|
|
3508
|
+
error: `Subscription failed: ${errorMessage}`,
|
|
3509
|
+
time: Date.now(),
|
|
3510
|
+
createRetryCount: previousCreateRetryCount + 1
|
|
3511
|
+
};
|
|
3512
|
+
this.subscriptionState = 'failed';
|
|
3513
|
+
} finally {
|
|
3514
|
+
await this.saveStoredSubscription(outlookSubscription);
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
// Schedule retry with exponential backoff if creation failed
|
|
3518
|
+
if (outlookSubscription.state?.state === 'error') {
|
|
3519
|
+
const createRetryCount = outlookSubscription.state.createRetryCount || 0;
|
|
3520
|
+
if (createRetryCount < OUTLOOK_MAX_RETRY_ATTEMPTS) {
|
|
3521
|
+
const retryDelay = Math.min(OUTLOOK_RETRY_BASE_DELAY * Math.pow(2, createRetryCount - 1), OUTLOOK_RETRY_MAX_DELAY) * 1000;
|
|
3522
|
+
|
|
3523
|
+
this.scheduleSubscriptionRetry({
|
|
3524
|
+
delayMs: retryDelay,
|
|
3525
|
+
reason: 'creation_failure',
|
|
3526
|
+
errorMsg: 'Subscription creation retry failed',
|
|
3527
|
+
fn: () => this.ensureSubscription(),
|
|
3528
|
+
retryAttempt: createRetryCount
|
|
3529
|
+
});
|
|
3530
|
+
|
|
3531
|
+
this.logger.info({
|
|
3532
|
+
msg: 'Scheduling subscription creation retry',
|
|
3533
|
+
account: this.account,
|
|
3534
|
+
retryAttempt: createRetryCount,
|
|
3535
|
+
retryDelayMs: retryDelay
|
|
3536
|
+
});
|
|
3537
|
+
} else {
|
|
3538
|
+
this.logger.error({
|
|
3539
|
+
msg: 'Max subscription creation retries exceeded',
|
|
3540
|
+
account: this.account,
|
|
3541
|
+
createRetryCount
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
} finally {
|
|
3547
|
+
if (ensureLock?.success) {
|
|
3548
|
+
try {
|
|
3549
|
+
await lock.releaseLock(ensureLock);
|
|
3550
|
+
} catch (err) {
|
|
3551
|
+
this.logger.error({ msg: 'Failed to release subscription ensure lock', account: this.account, err });
|
|
3552
|
+
}
|
|
3357
3553
|
}
|
|
3358
3554
|
}
|
|
3359
3555
|
}
|