emailengine-app 2.61.0 → 2.61.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +17 -178
  5. package/lib/api-routes/account-routes.js +1006 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +3 -1
  14. package/lib/email-client/message-builder.js +566 -0
  15. package/lib/email-client/notification-handler.js +314 -0
  16. package/lib/email-client/outlook/graph-api.js +326 -0
  17. package/lib/email-client/outlook-client.js +159 -113
  18. package/lib/email-client/smtp-pool-manager.js +196 -0
  19. package/lib/imapproxy/imap-server.js +3 -12
  20. package/lib/oauth/gmail.js +4 -4
  21. package/lib/oauth/mail-ru.js +30 -5
  22. package/lib/oauth/outlook.js +57 -3
  23. package/lib/oauth/pubsub/google.js +30 -11
  24. package/lib/oauth/scope-checker.js +202 -0
  25. package/lib/oauth2-apps.js +8 -4
  26. package/lib/redis-operations.js +484 -0
  27. package/lib/routes-ui.js +283 -2582
  28. package/lib/tools.js +5 -196
  29. package/lib/ui-routes/account-routes.js +1931 -0
  30. package/lib/ui-routes/admin-config-routes.js +1233 -0
  31. package/lib/ui-routes/admin-entities-routes.js +2367 -0
  32. package/lib/ui-routes/oauth-routes.js +992 -0
  33. package/lib/utils/network.js +237 -0
  34. package/package.json +12 -12
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +91 -21
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +85 -82
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +63 -71
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +84 -82
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +85 -82
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +84 -82
  48. package/translations/messages.pot +67 -80
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +86 -82
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +84 -82
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/workers/webhooks.js +6 -0
  78. package/lib/imapproxy/imap-core/test/client.js +0 -46
  79. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  80. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  81. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  82. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  83. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  84. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  88. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  89. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  90. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  92. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  93. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  94. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  95. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  96. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  97. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  98. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  99. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  100. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  101. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  102. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  103. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  104. package/test/api-test.js +0 -899
  105. package/test/autoreply-test.js +0 -327
  106. package/test/bounce-test.js +0 -151
  107. package/test/complaint-test.js +0 -256
  108. package/test/fixtures/autoreply/LICENSE +0 -27
  109. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  110. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  111. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  112. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  113. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  114. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  115. package/test/fixtures/bounces/163.eml +0 -2521
  116. package/test/fixtures/bounces/fastmail.eml +0 -242
  117. package/test/fixtures/bounces/gmail.eml +0 -252
  118. package/test/fixtures/bounces/hotmail.eml +0 -655
  119. package/test/fixtures/bounces/mailru.eml +0 -121
  120. package/test/fixtures/bounces/outlook.eml +0 -1107
  121. package/test/fixtures/bounces/postfix.eml +0 -101
  122. package/test/fixtures/bounces/rambler.eml +0 -116
  123. package/test/fixtures/bounces/workmail.eml +0 -142
  124. package/test/fixtures/bounces/yahoo.eml +0 -139
  125. package/test/fixtures/bounces/zoho.eml +0 -83
  126. package/test/fixtures/bounces/zonemta.eml +0 -100
  127. package/test/fixtures/complaints/LICENSE +0 -27
  128. package/test/fixtures/complaints/amazonses.eml +0 -72
  129. package/test/fixtures/complaints/dmarc.eml +0 -59
  130. package/test/fixtures/complaints/hotmail.eml +0 -49
  131. package/test/fixtures/complaints/optout.eml +0 -40
  132. package/test/fixtures/complaints/standard-arf.eml +0 -68
  133. package/test/fixtures/complaints/yahoo.eml +0 -68
  134. package/test/oauth2-apps-test.js +0 -301
  135. package/test/sendonly-test.js +0 -160
  136. package/test/test-config.js +0 -34
  137. package/test/webhooks-server.js +0 -39
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ const { metricsMeta } = require('../base-client');
4
+
5
+ // Gmail API configuration
6
+ const GMAIL_API_BASE = 'https://gmail.googleapis.com';
7
+
8
+ // Maximum concurrent listing requests
9
+ const LIST_BATCH_SIZE = 10;
10
+
11
+ // Rate limiting configuration
12
+ const MAX_RETRY_ATTEMPTS = 3;
13
+ const RETRY_BASE_DELAY = 1000; // 1 second base delay
14
+
15
+ // Gmail API error code mapping to internal error codes
16
+ // https://developers.google.com/gmail/api/reference/rest#error-codes
17
+ const GMAIL_ERROR_MAP = {
18
+ INVALID_ARGUMENT: { code: 'InvalidArgument', status: 400 },
19
+ FAILED_PRECONDITION: { code: 'FailedPrecondition', status: 400 },
20
+ NOT_FOUND: { code: 'NotFound', status: 404 },
21
+ PERMISSION_DENIED: { code: 'PermissionDenied', status: 403 },
22
+ RESOURCE_EXHAUSTED: { code: 'RateLimitExceeded', status: 429 },
23
+ UNAUTHENTICATED: { code: 'Unauthenticated', status: 401 },
24
+ INTERNAL: { code: 'InternalError', status: 500 },
25
+ UNAVAILABLE: { code: 'ServiceUnavailable', status: 503 }
26
+ };
27
+
28
+ /**
29
+ * Creates an error from a Gmail API error response
30
+ * @param {Object} gmailError - The error object from Gmail API
31
+ * @param {string} gmailErrorStatus - The error status from Gmail API
32
+ * @returns {Error|null} A formatted error object or null if not mappable
33
+ */
34
+ function createGmailError(gmailError, gmailErrorStatus) {
35
+ const mappedError = GMAIL_ERROR_MAP[gmailErrorStatus];
36
+ if (!mappedError) {
37
+ return null;
38
+ }
39
+
40
+ const error = new Error(gmailError?.message || gmailErrorStatus);
41
+ error.code = mappedError.code;
42
+ error.statusCode = mappedError.status;
43
+ error.gmailErrorStatus = gmailErrorStatus;
44
+ return error;
45
+ }
46
+
47
+ /**
48
+ * Checks if an error indicates rate limiting
49
+ * @param {Object} err - The error object
50
+ * @returns {boolean} True if rate limited
51
+ */
52
+ function isRateLimitError(err) {
53
+ const status = err.oauthRequest?.status;
54
+ const errorReason = err.oauthRequest?.response?.error?.errors?.[0]?.reason;
55
+
56
+ return status === 429 || errorReason === 'rateLimitExceeded' || errorReason === 'userRateLimitExceeded';
57
+ }
58
+
59
+ /**
60
+ * Calculates retry delay with exponential backoff and jitter
61
+ * @param {Object} err - The error object (may contain Retry-After header)
62
+ * @param {number} attempt - Current attempt number (0-indexed)
63
+ * @returns {number} Delay in milliseconds
64
+ */
65
+ function calculateRetryDelay(err, attempt) {
66
+ const retryAfter = err.oauthRequest?.headers?.['retry-after'];
67
+
68
+ // Use Retry-After header if available, otherwise exponential backoff
69
+ let delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : RETRY_BASE_DELAY * Math.pow(2, attempt);
70
+
71
+ // Add jitter (0-500ms) to prevent synchronized retries
72
+ delay += Math.random() * 500;
73
+
74
+ return delay;
75
+ }
76
+
77
+ /**
78
+ * Makes authenticated requests to Gmail API with automatic retry on rate limiting
79
+ * Implements exponential backoff with jitter
80
+ *
81
+ * @param {Object} context - The client context (GmailClient instance)
82
+ * @param {string} url - API endpoint URL
83
+ * @param {string} [method='get'] - HTTP method
84
+ * @param {*} [payload] - Request payload
85
+ * @param {Object} [options={}] - Request options
86
+ * @returns {Promise<*>} API response
87
+ */
88
+ async function request(context, url, method, payload, options = {}) {
89
+ const maxRetries = options.maxRetries ?? MAX_RETRY_ATTEMPTS;
90
+ let lastError;
91
+
92
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
93
+ let result, accessToken;
94
+
95
+ try {
96
+ accessToken = await context.getToken();
97
+ } catch (err) {
98
+ context.logger.error({ msg: 'Failed to load access token', account: context.account, err });
99
+ throw err;
100
+ }
101
+
102
+ try {
103
+ if (!context.oAuth2Client) {
104
+ await context.getClient();
105
+ }
106
+
107
+ result = await context.oAuth2Client.request(accessToken, url, method, payload, options);
108
+
109
+ // Track successful API request
110
+ metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
111
+ status: 'success',
112
+ provider: 'gmail',
113
+ statusCode: '200'
114
+ });
115
+
116
+ return result;
117
+ } catch (err) {
118
+ lastError = err;
119
+
120
+ // Check if this is a rate limit error
121
+ if (isRateLimitError(err) && attempt < maxRetries) {
122
+ const delay = calculateRetryDelay(err, attempt);
123
+
124
+ context.logger.warn({
125
+ msg: 'Rate limited by Gmail API, retrying',
126
+ account: context.account,
127
+ attempt: attempt + 1,
128
+ maxRetries,
129
+ delay,
130
+ errorReason: err.oauthRequest?.response?.error?.errors?.[0]?.reason
131
+ });
132
+
133
+ metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
134
+ status: 'rate_limited',
135
+ provider: 'gmail',
136
+ statusCode: '429'
137
+ });
138
+
139
+ await new Promise(resolve => setTimeout(resolve, delay));
140
+ continue;
141
+ }
142
+
143
+ // Log client errors (4xx) at debug level - these are expected operational errors
144
+ // Log server errors (5xx) and other failures at error level
145
+ const status = err.oauthRequest?.status;
146
+ const isClientError = status >= 400 && status < 500;
147
+
148
+ if (isClientError) {
149
+ context.logger.debug({ msg: 'API request failed with client error', account: context.account, err });
150
+ } else {
151
+ context.logger.error({ msg: 'Failed to run API request', account: context.account, err });
152
+ }
153
+
154
+ // Track failed API request
155
+ const statusCode = String(err.oauthRequest?.status || 0);
156
+ metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
157
+ status: 'failure',
158
+ provider: 'gmail',
159
+ statusCode
160
+ });
161
+
162
+ throw err;
163
+ }
164
+ }
165
+
166
+ // If we exhausted all retries, throw the last error
167
+ throw lastError;
168
+ }
169
+
170
+ /**
171
+ * Builds a Gmail API URL for a specific endpoint
172
+ * @param {string} endpoint - The API endpoint path (e.g., '/users/me/messages')
173
+ * @returns {string} Full API URL
174
+ */
175
+ function buildApiUrl(endpoint) {
176
+ // Remove leading slash if present to avoid double slashes
177
+ const path = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
178
+ return `${GMAIL_API_BASE}/gmail/v1${path}`;
179
+ }
180
+
181
+ /**
182
+ * Executes batch API requests with concurrency control
183
+ *
184
+ * @param {Object} context - The client context (GmailClient instance)
185
+ * @param {Array<Object>} items - Array of items to process
186
+ * @param {Function} requestFn - Function that takes an item and returns a promise
187
+ * @param {number} [batchSize=LIST_BATCH_SIZE] - Maximum concurrent requests
188
+ * @returns {Promise<Array>} Array of results
189
+ */
190
+ async function executeBatchRequests(context, items, requestFn, batchSize = LIST_BATCH_SIZE) {
191
+ const results = [];
192
+ let batch = [];
193
+
194
+ const processBatch = async () => {
195
+ if (batch.length === 0) {
196
+ return;
197
+ }
198
+
199
+ const batchResults = await Promise.allSettled(batch);
200
+
201
+ for (const entry of batchResults) {
202
+ if (entry.status === 'rejected') {
203
+ throw entry.reason;
204
+ }
205
+ if (entry.value) {
206
+ results.push(entry.value);
207
+ }
208
+ }
209
+
210
+ batch = [];
211
+ };
212
+
213
+ for (const item of items) {
214
+ batch.push(requestFn(item));
215
+
216
+ if (batch.length >= batchSize) {
217
+ await processBatch();
218
+ }
219
+ }
220
+
221
+ await processBatch();
222
+
223
+ return results;
224
+ }
225
+
226
+ module.exports = {
227
+ // Constants
228
+ GMAIL_API_BASE,
229
+ LIST_BATCH_SIZE,
230
+ MAX_RETRY_ATTEMPTS,
231
+ RETRY_BASE_DELAY,
232
+ GMAIL_ERROR_MAP,
233
+
234
+ // Request functions
235
+ request,
236
+ buildApiUrl,
237
+ executeBatchRequests,
238
+
239
+ // Error handling
240
+ createGmailError,
241
+ isRateLimitError,
242
+ calculateRetryDelay
243
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { Account } = require('../account');
4
4
  const { oauth2Apps } = require('../oauth2-apps');
5
+ const { checkAccountScopes } = require('../oauth/scope-checker');
5
6
  const getSecret = require('../get-secret');
6
7
  const msgpack = require('msgpack5')();
7
8
  const addressparser = require('nodemailer/lib/addressparser');
@@ -22,9 +23,7 @@ const {
22
23
  AUTH_SUCCESS_NOTIFY
23
24
  } = require('../consts');
24
25
 
25
- // Gmail API configuration
26
- const GMAIL_API_BASE = 'https://gmail.googleapis.com';
27
- const LIST_BATCH_SIZE = 10; // how many listing requests to run at the same time
26
+ const { GMAIL_API_BASE, LIST_BATCH_SIZE, request: gmailApiRequest } = require('./gmail/gmail-api');
28
27
 
29
28
  // Labels to exclude from folder listings
30
29
  const SKIP_LABELS = ['UNREAD', 'STARRED', 'IMPORTANT', 'CHAT', 'CATEGORY_PERSONAL'];
@@ -61,6 +60,7 @@ for (let label of Object.keys(SYSTEM_LABELS)) {
61
60
  // Timing constants for Gmail Pub/Sub watch
62
61
  const RENEW_WATCH_TTL = 60 * 60 * 1000; // 1h - how often to check if watch needs renewal
63
62
  const MIN_WATCH_TTL = 24 * 3600 * 1000; // 1day - minimum time before renewing watch
63
+ const FALLBACK_POLLING_INTERVAL = 10 * 60 * 1000; // 10min - fallback polling if no Pub/Sub notifications
64
64
 
65
65
  /*
66
66
  Gmail API implementation status:
@@ -251,47 +251,15 @@ class GmailClient extends BaseClient {
251
251
 
252
252
  /**
253
253
  * Makes authenticated request to Gmail API
254
- * Handles token refresh automatically
255
- * @param {...any} args - Request parameters
254
+ * Handles token refresh and rate limit retries automatically
255
+ * @param {string} url - API endpoint URL
256
+ * @param {string} [method='get'] - HTTP method
257
+ * @param {*} [payload] - Request payload
258
+ * @param {Object} [options={}] - Request options
256
259
  * @returns {Object} API response
257
260
  */
258
- async request(...args) {
259
- let result, accessToken;
260
- try {
261
- accessToken = await this.getToken();
262
- } catch (err) {
263
- this.logger.error({ msg: 'Failed to load access token', account: this.account, err });
264
- throw err;
265
- }
266
-
267
- try {
268
- if (!this.oAuth2Client) {
269
- await this.getClient();
270
- }
271
- result = await this.oAuth2Client.request(accessToken, ...args);
272
-
273
- // Track successful API request
274
- metricsMeta({ account: this.account }, this.logger, 'oauth2ApiRequest', 'inc', { status: 'success', provider: 'gmail', statusCode: '200' });
275
- } catch (err) {
276
- // Log client errors (4xx) at debug level - these are expected operational errors
277
- // Log server errors (5xx) and other failures at error level
278
- const status = err.oauthRequest?.status;
279
- const isClientError = status >= 400 && status < 500;
280
-
281
- if (isClientError) {
282
- this.logger.debug({ msg: 'API request failed with client error', account: this.account, err });
283
- } else {
284
- this.logger.error({ msg: 'Failed to run API request', account: this.account, err });
285
- }
286
-
287
- // Track failed API request
288
- const statusCode = String(err.oauthRequest?.status || 0);
289
- metricsMeta({ account: this.account }, this.logger, 'oauth2ApiRequest', 'inc', { status: 'failure', provider: 'gmail', statusCode });
290
-
291
- throw err;
292
- }
293
-
294
- return result;
261
+ async request(url, method, payload, options) {
262
+ return gmailApiRequest(this, url, method, payload, options);
295
263
  }
296
264
 
297
265
  // PUBLIC METHODS
@@ -326,7 +294,7 @@ class GmailClient extends BaseClient {
326
294
 
327
295
  // Check if send-only mode
328
296
  const scopes = accountData.oauth2?.accessToken?.scope || accountData.oauth2?.scope || [];
329
- const { hasSendScope, hasReadScope } = this.accountObject.checkAccountScopes('gmail', scopes);
297
+ const { hasSendScope, hasReadScope } = checkAccountScopes('gmail', scopes);
330
298
  const isSendOnly = hasSendScope && !hasReadScope;
331
299
 
332
300
  this.logger.info({
@@ -453,6 +421,10 @@ class GmailClient extends BaseClient {
453
421
 
454
422
  // Schedule periodic watch renewal (only for full access accounts)
455
423
  this.setupRenewWatchTimer();
424
+
425
+ // Schedule fallback polling in case Pub/Sub notifications are dropped
426
+ this.lastNotificationTime = Date.now();
427
+ this.setupFallbackPollingTimer();
456
428
  }
457
429
 
458
430
  // Determine if this is a reconnection after error
@@ -501,8 +473,14 @@ class GmailClient extends BaseClient {
501
473
  */
502
474
  async close() {
503
475
  clearTimeout(this.renewWatchTimer);
476
+ clearTimeout(this.fallbackPollingTimer);
504
477
  this.closed = true;
505
478
 
479
+ // Clean up cached data
480
+ this.cachedLabels = null;
481
+ this.cachedLabelsTime = null;
482
+ this.pendingHistoryId = null;
483
+
506
484
  if (['init', 'connecting', 'syncing', 'connected'].includes(this.state)) {
507
485
  this.state = 'disconnected';
508
486
  await this.setStateVal();
@@ -518,8 +496,14 @@ class GmailClient extends BaseClient {
518
496
 
519
497
  async delete() {
520
498
  clearTimeout(this.renewWatchTimer);
499
+ clearTimeout(this.fallbackPollingTimer);
521
500
  this.closed = true;
522
501
 
502
+ // Clean up cached data
503
+ this.cachedLabels = null;
504
+ this.cachedLabelsTime = null;
505
+ this.pendingHistoryId = null;
506
+
523
507
  if (['init', 'connecting', 'syncing', 'connected'].includes(this.state)) {
524
508
  this.state = 'disconnected';
525
509
  await this.setStateVal();
@@ -1074,7 +1058,7 @@ class GmailClient extends BaseClient {
1074
1058
  }
1075
1059
 
1076
1060
  for (let flag of [].concat(updates.flags.delete || [])) {
1077
- labelUpdates.push(this.flagToLabel(flag), true);
1061
+ labelUpdates.push(this.flagToLabel(flag, true));
1078
1062
  }
1079
1063
 
1080
1064
  labelUpdates
@@ -1564,9 +1548,9 @@ class GmailClient extends BaseClient {
1564
1548
 
1565
1549
  let gmailMessageId;
1566
1550
  if (submitInfo?.id) {
1567
- // Detect send-only mode - use same scope resolution as in account.js
1551
+ // Detect send-only mode using centralized scope checker
1568
1552
  const scopes = accountData.oauth2?.accessToken?.scope || accountData.oauth2?.scope || [];
1569
- const { hasSendScope, hasReadScope } = this.accountObject.checkAccountScopes('gmail', scopes);
1553
+ const { hasSendScope, hasReadScope } = checkAccountScopes('gmail', scopes);
1570
1554
  const isSendOnly = hasSendScope && !hasReadScope;
1571
1555
 
1572
1556
  if (!isSendOnly) {
@@ -1797,6 +1781,9 @@ class GmailClient extends BaseClient {
1797
1781
  * @returns {boolean} Processing result
1798
1782
  */
1799
1783
  async externalNotify(message) {
1784
+ // Track notification time for fallback polling
1785
+ this.lastNotificationTime = Date.now();
1786
+
1800
1787
  let { historyId } = message || {};
1801
1788
 
1802
1789
  let existingHistoryId = Number(await this.redis.hget(this.getAccountKey(), 'googleHistoryId')) || null;
@@ -1882,28 +1869,93 @@ class GmailClient extends BaseClient {
1882
1869
 
1883
1870
  /**
1884
1871
  * Sets up timer to periodically renew Gmail watch subscription
1872
+ * Uses actual watch expiration if available for smarter scheduling
1885
1873
  */
1886
1874
  setupRenewWatchTimer() {
1887
1875
  if (this.closed) {
1888
1876
  return;
1889
1877
  }
1890
1878
  clearTimeout(this.renewWatchTimer);
1879
+
1880
+ // Calculate optimal delay based on watch expiration
1881
+ let delay = RENEW_WATCH_TTL;
1882
+ if (this.watchExpiration) {
1883
+ // Renew 1 hour before expiration
1884
+ const renewAt = this.watchExpiration - 60 * 60 * 1000;
1885
+ const timeUntilRenewal = renewAt - Date.now();
1886
+ // Use the calculated time, but not less than RENEW_WATCH_TTL
1887
+ delay = Math.max(timeUntilRenewal, RENEW_WATCH_TTL);
1888
+ }
1889
+
1891
1890
  this.renewWatchTimer = setTimeout(() => {
1892
1891
  if (this.closed) {
1893
1892
  return;
1894
1893
  }
1894
+ let authError = false;
1895
1895
  this.renewWatch()
1896
1896
  .catch(err => {
1897
1897
  this.logger.error({ msg: 'Failed to renew Gmail subscription watch', account: this.account, err });
1898
+ // Check if this is a permanent auth failure
1899
+ if (err.code === 'ETokenRefresh' || this.state === 'authenticationError') {
1900
+ authError = true;
1901
+ }
1898
1902
  })
1899
1903
  .finally(() => {
1900
- // restart timer
1901
- this.setupRenewWatchTimer();
1904
+ // Don't restart timer on auth failures - let the auth flow handle recovery
1905
+ if (!this.closed && !authError && this.state !== 'authenticationError') {
1906
+ this.setupRenewWatchTimer();
1907
+ }
1902
1908
  });
1903
- }, RENEW_WATCH_TTL);
1909
+ }, delay);
1904
1910
  this.renewWatchTimer.unref();
1905
1911
  }
1906
1912
 
1913
+ /**
1914
+ * Sets up fallback polling timer to check for missed notifications
1915
+ * If no Pub/Sub notifications received within the interval, triggers a proactive sync
1916
+ */
1917
+ setupFallbackPollingTimer() {
1918
+ if (this.closed) {
1919
+ return;
1920
+ }
1921
+ clearTimeout(this.fallbackPollingTimer);
1922
+ this.fallbackPollingTimer = setTimeout(async () => {
1923
+ if (this.closed) {
1924
+ return;
1925
+ }
1926
+
1927
+ const timeSinceNotification = Date.now() - (this.lastNotificationTime || 0);
1928
+ if (timeSinceNotification >= FALLBACK_POLLING_INTERVAL) {
1929
+ // No notifications received within the interval, do a proactive sync
1930
+ this.logger.info({
1931
+ msg: 'No Pub/Sub notifications received, triggering fallback sync',
1932
+ account: this.account,
1933
+ timeSinceNotification
1934
+ });
1935
+
1936
+ try {
1937
+ const historyId = await this.redis.hget(this.getAccountKey(), 'googleHistoryId');
1938
+ if (historyId) {
1939
+ // Trigger sync to check for any missed changes
1940
+ this.triggerSync(Number(historyId), Number(historyId));
1941
+ }
1942
+ } catch (err) {
1943
+ this.logger.error({
1944
+ msg: 'Failed to trigger fallback sync',
1945
+ account: this.account,
1946
+ err
1947
+ });
1948
+ }
1949
+ }
1950
+
1951
+ // Restart the timer
1952
+ if (!this.closed && this.state !== 'authenticationError') {
1953
+ this.setupFallbackPollingTimer();
1954
+ }
1955
+ }, FALLBACK_POLLING_INTERVAL);
1956
+ this.fallbackPollingTimer.unref();
1957
+ }
1958
+
1907
1959
  /**
1908
1960
  * Renews Gmail Pub/Sub watch subscription
1909
1961
  * @param {Object} accountData - Account data
@@ -1930,15 +1982,22 @@ class GmailClient extends BaseClient {
1930
1982
  topicName: appData?.pubSubTopic
1931
1983
  });
1932
1984
  // { historyId: '3663748', expiration: '1720183655953' }
1985
+
1986
+ // Store expiration for smarter renewal scheduling
1987
+ const watchExpiration = watchResponse?.expiration ? Number(watchResponse.expiration) : null;
1988
+ this.watchExpiration = watchExpiration;
1989
+
1933
1990
  await this.accountObject.update({
1934
1991
  lastWatch: new Date(now),
1935
1992
  watchResponse,
1993
+ watchExpiration,
1936
1994
  watchFailure: null
1937
1995
  });
1938
1996
  this.logger.info({
1939
1997
  msg: 'Renewed Gmail pubsub watch',
1940
1998
  account: this.account,
1941
- watchResponse
1999
+ watchResponse,
2000
+ watchExpiration: watchExpiration ? new Date(watchExpiration).toISOString() : null
1942
2001
  });
1943
2002
  } catch (err) {
1944
2003
  await this.accountObject.update({
@@ -2486,15 +2545,37 @@ class GmailClient extends BaseClient {
2486
2545
  */
2487
2546
  triggerSync(currentHistoryId, updatedHistoryId) {
2488
2547
  if (this.processingHistory) {
2548
+ // Queue the latest historyId instead of dropping the notification
2549
+ this.pendingHistoryId = Math.max(this.pendingHistoryId || 0, updatedHistoryId);
2550
+ this.logger.debug({
2551
+ msg: 'Sync already in progress, queued pending historyId',
2552
+ account: this.account,
2553
+ pendingHistoryId: this.pendingHistoryId
2554
+ });
2489
2555
  return;
2490
2556
  }
2491
2557
  this.processingHistory = true;
2558
+ const processedHistoryId = updatedHistoryId;
2492
2559
  this.processHistory(currentHistoryId, updatedHistoryId)
2493
2560
  .catch(err => {
2494
2561
  this.logger.error({ msg: 'Failed to process account history', currentHistoryId, updatedHistoryId, account: this.account, err });
2495
2562
  })
2496
2563
  .finally(() => {
2497
2564
  this.processingHistory = false;
2565
+ // Process any queued sync notifications
2566
+ if (this.pendingHistoryId && this.pendingHistoryId > processedHistoryId) {
2567
+ const pending = this.pendingHistoryId;
2568
+ this.pendingHistoryId = null;
2569
+ this.logger.debug({
2570
+ msg: 'Processing queued historyId',
2571
+ account: this.account,
2572
+ fromHistoryId: processedHistoryId,
2573
+ toHistoryId: pending
2574
+ });
2575
+ this.triggerSync(processedHistoryId, pending);
2576
+ } else {
2577
+ this.pendingHistoryId = null;
2578
+ }
2498
2579
  });
2499
2580
  }
2500
2581
 
@@ -2668,9 +2749,20 @@ class GmailClient extends BaseClient {
2668
2749
  } catch (err) {
2669
2750
  switch (err?.oauthRequest?.response?.error?.code) {
2670
2751
  case 404: {
2671
- // History ID too old
2672
- this.logger.info({ msg: 'Provided history ID is too old', account: this.account, historyId: currentHistoryId, updatedHistoryId, err });
2673
- // set to newest known value, ignore missed entries
2752
+ // History ID too old - some changes may have been missed
2753
+ this.logger.warn({
2754
+ msg: 'History ID too old, some email changes may have been missed',
2755
+ account: this.account,
2756
+ currentHistoryId,
2757
+ updatedHistoryId,
2758
+ err
2759
+ });
2760
+ // Emit warning event so the account can be flagged for attention
2761
+ await emitChangeEvent(this.logger, this.account, 'syncWarning', {
2762
+ type: 'historyIdExpired',
2763
+ message: 'Some email changes may have been missed due to expired history ID'
2764
+ });
2765
+ // Set to newest known value
2674
2766
  newestHistoryId = updatedHistoryId;
2675
2767
  return;
2676
2768
  }