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
@@ -48,6 +48,7 @@ async function call(message, transferList) {
48
48
  err.statusCode = 504;
49
49
  err.code = 'Timeout';
50
50
  err.ttl = ttl;
51
+ callQueue.delete(mid);
51
52
  reject(err);
52
53
  }, ttl);
53
54
 
@@ -481,7 +481,8 @@ class GmailOauth {
481
481
  Authorization: `Bearer ${accessToken}`,
482
482
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
483
483
  },
484
- dispatcher: httpAgent.retry
484
+ dispatcher: httpAgent.retry,
485
+ signal: options.signal || undefined
485
486
  };
486
487
 
487
488
  if (payload && method !== 'get') {
@@ -535,6 +536,16 @@ class GmailOauth {
535
536
  reqTime
536
537
  };
537
538
 
539
+ // Capture Retry-After header for rate limiting (429) responses
540
+ if (res.status === 429) {
541
+ let retryAfterHeader = res.headers.get('retry-after');
542
+ if (retryAfterHeader) {
543
+ let parsed = parseInt(retryAfterHeader, 10);
544
+ err.retryAfter = isNaN(parsed) ? null : parsed;
545
+ err.oauthRequest.retryAfter = err.retryAfter;
546
+ }
547
+ }
548
+
538
549
  try {
539
550
  err.oauthRequest.response = await res.json();
540
551
 
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { redis } = require('../../db');
4
- const { REDIS_PREFIX } = require('../../consts');
4
+ const { REDIS_PREFIX, TRANSIENT_NETWORK_CODES } = require('../../consts');
5
5
  const logger = require('../../logger').child({
6
6
  component: 'google-subscriber'
7
7
  });
@@ -16,12 +16,28 @@ class PubSubInstance {
16
16
  this.app = opts.app;
17
17
 
18
18
  this.stopped = false;
19
+ this._loopTimer = null;
20
+ this._immediateHandle = null;
21
+ this._abortController = null;
22
+ // Initialize to true so the first successful pull clears any stale
23
+ // pubSubFlag left in Redis by a previously crashed process
24
+ this._hadPubSubFlag = true;
25
+ // Track whether we have already reported an error this session
26
+ // (separate from _hadPubSubFlag which tracks stale flag clearing)
27
+ this._pubSubFlagSetThisSession = false;
28
+ this._lastLoopError = null;
29
+ this._consecutiveErrors = 0;
30
+ this._recoveryAttempts = 0;
19
31
 
20
32
  this.checkSchemaVersions()
21
33
  .catch(err => {
22
- logger.error({ msg: 'Failed to process shcema versions', err });
34
+ logger.error({ msg: 'Failed to process schema versions', err });
23
35
  })
24
- .finally(() => this.startLoop());
36
+ .finally(() => {
37
+ if (!this.stopped) {
38
+ this.startLoop();
39
+ }
40
+ });
25
41
  }
26
42
 
27
43
  getPubsubAppKey() {
@@ -33,26 +49,46 @@ class PubSubInstance {
33
49
  return;
34
50
  }
35
51
  this.run()
36
- .then(() => {
37
- this.startLoop();
52
+ .then(messageCount => {
53
+ if (this.stopped) {
54
+ return;
55
+ }
56
+ this._lastLoopError = null;
57
+ this._consecutiveErrors = 0;
58
+ this._recoveryAttempts = 0;
59
+ if (messageCount > 0) {
60
+ this._immediateHandle = setImmediate(() => this.startLoop());
61
+ } else {
62
+ // Add a small delay when no messages were received to prevent
63
+ // tight loops if Google returns empty responses immediately
64
+ this._loopTimer = setTimeout(() => this.startLoop(), 1000);
65
+ }
38
66
  })
39
67
  .catch(err => {
40
- logger.error({ msg: 'Failed to process subscription loop', app: this.app, err });
68
+ if (this.stopped || err.name === 'AbortError') {
69
+ return;
70
+ }
71
+ let errKey = [err.message, err.code, err.statusCode].filter(val => val).join('|');
72
+ if (this._lastLoopError !== errKey) {
73
+ logger.error({ msg: 'Failed to process subscription loop', app: this.app, err });
74
+ this._lastLoopError = errKey;
41
75
 
42
- oauth2Apps.setMeta(this.app, {
43
- pubSubFlag: {
44
- message: `Failed to process subscription loop`,
76
+ this._setPubSubFlag({
77
+ message: 'Failed to process subscription loop',
45
78
  description: [err.message, err.reason, err.code].filter(val => val).join('; ')
46
- }
47
- });
48
- setTimeout(() => this.startLoop(), 3000);
79
+ }).catch(() => {});
80
+ }
81
+ this._consecutiveErrors++;
82
+ let retryDelay = err.retryDelay || this._backoffMs(this._consecutiveErrors);
83
+ this._loopTimer = setTimeout(() => this.startLoop(), retryDelay);
49
84
  });
50
85
  }
51
86
 
52
87
  async checkSchemaVersions() {
53
88
  let subscriberApps = await redis.smembers(this.getPubsubAppKey());
54
89
  let currentSchemaId = 3;
55
- for (let subscriberApp of subscriberApps || []) {
90
+ for (let subscriberApp of subscriberApps) {
91
+ if (this.stopped) return;
56
92
  let schemaVersion = Number(await redis.hget(`${REDIS_PREFIX}oapp:h:${subscriberApp}`, '__schemaVersion')) || 0;
57
93
  if (schemaVersion < currentSchemaId) {
58
94
  // migrate
@@ -73,7 +109,7 @@ class PubSubInstance {
73
109
  }
74
110
 
75
111
  async processPulledMessage(messageId, data) {
76
- logger.info({ msg: 'Processing subscription message', source: 'google', app: this.app, messageId, data });
112
+ logger.debug({ msg: 'Processing subscription message', source: 'google', app: this.app, messageId, data });
77
113
 
78
114
  let payload;
79
115
  try {
@@ -83,14 +119,23 @@ class PubSubInstance {
83
119
  return;
84
120
  }
85
121
 
86
- if (!payload || !payload.emailAddress || !payload.historyId) {
122
+ if (!payload?.emailAddress || !payload?.historyId) {
123
+ logger.warn({
124
+ msg: 'Ignoring Pub/Sub message with missing required fields',
125
+ source: 'google',
126
+ app: this.app,
127
+ messageId,
128
+ hasPayload: !!payload,
129
+ hasEmailAddress: !!payload?.emailAddress,
130
+ hasHistoryId: !!payload?.historyId
131
+ });
87
132
  return;
88
133
  }
89
134
 
90
135
  let subscriberApps = await redis.smembers(this.getPubsubAppKey());
91
136
  let accountIds = new Set();
92
- for (let subscriberApp of subscriberApps || []) {
93
- let accountId = await redis.hget(`${REDIS_PREFIX}oapp:h:${subscriberApp}`, payload.emailAddress?.toLowerCase());
137
+ for (let subscriberApp of subscriberApps) {
138
+ let accountId = await redis.hget(`${REDIS_PREFIX}oapp:h:${subscriberApp}`, payload.emailAddress.toLowerCase());
94
139
  if (accountId) {
95
140
  accountIds.add(accountId);
96
141
  }
@@ -101,11 +146,7 @@ class PubSubInstance {
101
146
  return;
102
147
  }
103
148
 
104
- try {
105
- await this.parent.call({ cmd: 'externalNotify', accounts: Array.from(accountIds), historyId: Number(payload.historyId) || null });
106
- } catch (err) {
107
- logger.error({ msg: 'Failed to notify about changes', app: this.app, messageId, emailAddress: payload.emailAddress, err });
108
- }
149
+ await this.parent.call({ cmd: 'externalNotify', accounts: [...accountIds], historyId: Number(payload.historyId) || null });
109
150
  }
110
151
 
111
152
  async run() {
@@ -118,6 +159,17 @@ class PubSubInstance {
118
159
  return;
119
160
  }
120
161
 
162
+ // Check if subscription needs initial setup (use cached data first)
163
+ await this.getApp();
164
+ if (!this.appData.pubSubSubscription) {
165
+ // Force refresh to confirm subscription is really missing
166
+ await this.getApp(true);
167
+ if (!this.appData.pubSubSubscription) {
168
+ await this.attemptRecovery('Subscription not configured');
169
+ return; // re-enter pull loop via startLoop
170
+ }
171
+ }
172
+
121
173
  let accessToken = await this.getAccessToken();
122
174
  if (!accessToken) {
123
175
  logger.error({ msg: 'Failed to retrieve access token', app: this.app });
@@ -128,15 +180,28 @@ class PubSubInstance {
128
180
  let acknowledgeUrl = `https://pubsub.googleapis.com/v1/${this.appData.pubSubSubscription}:acknowledge`;
129
181
 
130
182
  try {
131
- let start = Date.now();
132
-
133
- let pullRes = await this.client.request(accessToken, pullUrl, 'POST', { returnImmediately: false, maxMessages: 100 });
183
+ let pullStartTime = Date.now();
184
+
185
+ this._abortController = new AbortController();
186
+ let pullTimeoutId = setTimeout(() => this._abortController.abort(), 5 * 60 * 1000);
187
+ let pullRes;
188
+ try {
189
+ pullRes = await this.client.request(
190
+ accessToken,
191
+ pullUrl,
192
+ 'POST',
193
+ { returnImmediately: false, maxMessages: 100 },
194
+ { signal: this._abortController.signal }
195
+ );
196
+ } finally {
197
+ clearTimeout(pullTimeoutId);
198
+ this._abortController = null;
199
+ }
134
200
  if (this.stopped) {
135
- // ignore if stopped
136
201
  return;
137
202
  }
138
203
 
139
- let reqTime = Date.now() - start;
204
+ let reqTime = Date.now() - pullStartTime;
140
205
 
141
206
  logger.debug({
142
207
  msg: 'Pulled subscription messages',
@@ -146,6 +211,9 @@ class PubSubInstance {
146
211
  reqTime
147
212
  });
148
213
 
214
+ // Collect ackIds for batch acknowledgement
215
+ let ackIds = [];
216
+
149
217
  for (let receivedMessage of pullRes?.receivedMessages || []) {
150
218
  // Check stopped flag at start of each message for faster shutdown
151
219
  if (this.stopped) {
@@ -153,76 +221,143 @@ class PubSubInstance {
153
221
  return;
154
222
  }
155
223
 
156
- let processingSuccess = false;
224
+ let messageId = receivedMessage?.message?.messageId;
225
+
157
226
  try {
158
- await this.processPulledMessage(
159
- receivedMessage?.message?.messageId,
160
- Buffer.from(receivedMessage?.message?.data || '', 'base64').toString()
161
- );
162
- processingSuccess = true;
227
+ await this.processPulledMessage(messageId, Buffer.from(receivedMessage?.message?.data || '', 'base64').toString());
163
228
  } catch (err) {
164
- // Processing failed - don't ACK so message will be redelivered
165
- logger.error({
166
- msg: 'Failed to process subscription message',
167
- app: this.app,
168
- messageId: receivedMessage?.message?.messageId,
169
- err
170
- });
229
+ // Processing failed - skip ACK so message will be redelivered
230
+ logger.error({ msg: 'Failed to process subscription message', app: this.app, messageId, err });
231
+ continue;
232
+ }
233
+
234
+ if (receivedMessage?.ackId) {
235
+ ackIds.push(receivedMessage.ackId);
171
236
  }
237
+ }
172
238
 
173
- // Only ACK after successful processing
174
- if (processingSuccess) {
239
+ // Batch ACK all successfully processed messages
240
+ if (ackIds.length > 0) {
241
+ // Refresh access token if the pull took a long time (token may be near expiration)
242
+ let elapsed = Date.now() - pullStartTime;
243
+ if (elapsed > 4 * 60 * 1000) {
175
244
  try {
176
245
  accessToken = await this.getAccessToken();
177
- if (!accessToken) {
178
- logger.error({
179
- msg: 'Failed to ack subscription message. No access token',
180
- app: this.app,
181
- messageId: receivedMessage?.message?.messageId
182
- });
183
- } else {
184
- await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds: [receivedMessage?.ackId] }, { returnText: true });
185
- logger.debug({
186
- msg: 'Acked subscription message',
187
- app: this.app,
188
- messageId: receivedMessage?.message?.messageId
189
- });
190
- }
191
246
  } catch (err) {
192
- // failed to ack
193
- logger.error({
194
- msg: 'Failed to ack subscription message',
195
- app: this.app,
196
- messageId: receivedMessage?.message?.messageId,
197
- err
198
- });
247
+ logger.warn({ msg: 'Failed to refresh access token before ACK, using original', app: this.app, err });
248
+ }
249
+ }
250
+
251
+ try {
252
+ await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds }, { returnText: true });
253
+ logger.debug({ msg: 'Batch acked subscription messages', app: this.app, count: ackIds.length });
254
+ } catch (err) {
255
+ logger.warn({ msg: 'Batch ACK failed, retrying once', app: this.app, count: ackIds.length, err });
256
+ try {
257
+ await new Promise(resolve => setTimeout(resolve, 1000));
258
+ await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds }, { returnText: true });
259
+ logger.info({ msg: 'Batch ACK retry succeeded', app: this.app, count: ackIds.length });
260
+ } catch (retryErr) {
261
+ logger.error({ msg: 'Batch ACK retry also failed, messages will be redelivered', app: this.app, count: ackIds.length, err: retryErr });
199
262
  }
200
263
  }
201
264
  }
202
265
 
203
- await oauth2Apps.setMeta(this.app, { pubSubFlag: null });
266
+ if (this._hadPubSubFlag) {
267
+ try {
268
+ await oauth2Apps.setMeta(this.app, { pubSubFlag: null });
269
+ this._hadPubSubFlag = false;
270
+ } catch (metaErr) {
271
+ logger.error({ msg: 'Failed to clear pubSubFlag after successful pull', app: this.app, err: metaErr });
272
+ }
273
+ }
274
+
275
+ return ackIds.length;
204
276
  } catch (err) {
277
+ // Manual timeout abort or shutdown - not an error, just restart the loop
278
+ if (err.name === 'AbortError') {
279
+ throw err;
280
+ }
205
281
  // Transient network errors are expected for long-polling connections
206
- if (
207
- [
208
- 'ENOTFOUND',
209
- 'EAI_AGAIN',
210
- 'ETIMEDOUT',
211
- 'ECONNRESET',
212
- 'ECONNREFUSED',
213
- 'UND_ERR_SOCKET',
214
- 'UND_ERR_CONNECT_TIMEOUT',
215
- 'UND_ERR_HEADERS_TIMEOUT'
216
- ].includes(err.code)
217
- ) {
282
+ if (TRANSIENT_NETWORK_CODES.has(err.code)) {
218
283
  logger.warn({ msg: 'Transient error pulling subscription messages', app: this.app, code: err.code });
219
- return;
284
+ err.retryDelay = 5000;
285
+ throw err;
286
+ }
287
+ // Rate limited by Google API - respect Retry-After header
288
+ if (err.statusCode === 429) {
289
+ let retryDelay = (err.retryAfter ? err.retryAfter * 1000 : null) || 30000;
290
+ logger.warn({ msg: 'Rate limited by Google Pub/Sub API', app: this.app, retryAfterSec: err.retryAfter });
291
+ err.retryDelay = retryDelay;
292
+ throw err;
293
+ }
294
+ // Detect deleted subscription (expired after 31 days of inactivity) and try to recreate
295
+ if (err.statusCode === 404 || err?.oauthRequest?.response?.error?.code === 404) {
296
+ await this.attemptRecovery('Subscription not found (404)');
297
+ return; // re-enter the pull loop
298
+ }
299
+
300
+ // Authentication or authorization failure -- set operator-visible flag and let startLoop retry with backoff.
301
+ // 401: token refresh happens automatically via getServiceAccessToken() on next run().
302
+ // 403: permanent until operator fixes IAM permissions; retrying ensurePubsub wastes API calls.
303
+ if (err.statusCode === 401 || err.statusCode === 403) {
304
+ logger.error({
305
+ msg: 'Authentication/authorization error pulling subscription messages',
306
+ app: this.app,
307
+ statusCode: err.statusCode,
308
+ err
309
+ });
310
+ if (!this._pubSubFlagSetThisSession) {
311
+ await this._setPubSubFlag({
312
+ message:
313
+ err.statusCode === 401
314
+ ? 'Service account access token expired or revoked'
315
+ : 'Insufficient permissions to pull from the subscription',
316
+ description: [err.message, err.reason, err.code].filter(val => val).join('; ')
317
+ });
318
+ }
319
+ throw err;
220
320
  }
321
+
221
322
  logger.error({ msg: 'Failed to pull subscription messages', app: this.app, err });
222
323
  throw err;
223
324
  }
224
325
  }
225
326
 
327
+ async attemptRecovery(reason) {
328
+ this._recoveryAttempts++;
329
+ if (this._recoveryAttempts > 5) {
330
+ throw new Error(`Recovery attempted ${this._recoveryAttempts} times without a successful pull; backing off`);
331
+ }
332
+
333
+ logger.info({ msg: 'Attempting subscription recovery', app: this.app, reason, attempt: this._recoveryAttempts });
334
+
335
+ try {
336
+ if (this.stopped) return;
337
+ await this.getApp(true);
338
+ if (this.stopped) return;
339
+ await oauth2Apps.ensurePubsub(this.appData);
340
+ if (this.stopped) return;
341
+
342
+ // Verify recovery actually created a subscription
343
+ await this.getApp(true);
344
+ if (!this.appData.pubSubSubscription) {
345
+ throw new Error('Pub/Sub setup completed but subscription is still missing');
346
+ }
347
+
348
+ if (this.stopped) return;
349
+ await this.getClient(true);
350
+ if (this.stopped) return;
351
+ await oauth2Apps.setMeta(this.app, { pubSubFlag: null });
352
+ this._hadPubSubFlag = false;
353
+ this._pubSubFlagSetThisSession = false;
354
+ logger.info({ msg: 'Successfully recovered Pub/Sub subscription', app: this.app, reason });
355
+ } catch (recoveryErr) {
356
+ logger.warn({ msg: 'Subscription recovery failed', app: this.app, reason, err: recoveryErr });
357
+ throw recoveryErr;
358
+ }
359
+ }
360
+
226
361
  async getClient(force) {
227
362
  if (this.client && !force) {
228
363
  return this.client;
@@ -236,7 +371,14 @@ class PubSubInstance {
236
371
  if (this.appData && !force) {
237
372
  return this.appData;
238
373
  }
239
- this.appData = await oauth2Apps.get(this.app);
374
+ let result = await oauth2Apps.get(this.app);
375
+ if (!result) {
376
+ logger.info({ msg: 'App data not found in getApp, removing subscription instance', app: this.app });
377
+ this.stopped = true;
378
+ this.parent.remove(this.app);
379
+ throw new Error('App no longer exists');
380
+ }
381
+ this.appData = result;
240
382
  return this.appData;
241
383
  }
242
384
 
@@ -244,6 +386,19 @@ class PubSubInstance {
244
386
  await this.getClient();
245
387
  return await oauth2Apps.getServiceAccessToken(this.appData, this.client);
246
388
  }
389
+
390
+ _setPubSubFlag(flag) {
391
+ this._hadPubSubFlag = true;
392
+ this._pubSubFlagSetThisSession = true;
393
+ return oauth2Apps.setMeta(this.app, { pubSubFlag: flag }).catch(metaErr => {
394
+ logger.error({ msg: 'Failed to update pubSubFlag', app: this.app, err: metaErr });
395
+ });
396
+ }
397
+
398
+ _backoffMs(attempts) {
399
+ let base = Math.min(3000 * Math.pow(2, Math.min(attempts, 20)), 5 * 60 * 1000);
400
+ return Math.floor(base * (0.5 + Math.random() * 0.5));
401
+ }
247
402
  }
248
403
 
249
404
  class GooglePubSub {
@@ -258,25 +413,38 @@ class GooglePubSub {
258
413
  }
259
414
 
260
415
  async start() {
261
- let apps = await redis.smembers(this.getSubscribersKey());
416
+ // Backfill apps with baseScopes === 'pubsub' that are missing from the subscribers set
417
+ let apps = await oauth2Apps.backfillPubSubApps();
262
418
  for (let app of apps) {
263
- this.pubSubInstances.set(app, new PubSubInstance(this, { app }));
419
+ await this.update(app);
264
420
  }
265
421
  }
266
422
 
267
423
  async update(app) {
268
- if (!this.pubSubInstances.has(app)) {
269
- this.pubSubInstances.set(app, new PubSubInstance(this, { app }));
424
+ if (this.pubSubInstances.has(app)) {
425
+ this.remove(app);
270
426
  }
427
+ this.pubSubInstances.set(app, new PubSubInstance(this, { app }));
271
428
  }
272
429
 
273
- async remove(app) {
430
+ remove(app) {
274
431
  if (this.pubSubInstances.has(app)) {
275
432
  const instance = this.pubSubInstances.get(app);
276
- instance.stopped = true; // Stop the loop before removing
433
+ instance.stopped = true;
434
+ clearTimeout(instance._loopTimer);
435
+ clearImmediate(instance._immediateHandle);
436
+ if (instance._abortController) {
437
+ instance._abortController.abort();
438
+ }
277
439
  this.pubSubInstances.delete(app);
278
440
  }
279
441
  }
442
+
443
+ stopAll() {
444
+ for (let app of [...this.pubSubInstances.keys()]) {
445
+ this.remove(app);
446
+ }
447
+ }
280
448
  }
281
449
 
282
- module.exports = { GooglePubSub };
450
+ module.exports = { GooglePubSub, PubSubInstance };