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.
Files changed (59) hide show
  1. package/.github/workflows/test.yml +4 -0
  2. package/CHANGELOG.md +70 -0
  3. package/copy-static-files.sh +1 -1
  4. package/data/google-crawlers.json +1 -1
  5. package/eslint.config.js +2 -0
  6. package/lib/account.js +13 -9
  7. package/lib/api-routes/account-routes.js +7 -1
  8. package/lib/consts.js +17 -1
  9. package/lib/email-client/gmail/gmail-api.js +1 -12
  10. package/lib/email-client/imap-client.js +5 -3
  11. package/lib/email-client/outlook/graph-api.js +9 -15
  12. package/lib/email-client/outlook-client.js +406 -177
  13. package/lib/export.js +17 -0
  14. package/lib/imapproxy/imap-server.js +3 -2
  15. package/lib/oauth/gmail.js +12 -1
  16. package/lib/oauth/outlook.js +99 -1
  17. package/lib/oauth/pubsub/google.js +253 -85
  18. package/lib/oauth2-apps.js +620 -389
  19. package/lib/outbox.js +1 -1
  20. package/lib/routes-ui.js +193 -238
  21. package/lib/schemas.js +189 -12
  22. package/lib/ui-routes/account-routes.js +7 -2
  23. package/lib/ui-routes/admin-entities-routes.js +3 -3
  24. package/lib/ui-routes/oauth-routes.js +27 -175
  25. package/package.json +21 -21
  26. package/sbom.json +1 -1
  27. package/server.js +54 -22
  28. package/static/licenses.html +30 -90
  29. package/translations/de.mo +0 -0
  30. package/translations/de.po +54 -42
  31. package/translations/en.mo +0 -0
  32. package/translations/en.po +55 -43
  33. package/translations/et.mo +0 -0
  34. package/translations/et.po +54 -42
  35. package/translations/fr.mo +0 -0
  36. package/translations/fr.po +54 -42
  37. package/translations/ja.mo +0 -0
  38. package/translations/ja.po +54 -42
  39. package/translations/messages.pot +93 -71
  40. package/translations/nl.mo +0 -0
  41. package/translations/nl.po +54 -42
  42. package/translations/pl.mo +0 -0
  43. package/translations/pl.po +54 -42
  44. package/views/config/oauth/app.hbs +12 -0
  45. package/views/config/oauth/edit.hbs +2 -0
  46. package/views/config/oauth/index.hbs +4 -1
  47. package/views/config/oauth/new.hbs +2 -0
  48. package/views/config/oauth/subscriptions.hbs +175 -0
  49. package/views/error.hbs +4 -4
  50. package/views/partials/oauth_form.hbs +179 -4
  51. package/views/partials/oauth_tabs.hbs +8 -0
  52. package/views/partials/scope_info.hbs +10 -0
  53. package/workers/api.js +174 -96
  54. package/workers/documents.js +1 -0
  55. package/workers/export.js +6 -2
  56. package/workers/imap.js +33 -49
  57. package/workers/smtp.js +1 -0
  58. package/workers/submit.js +1 -0
  59. 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
