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.
- package/CHANGELOG.md +16 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account/account-state.js +248 -0
- package/lib/account.js +17 -178
- package/lib/api-routes/account-routes.js +1006 -0
- package/lib/api-routes/message-routes.js +1377 -0
- package/lib/consts.js +12 -2
- package/lib/email-client/base-client.js +282 -771
- package/lib/email-client/gmail/gmail-api.js +243 -0
- package/lib/email-client/gmail-client.js +145 -53
- package/lib/email-client/imap/mailbox.js +24 -698
- package/lib/email-client/imap/sync-operations.js +812 -0
- package/lib/email-client/imap-client.js +3 -1
- package/lib/email-client/message-builder.js +566 -0
- package/lib/email-client/notification-handler.js +314 -0
- package/lib/email-client/outlook/graph-api.js +326 -0
- package/lib/email-client/outlook-client.js +159 -113
- package/lib/email-client/smtp-pool-manager.js +196 -0
- package/lib/imapproxy/imap-server.js +3 -12
- package/lib/oauth/gmail.js +4 -4
- package/lib/oauth/mail-ru.js +30 -5
- package/lib/oauth/outlook.js +57 -3
- package/lib/oauth/pubsub/google.js +30 -11
- package/lib/oauth/scope-checker.js +202 -0
- package/lib/oauth2-apps.js +8 -4
- package/lib/redis-operations.js +484 -0
- package/lib/routes-ui.js +283 -2582
- package/lib/tools.js +5 -196
- package/lib/ui-routes/account-routes.js +1931 -0
- package/lib/ui-routes/admin-config-routes.js +1233 -0
- package/lib/ui-routes/admin-entities-routes.js +2367 -0
- package/lib/ui-routes/oauth-routes.js +992 -0
- package/lib/utils/network.js +237 -0
- package/package.json +12 -12
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +91 -21
- package/translations/de.mo +0 -0
- package/translations/de.po +85 -82
- package/translations/en.mo +0 -0
- package/translations/en.po +63 -71
- package/translations/et.mo +0 -0
- package/translations/et.po +84 -82
- package/translations/fr.mo +0 -0
- package/translations/fr.po +85 -82
- package/translations/ja.mo +0 -0
- package/translations/ja.po +84 -82
- package/translations/messages.pot +67 -80
- package/translations/nl.mo +0 -0
- package/translations/nl.po +86 -82
- package/translations/pl.mo +0 -0
- package/translations/pl.po +84 -82
- package/views/account/security.hbs +4 -4
- package/views/accounts/account.hbs +13 -13
- package/views/accounts/register/imap-server.hbs +12 -12
- package/views/config/document-store/pre-processing/index.hbs +4 -2
- package/views/config/oauth/app.hbs +6 -7
- package/views/config/oauth/index.hbs +2 -2
- package/views/config/service.hbs +3 -4
- package/views/dashboard.hbs +5 -7
- package/views/error.hbs +22 -7
- package/views/gateways/gateway.hbs +2 -2
- package/views/partials/add_account_modal.hbs +7 -10
- package/views/partials/document_store_header.hbs +1 -1
- package/views/partials/editor_scope_info.hbs +0 -1
- package/views/partials/oauth_config_header.hbs +1 -1
- package/views/partials/side_menu.hbs +3 -3
- package/views/partials/webhook_form.hbs +2 -2
- package/views/templates/index.hbs +1 -1
- package/views/templates/template.hbs +8 -8
- package/views/tokens/index.hbs +6 -6
- package/views/tokens/new.hbs +1 -1
- package/views/webhooks/index.hbs +4 -4
- package/views/webhooks/webhook.hbs +7 -7
- package/workers/api.js +148 -2436
- package/workers/smtp.js +2 -1
- package/workers/webhooks.js +6 -0
- package/lib/imapproxy/imap-core/test/client.js +0 -46
- package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
- package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
- package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
- package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
- package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
- package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
- package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
- package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
- package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
- package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
- package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
- package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
- package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
- package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
- package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
- package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
- package/lib/imapproxy/imap-core/test/test-client.js +0 -152
- package/lib/imapproxy/imap-core/test/test-server.js +0 -623
- package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
- package/test/api-test.js +0 -899
- package/test/autoreply-test.js +0 -327
- package/test/bounce-test.js +0 -151
- package/test/complaint-test.js +0 -256
- package/test/fixtures/autoreply/LICENSE +0 -27
- package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
- package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
- package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
- package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
- package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
- package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
- package/test/fixtures/bounces/163.eml +0 -2521
- package/test/fixtures/bounces/fastmail.eml +0 -242
- package/test/fixtures/bounces/gmail.eml +0 -252
- package/test/fixtures/bounces/hotmail.eml +0 -655
- package/test/fixtures/bounces/mailru.eml +0 -121
- package/test/fixtures/bounces/outlook.eml +0 -1107
- package/test/fixtures/bounces/postfix.eml +0 -101
- package/test/fixtures/bounces/rambler.eml +0 -116
- package/test/fixtures/bounces/workmail.eml +0 -142
- package/test/fixtures/bounces/yahoo.eml +0 -139
- package/test/fixtures/bounces/zoho.eml +0 -83
- package/test/fixtures/bounces/zonemta.eml +0 -100
- package/test/fixtures/complaints/LICENSE +0 -27
- package/test/fixtures/complaints/amazonses.eml +0 -72
- package/test/fixtures/complaints/dmarc.eml +0 -59
- package/test/fixtures/complaints/hotmail.eml +0 -49
- package/test/fixtures/complaints/optout.eml +0 -40
- package/test/fixtures/complaints/standard-arf.eml +0 -68
- package/test/fixtures/complaints/yahoo.eml +0 -68
- package/test/oauth2-apps-test.js +0 -301
- package/test/sendonly-test.js +0 -160
- package/test/test-config.js +0 -34
- 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
|
-
|
|
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 {
|
|
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(
|
|
259
|
-
|
|
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 } =
|
|
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
|
|
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
|
|
1551
|
+
// Detect send-only mode using centralized scope checker
|
|
1568
1552
|
const scopes = accountData.oauth2?.accessToken?.scope || accountData.oauth2?.scope || [];
|
|
1569
|
-
const { hasSendScope, hasReadScope } =
|
|
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.
|
|
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
|
-
},
|
|
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.
|
|
2673
|
-
|
|
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
|
}
|