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.
Files changed (48) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/eslint.config.js +2 -0
  4. package/lib/account.js +6 -2
  5. package/lib/consts.js +17 -1
  6. package/lib/email-client/gmail/gmail-api.js +1 -12
  7. package/lib/email-client/imap-client.js +5 -3
  8. package/lib/email-client/outlook/graph-api.js +7 -13
  9. package/lib/email-client/outlook-client.js +363 -167
  10. package/lib/imapproxy/imap-server.js +1 -0
  11. package/lib/oauth/gmail.js +12 -1
  12. package/lib/oauth/pubsub/google.js +253 -85
  13. package/lib/oauth2-apps.js +554 -377
  14. package/lib/routes-ui.js +186 -91
  15. package/lib/schemas.js +18 -1
  16. package/lib/ui-routes/account-routes.js +1 -1
  17. package/lib/ui-routes/admin-entities-routes.js +3 -3
  18. package/lib/ui-routes/oauth-routes.js +9 -3
  19. package/package.json +9 -9
  20. package/sbom.json +1 -1
  21. package/server.js +54 -22
  22. package/static/licenses.html +27 -27
  23. package/translations/de.mo +0 -0
  24. package/translations/de.po +54 -42
  25. package/translations/en.mo +0 -0
  26. package/translations/en.po +55 -43
  27. package/translations/et.mo +0 -0
  28. package/translations/et.po +54 -42
  29. package/translations/fr.mo +0 -0
  30. package/translations/fr.po +54 -42
  31. package/translations/ja.mo +0 -0
  32. package/translations/ja.po +54 -42
  33. package/translations/messages.pot +74 -52
  34. package/translations/nl.mo +0 -0
  35. package/translations/nl.po +54 -42
  36. package/translations/pl.mo +0 -0
  37. package/translations/pl.po +54 -42
  38. package/views/config/oauth/app.hbs +12 -0
  39. package/views/config/oauth/index.hbs +2 -0
  40. package/views/config/oauth/subscriptions.hbs +175 -0
  41. package/views/error.hbs +4 -4
  42. package/views/partials/oauth_tabs.hbs +8 -0
  43. package/workers/api.js +174 -96
  44. package/workers/documents.js +1 -0
  45. package/workers/imap.js +30 -47
  46. package/workers/smtp.js +1 -0
  47. package/workers/submit.js +1 -0
  48. 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
- // If subscription expired or doesn't exist, ensure we have one
2769
- await this.ensureSubscription();
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 {boolean} force - Force renewal even if not expired soon
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 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');
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
- 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' };
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.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
- }
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 = new Date(outlookSubscription.expirationDateTime);
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 + CLOCK_SKEW_BUFFER) {
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.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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: wasRetry ? 'Subscription renewed successfully after retry' : 'Subscription renewed successfully',
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: (outlookSubscription.state?.retryCount || 0) + 1
3294
+ retryCount: previousRetryCount + 1,
3295
+ createRetryCount: previousCreateRetryCount
3174
3296
  };
3175
- await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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 <= OUTLOOK_MAX_RETRY_ATTEMPTS) {
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
- 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);
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
- await this.redis.del(lockKey);
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
- 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 = {};
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 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
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
- 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;
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
- 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',
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
- created: outlookSubscription.state?.time,
3290
- expirationDateTime: outlookSubscription.expirationDateTime
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
- const lifecycleNotificationUrl = prepareUrl('/oauth/msg/lifecycle', notificationBaseUrl, {
3304
- account: this.account
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
- 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
- };
3415
+ let now = Date.now();
3416
+ let expirationDateTime = new Date(now + OUTLOOK_EXPIRATION_TIME);
3315
3417
 
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';
3418
+ // Renew existing subscription if needed
3419
+ if (outlookSubscription.id) {
3420
+ let existingExpirationDateTime = this.parseExpirationDate(outlookSubscription.expirationDateTime);
3322
3421
 
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';
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
- throw new Error('Empty server response');
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
- } 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
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: 'error',
3351
- error: `Subscription failed: ${errorMessage}`,
3472
+ state: 'creating',
3352
3473
  time: Date.now()
3353
3474
  };
3354
- this.subscriptionState = 'failed';
3355
- } finally {
3356
- await this.redis.hSetExists(this.getAccountKey(), 'outlookSubscription', JSON.stringify(outlookSubscription));
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
  }