- // Note: Scopes are checked at initialization and after account updates. If scopes change
135
- // during token refresh (rare - typically requires re-authorization), the account must be
136
- // reinitialized to detect the change. Consider checking scopes periodically if dynamic
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
- const { hasSendScope, hasReadScope } = checkAccountScopes('outlook', scopes, this.logger);
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 (profileRes.userPrincipalName && accountData.oauth2.auth?.user !== profileRes.userPrincipalName && !accountData.oauth2.auth?.delegatedUser) {
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', { status: 'success', provider: 'outlook', statusCode: '200' });
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', { status: 'failure', provider: 'outlook', statusCode });
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
- // If subscription expired or doesn't exist, ensure we have one
2769
- await this.ensureSubscription();
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 {boolean} force - Force renewal even if not expired soon
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 lockKey = `${this.getAccountKey()}:subscription-renew-lock`;
3038
- // Use constant for lock TTL (60 seconds) to handle slow network conditions
3039
- const lockTTL = OUTLOOK_SUBSCRIPTION_LOCK_TTL;
3040
-
3041
- // Try to acquire lock using Redis SET NX with expiry
3042
- const lockAcquired = await this.redis.set(lockKey, Date.now(), 'EX', lockTTL, 'NX');
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
- if (!lockAcquired) {
3045
- this.logger.info({
3046
- msg: 'Subscription renewal skipped',
3047
- reason: 'lock_exists',
3048
- account: this.account
3049
- });
3050
- return { success: false, reason: 'lock_exists' };
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.redis.hget(this.getAccountKey(), 'outlookSubscription');
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 = new Date(outlookSubscription.expirationDateTime);
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 + CLOCK_SKEW_BUFFER) {
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.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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: wasRetry ? 'Subscription renewed successfully after retry' : 'Subscription renewed successfully',
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: (outlookSubscription.state?.retryCount || 0) + 1
3327
+ retryCount: previousRetryCount + 1,
3328
+ createRetryCount: previousCreateRetryCount
3174
3329
  };
3175
- await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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 <= OUTLOOK_MAX_RETRY_ATTEMPTS) {
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
- setTimeout(() => {
3185
- // Retry will also acquire lock
3186
- this.renewSubscription(force).catch(err => {
3187
- this.logger.error({
3188
- msg: 'Subscription renewal retry failed',
3189
- account: this.account,
3190
- retryAttempt: retryCount,
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
- await this.redis.del(lockKey);
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
- let outlookSubscription = await this.redis.hget(this.getAccountKey(), 'outlookSubscription');
3233
- if (outlookSubscription) {
3234
- try {
3235
- outlookSubscription = JSON.parse(outlookSubscription);
3236
- } catch (err) {
3237
- outlookSubscription = {};
3238
- }
3239
- }
3240
-
3241
- if (!outlookSubscription) {
3242
- outlookSubscription = {};
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 renewal skipped',
3250
- reason: 'pending',
3251
- subscriptionId: outlookSubscription?.id,
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
- let now = Date.now();
3261
- let expirationDateTime = new Date(now + OUTLOOK_EXPIRATION_TIME);
3262
-
3263
- // Renew existing subscription if needed
3264
- if (outlookSubscription.id) {
3265
- let existingExpirationDateTime = new Date(outlookSubscription.expirationDateTime);
3266
- if (existingExpirationDateTime.toString() === 'Invalid Date') {
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
- if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now) {
3271
- // already expired
3272
- outlookSubscription = {};
3273
- } else if (existingExpirationDateTime && existingExpirationDateTime.getTime() < now + OUTLOOK_EXPIRATION_RENEW_TIME) {
3274
- // Use the unified renewal method
3275
- const renewalResult = await this.renewSubscription(false);
3276
- if (!renewalResult.success && renewalResult.reason === 'expired') {
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
- created: outlookSubscription.state?.time,
3290
- expirationDateTime: outlookSubscription.expirationDateTime
3438
+ stateTime: outlookSubscription.state?.time,
3439
+ account: this.account
3291
3440
  });
3292
- this.subscriptionState = 'valid';
3293
- return;
3294
- }
3295
- }
3296
3441
 
3297
- // Create new subscription if needed
3298
- if (!outlookSubscription.id) {
3299
- const notificationUrl = prepareUrl('/oauth/msg/notification', notificationBaseUrl, {
3300
- account: this.account
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
- const subscriptionPayload = {
3308
- changeType: 'created,updated,deleted',
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
- outlookSubscription.state = {
3317
- state: 'creating',
3318
- time: Date.now()
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
- let subscriptionRes;
3324
- try {
3325
- subscriptionRes = await this.request(`/subscriptions`, 'post', subscriptionPayload);
3326
- if (subscriptionRes?.expirationDateTime) {
3327
- outlookSubscription = {
3328
- id: subscriptionRes.id,
3329
- expirationDateTime: subscriptionRes.expirationDateTime,
3330
- clientState: subscriptionRes.clientState,
3331
- state: {
3332
- state: 'created',
3333
- time: Date.now()
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
- throw new Error('Empty server response');
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
- } catch (err) {
3341
- const errorMessage = err.oauthRequest?.response?.error?.message || err.message;
3342
- this.logger.error({
3343
- msg: 'Failed to create MS Graph subscription',
3344
- account: this.account,
3345
- error: errorMessage,
3346
- code: err.oauthRequest?.response?.error?.code,
3347
- statusCode: err.oauthRequest?.status
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: 'error',
3351
- error: `Subscription failed: ${errorMessage}`,
3505
+ state: 'creating',
3352
3506
  time: Date.now()
3353
3507
  };
3354
- this.subscriptionState = 'failed';
3355
- } finally {
3356
- await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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
  }