emailengine-app 2.61.1 → 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 (136) hide show
  1. package/CHANGELOG.md +9 -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 +1 -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 +4 -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 +9 -9
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +78 -18
  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 +74 -87
  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/lib/imapproxy/imap-core/test/client.js +0 -46
  78. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  79. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  80. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  81. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  82. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  83. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  84. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  88. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  89. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  90. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  92. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  93. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  94. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  95. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  96. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  97. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  98. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  99. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  100. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  101. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  102. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  103. package/test/api-test.js +0 -899
  104. package/test/autoreply-test.js +0 -327
  105. package/test/bounce-test.js +0 -151
  106. package/test/complaint-test.js +0 -256
  107. package/test/fixtures/autoreply/LICENSE +0 -27
  108. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  109. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  110. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  111. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  112. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  113. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  114. package/test/fixtures/bounces/163.eml +0 -2521
  115. package/test/fixtures/bounces/fastmail.eml +0 -242
  116. package/test/fixtures/bounces/gmail.eml +0 -252
  117. package/test/fixtures/bounces/hotmail.eml +0 -655
  118. package/test/fixtures/bounces/mailru.eml +0 -121
  119. package/test/fixtures/bounces/outlook.eml +0 -1107
  120. package/test/fixtures/bounces/postfix.eml +0 -101
  121. package/test/fixtures/bounces/rambler.eml +0 -116
  122. package/test/fixtures/bounces/workmail.eml +0 -142
  123. package/test/fixtures/bounces/yahoo.eml +0 -139
  124. package/test/fixtures/bounces/zoho.eml +0 -83
  125. package/test/fixtures/bounces/zonemta.eml +0 -100
  126. package/test/fixtures/complaints/LICENSE +0 -27
  127. package/test/fixtures/complaints/amazonses.eml +0 -72
  128. package/test/fixtures/complaints/dmarc.eml +0 -59
  129. package/test/fixtures/complaints/hotmail.eml +0 -49
  130. package/test/fixtures/complaints/optout.eml +0 -40
  131. package/test/fixtures/complaints/standard-arf.eml +0 -68
  132. package/test/fixtures/complaints/yahoo.eml +0 -68
  133. package/test/oauth2-apps-test.js +0 -301
  134. package/test/sendonly-test.js +0 -160
  135. package/test/test-config.js +0 -34
  136. package/test/webhooks-server.js +0 -39
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const nodemailer = require('nodemailer');
5
+ const socks = require('socks');
6
+ const logger = require('../logger');
7
+
8
+ // Cache for SMTP connection pools to reuse connections
9
+ const SMTP_POOLS = new Map();
10
+ // Track last usage time for each pool to enable LRU-based cleanup
11
+ const SMTP_POOL_LAST_USED = new Map();
12
+
13
+ // Maximum idle time for SMTP pool connections (10 minutes of inactivity)
14
+ const SMTP_POOL_MAX_IDLE = 10 * 60 * 1000;
15
+ // Cleanup interval for idle SMTP pools (2 minutes)
16
+ const SMTP_POOL_CLEANUP_INTERVAL = 2 * 60 * 1000;
17
+
18
+ // Keys that affect SMTP connection identity
19
+ const CONNECTION_IDENTITY_KEYS = ['name', 'localAddress', 'auth', 'host', 'port', 'secure', 'transactionLog', 'proxy'];
20
+
21
+ /**
22
+ * Generates a unique pool key from SMTP settings
23
+ * @param {Object} smtpSettings - SMTP configuration settings
24
+ * @returns {string} SHA256 hash of connection-relevant settings
25
+ */
26
+ function generatePoolKey(smtpSettings) {
27
+ const limitedSettings = {};
28
+ for (const key of CONNECTION_IDENTITY_KEYS) {
29
+ limitedSettings[key] = smtpSettings[key];
30
+ }
31
+ const serializedSettings = JSON.stringify(limitedSettings);
32
+ return crypto.createHash('sha256').update(serializedSettings).digest('hex');
33
+ }
34
+
35
+ /**
36
+ * Sets up event handlers for a new transporter
37
+ * @param {Object} transporter - Nodemailer transport instance
38
+ * @param {string} poolKey - Pool key for this transport
39
+ */
40
+ function setupTransporterHandlers(transporter, poolKey) {
41
+ // Handle connection pool cleanup when idle
42
+ transporter.once('clear', () => {
43
+ // All emails processed and connection timed out
44
+ logger.trace({ msg: 'Clearing disconnected SMTP pool', poolKey });
45
+ SMTP_POOLS.delete(poolKey);
46
+ SMTP_POOL_LAST_USED.delete(poolKey);
47
+ try {
48
+ transporter.close();
49
+ } catch (closeErr) {
50
+ logger.error({ msg: 'Failed to close transporter', err: closeErr });
51
+ }
52
+ });
53
+
54
+ // Handle transport errors by removing from pool
55
+ transporter.once('error', err => {
56
+ // Not sure what happened, but do not re-use this transporter object anymore
57
+ logger.error({ msg: 'Transporter failed', err });
58
+ SMTP_POOLS.delete(poolKey);
59
+ SMTP_POOL_LAST_USED.delete(poolKey);
60
+ try {
61
+ transporter.close();
62
+ } catch (closeErr) {
63
+ logger.error({ msg: 'Failed to close transporter', err: closeErr });
64
+ }
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Creates a new SMTP transport with pooling enabled
70
+ * @param {Object} smtpSettings - SMTP configuration settings
71
+ * @param {string} poolKey - Pool key for this transport
72
+ * @returns {Object} Configured nodemailer transport instance
73
+ */
74
+ function createPooledTransport(smtpSettings, poolKey) {
75
+ // Configure connection pooling settings
76
+ smtpSettings.pool = true;
77
+ smtpSettings.maxConnections = 1;
78
+ smtpSettings.maxMessages = 100;
79
+ smtpSettings.socketTimeout = 2 * 60 * 1000; // 2 minute timeout
80
+
81
+ // Create new transport with pooling enabled
82
+ const transporter = nodemailer.createTransport(smtpSettings);
83
+ transporter.set('proxy_socks_module', socks);
84
+
85
+ setupTransporterHandlers(transporter, poolKey);
86
+
87
+ return transporter;
88
+ }
89
+
90
+ /**
91
+ * Gets or creates a reusable SMTP transport for the given configuration
92
+ * Connection pooling improves performance by reusing SMTP connections
93
+ * @param {Object} smtpSettings - SMTP configuration settings
94
+ * @returns {Object} Nodemailer transport instance
95
+ */
96
+ function getMailTransport(smtpSettings) {
97
+ const poolKey = generatePoolKey(smtpSettings);
98
+
99
+ // Return existing transport if available
100
+ if (SMTP_POOLS.has(poolKey)) {
101
+ const transporter = SMTP_POOLS.get(poolKey);
102
+ // Update last used time for LRU tracking
103
+ SMTP_POOL_LAST_USED.set(poolKey, Date.now());
104
+ return transporter;
105
+ }
106
+
107
+ // Create new transport
108
+ const transporter = createPooledTransport(smtpSettings, poolKey);
109
+
110
+ // Cache the transport for reuse
111
+ SMTP_POOLS.set(poolKey, transporter);
112
+ SMTP_POOL_LAST_USED.set(poolKey, Date.now());
113
+ logger.trace({ msg: 'Created SMTP pool', poolKey });
114
+
115
+ return transporter;
116
+ }
117
+
118
+ /**
119
+ * Closes a pooled connection by key
120
+ * @param {string} poolKey - The pool key to close
121
+ */
122
+ function closePooledConnection(poolKey) {
123
+ const transporter = SMTP_POOLS.get(poolKey);
124
+ if (transporter) {
125
+ try {
126
+ transporter.close();
127
+ } catch (err) {
128
+ logger.error({ msg: 'Failed to close pooled transporter', poolKey, err });
129
+ }
130
+ SMTP_POOLS.delete(poolKey);
131
+ SMTP_POOL_LAST_USED.delete(poolKey);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Performs cleanup of idle SMTP pool connections
137
+ */
138
+ function cleanupIdlePools() {
139
+ const now = Date.now();
140
+ const idleKeys = [];
141
+
142
+ for (const [poolKey, lastUsed] of SMTP_POOL_LAST_USED.entries()) {
143
+ if (now - lastUsed > SMTP_POOL_MAX_IDLE) {
144
+ idleKeys.push(poolKey);
145
+ }
146
+ }
147
+
148
+ for (const poolKey of idleKeys) {
149
+ const transporter = SMTP_POOLS.get(poolKey);
150
+ if (transporter) {
151
+ // Check if the transporter has active connections
152
+ if (transporter._connectionPool && transporter._connectionPool.size > 0) {
153
+ // Still has active connections, update last used time
154
+ SMTP_POOL_LAST_USED.set(poolKey, now);
155
+ logger.trace({ msg: 'SMTP pool still has active connections, skipping cleanup', poolKey });
156
+ continue;
157
+ }
158
+
159
+ logger.debug({ msg: 'Cleaning up idle SMTP pool connection', poolKey, idleTime: now - SMTP_POOL_LAST_USED.get(poolKey) });
160
+ try {
161
+ transporter.close();
162
+ } catch (err) {
163
+ logger.error({ msg: 'Failed to close idle SMTP transporter', poolKey, err });
164
+ }
165
+ }
166
+ SMTP_POOLS.delete(poolKey);
167
+ SMTP_POOL_LAST_USED.delete(poolKey);
168
+ }
169
+
170
+ if (idleKeys.length > 0) {
171
+ logger.info({ msg: 'SMTP pool cleanup completed', cleaned: idleKeys.length, remaining: SMTP_POOLS.size });
172
+ }
173
+ }
174
+
175
+ // Periodic cleanup of idle SMTP pool connections
176
+ const smtpPoolCleanupTimer = setInterval(cleanupIdlePools, SMTP_POOL_CLEANUP_INTERVAL);
177
+
178
+ // Prevent the timer from keeping the process alive
179
+ smtpPoolCleanupTimer.unref();
180
+
181
+ /**
182
+ * Gets the current pool size (for testing/monitoring)
183
+ * @returns {number} Number of active pools
184
+ */
185
+ function getPoolSize() {
186
+ return SMTP_POOLS.size;
187
+ }
188
+
189
+ module.exports = {
190
+ getMailTransport,
191
+ closePooledConnection,
192
+ getPoolSize,
193
+ // Export constants for testing
194
+ SMTP_POOL_MAX_IDLE,
195
+ SMTP_POOL_CLEANUP_INTERVAL
196
+ };
@@ -6,17 +6,8 @@ const config = require('@zone-eu/wild-config');
6
6
  const logger = require('../logger');
7
7
  const { oauth2Apps, oauth2ProviderData } = require('../oauth2-apps');
8
8
 
9
- const {
10
- getDuration,
11
- getBoolean,
12
- resolveCredentials,
13
- hasEnvValue,
14
- readEnvValue,
15
- emitChangeEvent,
16
- matchIp,
17
- getLocalAddress,
18
- loadTlsConfig
19
- } = require('../tools');
9
+ const { getDuration, getBoolean, resolveCredentials, hasEnvValue, readEnvValue, emitChangeEvent, loadTlsConfig } = require('../tools');
10
+ const { matchIp, getLocalAddress } = require('../utils/network');
20
11
 
21
12
  const { redis } = require('../db');
22
13
  const { Account } = require('../account');
@@ -247,7 +238,7 @@ async function onAuth(auth, session) {
247
238
  // load OAuth2 tokens
248
239
  let now = Date.now();
249
240
  let accessToken;
250
- if (!accountData.oauth2.accessToken || !accountData.oauth2.expires || accountData.oauth2.expires < new Date(now - 30 * 1000)) {
241
+ if (!accountData.oauth2.accessToken || !accountData.oauth2.expires || accountData.oauth2.expires < new Date(now + 30 * 1000)) {
251
242
  // renew access token
252
243
  try {
253
244
  accountData = await accountObject.renewAccessToken();
@@ -110,7 +110,7 @@ const checkForUserFlags = err => {
110
110
 
111
111
  let { error, error_description: description } = err;
112
112
 
113
- if (description && typeof description === 'string' && description.indexOf('Token has been expired or revoked')) {
113
+ if (description && typeof description === 'string' && description.includes('Token has been expired or revoked')) {
114
114
  description = `Refresh token has expired or been revoked. This usually happens if you're using a public OAuth2 application that hasn't passed Google's security verification process-in such cases, refresh tokens expire after 7 days. It may also occur if the user has revoked your app's access to their email account. To fix this, consider completing the verification process or ask the user to reauthorize your app.`;
115
115
  }
116
116
 
@@ -338,7 +338,7 @@ class GmailOauth {
338
338
  await this.setFlag(flag);
339
339
  err.tokenRequest.flag = flag;
340
340
  }
341
- } catch (err) {
341
+ } catch (e) {
342
342
  // ignore
343
343
  }
344
344
 
@@ -456,7 +456,7 @@ class GmailOauth {
456
456
  if (userFlag) {
457
457
  err.tokenRequest.userFlag = userFlag;
458
458
  }
459
- } catch (err) {
459
+ } catch (e) {
460
460
  // ignore
461
461
  }
462
462
  throw err;
@@ -538,7 +538,7 @@ class GmailOauth {
538
538
  let flag = checkForFlags(err.oauthRequest.response);
539
539
  if (flag) {
540
540
  await this.setFlag(flag);
541
- err.tokenRequest.flag = flag;
541
+ err.oauthRequest.flag = flag;
542
542
  if (flag.code && !err.code) {
543
543
  err.code = flag.code;
544
544
  }
@@ -161,7 +161,7 @@ class MailRuOauth {
161
161
  await this.setFlag(flag);
162
162
  err.tokenRequest.flag = flag;
163
163
  }
164
- } catch (err) {
164
+ } catch (e) {
165
165
  // ignore
166
166
  }
167
167
  throw err;
@@ -244,7 +244,7 @@ class MailRuOauth {
244
244
  await this.setFlag(flag);
245
245
  err.tokenRequest.flag = flag;
246
246
  }
247
- } catch (err) {
247
+ } catch (e) {
248
248
  // ignore
249
249
  }
250
250
  throw err;
@@ -291,9 +291,9 @@ class MailRuOauth {
291
291
  let flag = checkForFlags(err.oauthRequest.response);
292
292
  if (flag) {
293
293
  await this.setFlag(flag);
294
- err.tokenRequest.flag = flag;
294
+ err.oauthRequest.flag = flag;
295
295
  }
296
- } catch (err) {
296
+ } catch (e) {
297
297
  // ignore
298
298
  }
299
299
  throw err;
@@ -302,7 +302,32 @@ class MailRuOauth {
302
302
  // clear potential auth flag
303
303
  await this.setFlag();
304
304
 
305
- return await res.json();
305
+ // Parse JSON response with handling for empty or invalid responses
306
+ let responseText = await res.text();
307
+ if (!responseText || !responseText.trim()) {
308
+ let err = new Error('Empty response from API');
309
+ err.code = 'EmptyResponse';
310
+ err.oauthRequest = { url, method, provider: this.provider, status: res.status, clientId: this.clientId };
311
+ throw err;
312
+ }
313
+
314
+ try {
315
+ return JSON.parse(responseText);
316
+ } catch (parseErr) {
317
+ let err = new Error('Invalid JSON response from API');
318
+ err.code = 'InvalidJSON';
319
+ err.oauthRequest = {
320
+ url,
321
+ method,
322
+ provider: this.provider,
323
+ status: res.status,
324
+ clientId: this.clientId,
325
+ responseLength: responseText.length,
326
+ responseStart: responseText.slice(0, 200),
327
+ responseEnd: responseText.length > 200 ? responseText.slice(-200) : undefined
328
+ };
329
+ throw err;
330
+ }
306
331
  }
307
332
  }
308
333
 
@@ -316,7 +316,7 @@ class OutlookOauth {
316
316
  await this.setFlag(flag);
317
317
  err.tokenRequest.flag = flag;
318
318
  }
319
- } catch (err) {
319
+ } catch (e) {
320
320
  // ignore
321
321
  }
322
322
  throw err;
@@ -415,7 +415,7 @@ class OutlookOauth {
415
415
  if (userFlag) {
416
416
  err.tokenRequest.userFlag = userFlag;
417
417
  }
418
- } catch (err) {
418
+ } catch (e) {
419
419
  // ignore
420
420
  }
421
421
  throw err;
@@ -484,6 +484,18 @@ class OutlookOauth {
484
484
  retryCount,
485
485
  reqTime
486
486
  };
487
+
488
+ // Capture Retry-After header for rate limiting (429) responses
489
+ if (res.status === 429) {
490
+ const retryAfterHeader = res.headers.get('retry-after');
491
+ if (retryAfterHeader) {
492
+ // Retry-After can be seconds or HTTP-date; parse as seconds
493
+ const retryAfterSeconds = parseInt(retryAfterHeader, 10);
494
+ err.retryAfter = !isNaN(retryAfterSeconds) ? retryAfterSeconds : null;
495
+ err.oauthRequest.retryAfter = err.retryAfter;
496
+ }
497
+ }
498
+
487
499
  try {
488
500
  err.oauthRequest.response = await res.json();
489
501
 
@@ -532,7 +544,49 @@ class OutlookOauth {
532
544
  return await res.text();
533
545
  }
534
546
 
535
- return await res.json();
547
+ // Parse JSON response with handling for empty or invalid responses
548
+ // Microsoft Graph API may occasionally return 200 OK with empty body
549
+ let responseText = await res.text();
550
+ if (!responseText || !responseText.trim()) {
551
+ let err = new Error('Empty response from API');
552
+ err.code = 'EmptyResponse';
553
+ err.statusCode = res.status;
554
+ err.oauthRequest = {
555
+ url,
556
+ method,
557
+ provider: this.provider,
558
+ status: res.status,
559
+ clientId: this.clientId,
560
+ reqTime
561
+ };
562
+ if (this.logger) {
563
+ this.logger.error(Object.assign({ msg: 'API returned empty response' }, err.oauthRequest));
564
+ }
565
+ throw err;
566
+ }
567
+
568
+ try {
569
+ return JSON.parse(responseText);
570
+ } catch (parseErr) {
571
+ let err = new Error('Invalid JSON response from API');
572
+ err.code = 'InvalidJSON';
573
+ err.statusCode = res.status;
574
+ err.oauthRequest = {
575
+ url,
576
+ method,
577
+ provider: this.provider,
578
+ status: res.status,
579
+ clientId: this.clientId,
580
+ reqTime,
581
+ responseLength: responseText.length,
582
+ responseStart: responseText.slice(0, 200),
583
+ responseEnd: responseText.length > 200 ? responseText.slice(-200) : undefined
584
+ };
585
+ if (this.logger) {
586
+ this.logger.error(Object.assign({ msg: 'API returned invalid JSON' }, err.oauthRequest, { parseError: parseErr.message }));
587
+ }
588
+ throw err;
589
+ }
536
590
  }
537
591
  }
538
592
 
@@ -147,14 +147,31 @@ class PubSubInstance {
147
147
  });
148
148
 
149
149
  for (let receivedMessage of pullRes?.receivedMessages || []) {
150
+ // Check stopped flag at start of each message for faster shutdown
151
+ if (this.stopped) {
152
+ logger.info({ msg: 'Stopping message processing due to shutdown', app: this.app });
153
+ return;
154
+ }
155
+
156
+ let processingSuccess = false;
150
157
  try {
151
158
  await this.processPulledMessage(
152
159
  receivedMessage?.message?.messageId,
153
- Buffer.from(receivedMessage?.message?.data || '', 'base64url').toString()
160
+ Buffer.from(receivedMessage?.message?.data || '', 'base64').toString()
154
161
  );
155
- } finally {
156
- // ack the message
157
- // might be expired
162
+ processingSuccess = true;
163
+ } 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
+ });
171
+ }
172
+
173
+ // Only ACK after successful processing
174
+ if (processingSuccess) {
158
175
  try {
159
176
  accessToken = await this.getAccessToken();
160
177
  if (!accessToken) {
@@ -163,14 +180,14 @@ class PubSubInstance {
163
180
  app: this.app,
164
181
  messageId: receivedMessage?.message?.messageId
165
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
+ });
166
190
  }
167
-
168
- await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds: [receivedMessage?.ackId] }, { returnText: true });
169
- logger.debug({
170
- msg: 'Acked subscription message',
171
- app: this.app,
172
- messageId: receivedMessage?.message?.messageId
173
- });
174
191
  } catch (err) {
175
192
  // failed to ack
176
193
  logger.error({
@@ -239,6 +256,8 @@ class GooglePubSub {
239
256
 
240
257
  async remove(app) {
241
258
  if (this.pubSubInstances.has(app)) {
259
+ const instance = this.pubSubInstances.get(app);
260
+ instance.stopped = true; // Stop the loop before removing
242
261
  this.pubSubInstances.delete(app);
243
262
  }
244
263
  }
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ const { GMAIL_API_SCOPES } = require('./gmail');
4
+ const { OUTLOOK_API_SCOPES } = require('./outlook');
5
+
6
+ // Known Microsoft Graph API endpoints across different cloud environments
7
+ const MS_GRAPH_DOMAINS = [
8
+ 'graph.microsoft.com', // Global cloud
9
+ 'graph.microsoft.us', // GCC-High cloud
10
+ 'dod-graph.microsoft.us', // DoD cloud
11
+ 'microsoftgraph.chinacloudapi.cn' // China cloud
12
+ ];
13
+
14
+ /**
15
+ * Normalizes Microsoft Graph scope URLs to their scope names.
16
+ * Supports multiple MS Graph endpoints (global, GCC-High, DoD, China).
17
+ *
18
+ * Examples:
19
+ * https://graph.microsoft.com/Mail.Send -> Mail.Send
20
+ * https://graph.microsoft.us/Mail.ReadWrite -> Mail.ReadWrite
21
+ * https://dod-graph.microsoft.us/Mail.Send -> Mail.Send
22
+ * https://microsoftgraph.chinacloudapi.cn/Mail.Send -> Mail.Send
23
+ * offline_access -> offline_access (passed through)
24
+ *
25
+ * @param {string} scope - The scope string to normalize
26
+ * @param {Object} [logger] - Optional logger for warnings
27
+ * @returns {string} Normalized scope name
28
+ */
29
+ function normalizeMsGraphScope(scope, logger) {
30
+ // Handle plain scope names (e.g., offline_access, openid)
31
+ // These are not URLs and should be passed through as-is
32
+ if (!scope.includes('://')) {
33
+ return scope;
34
+ }
35
+
36
+ // Try to parse as URL to validate it's a Microsoft Graph endpoint
37
+ try {
38
+ const url = new URL(scope);
39
+
40
+ // Validate protocol - must be https
41
+ if (url.protocol !== 'https:') {
42
+ if (logger) {
43
+ logger.warn({
44
+ msg: 'Invalid protocol in MS Graph scope URL, expected https',
45
+ scope,
46
+ protocol: url.protocol
47
+ });
48
+ }
49
+ return scope; // Return as-is for non-https URLs
50
+ }
51
+
52
+ // Check if this is a recognized MS Graph domain
53
+ if (MS_GRAPH_DOMAINS.includes(url.hostname)) {
54
+ // Extract scope name from path only (ignoring query params and fragments)
55
+ // Examples:
56
+ // /Mail.Send -> Mail.Send
57
+ // /Mail.Send?foo=bar -> Mail.Send
58
+ // /Mail.Send#section -> Mail.Send
59
+ // /Mail.Send/ -> Mail.Send (removes trailing slash)
60
+ const scopeName = url.pathname.substring(1).replace(/\/$/, '');
61
+ if (scopeName) {
62
+ return scopeName;
63
+ }
64
+ if (logger) {
65
+ logger.warn({
66
+ msg: 'MS Graph scope URL has no scope name in path',
67
+ scope
68
+ });
69
+ }
70
+ }
71
+ } catch (err) {
72
+ // Invalid URL format
73
+ if (logger) {
74
+ logger.warn({
75
+ msg: 'Failed to parse MS Graph scope URL',
76
+ scope,
77
+ err: err.message
78
+ });
79
+ }
80
+ }
81
+
82
+ // Return as-is if not a recognized Graph URL or parsing failed
83
+ return scope;
84
+ }
85
+
86
+ /**
87
+ * Checks OAuth2 scopes to determine account capabilities.
88
+ *
89
+ * @param {string} provider - OAuth2 provider name ('gmail' or 'outlook')
90
+ * @param {Array<string>} scopes - Array of OAuth2 scope strings
91
+ * @param {Object} [logger] - Optional logger for warnings (used for Outlook scope parsing)
92
+ * @returns {{hasSendScope: boolean, hasReadScope: boolean}} Object indicating send and read capabilities
93
+ *
94
+ * @example
95
+ * // Gmail send-only account
96
+ * checkAccountScopes('gmail', ['https://www.googleapis.com/auth/gmail.send'])
97
+ * // Returns: { hasSendScope: true, hasReadScope: false }
98
+ *
99
+ * @example
100
+ * // Gmail full access account
101
+ * checkAccountScopes('gmail', ['https://www.googleapis.com/auth/gmail.modify'])
102
+ * // Returns: { hasSendScope: false, hasReadScope: true }
103
+ *
104
+ * @example
105
+ * // Outlook send-only account (global cloud)
106
+ * checkAccountScopes('outlook', ['https://graph.microsoft.com/Mail.Send', 'offline_access'])
107
+ * // Returns: { hasSendScope: true, hasReadScope: false }
108
+ *
109
+ * @example
110
+ * // Outlook full access account (GCC-High cloud)
111
+ * checkAccountScopes('outlook', ['https://graph.microsoft.us/Mail.ReadWrite', 'https://graph.microsoft.us/Mail.Send'])
112
+ * // Returns: { hasSendScope: true, hasReadScope: true }
113
+ *
114
+ * @example
115
+ * // Outlook send-only account (DoD cloud)
116
+ * checkAccountScopes('outlook', ['https://dod-graph.microsoft.us/Mail.Send', 'offline_access'])
117
+ * // Returns: { hasSendScope: true, hasReadScope: false }
118
+ */
119
+ function checkAccountScopes(provider, scopes, logger) {
120
+ if (!scopes || !Array.isArray(scopes)) {
121
+ return { hasSendScope: false, hasReadScope: false };
122
+ }
123
+
124
+ if (provider === 'gmail') {
125
+ const hasSendScope = scopes.some(s => s.includes(GMAIL_API_SCOPES.send));
126
+ const hasReadScope = scopes.some(
127
+ s =>
128
+ s.includes(GMAIL_API_SCOPES.modify) ||
129
+ s.includes(GMAIL_API_SCOPES.readonly) ||
130
+ s.includes(GMAIL_API_SCOPES.labels) ||
131
+ s.includes('mail.google.com')
132
+ );
133
+ return { hasSendScope, hasReadScope };
134
+ }
135
+
136
+ if (provider === 'outlook') {
137
+ // Normalize scopes by extracting the scope name from the full URL
138
+ const normalizedScopes = scopes.map(s => normalizeMsGraphScope(s, logger));
139
+
140
+ const hasSendScope = normalizedScopes.some(s => s === OUTLOOK_API_SCOPES.send);
141
+ const hasReadScope = normalizedScopes.some(s => s === OUTLOOK_API_SCOPES.read || s === OUTLOOK_API_SCOPES.readWrite);
142
+ return { hasSendScope, hasReadScope };
143
+ }
144
+
145
+ return { hasSendScope: false, hasReadScope: false };
146
+ }
147
+
148
+ /**
149
+ * Checks Gmail-specific scope requirements from account data.
150
+ *
151
+ * @param {Object} accountData - Account configuration object with oauth2 property
152
+ * @returns {{hasSendScope: boolean, hasReadScope: boolean}} Object indicating send and read capabilities
153
+ *
154
+ * @example
155
+ * checkGmailScopes({ oauth2: { scope: ['https://www.googleapis.com/auth/gmail.send'] } })
156
+ * // Returns: { hasSendScope: true, hasReadScope: false }
157
+ */
158
+ function checkGmailScopes(accountData) {
159
+ const scopes = accountData?.oauth2?.accessToken?.scope || accountData?.oauth2?.scope || [];
160
+ return checkAccountScopes('gmail', scopes);
161
+ }
162
+
163
+ /**
164
+ * Checks Outlook-specific scope requirements from account data.
165
+ *
166
+ * @param {Object} accountData - Account configuration object with oauth2 property
167
+ * @param {Object} [logger] - Optional logger for warnings
168
+ * @returns {{hasSendScope: boolean, hasReadScope: boolean}} Object indicating send and read capabilities
169
+ *
170
+ * @example
171
+ * checkOutlookScopes({ oauth2: { scope: ['https://graph.microsoft.com/Mail.Send'] } })
172
+ * // Returns: { hasSendScope: true, hasReadScope: false }
173
+ */
174
+ function checkOutlookScopes(accountData, logger) {
175
+ const scopes = accountData?.oauth2?.accessToken?.scope || accountData?.oauth2?.scope || [];
176
+ return checkAccountScopes('outlook', scopes, logger);
177
+ }
178
+
179
+ /**
180
+ * Determines if an account is in send-only mode based on its scopes.
181
+ *
182
+ * @param {string} provider - OAuth2 provider name ('gmail' or 'outlook')
183
+ * @param {Object} accountData - Account configuration object with oauth2 property
184
+ * @param {Object} [logger] - Optional logger for warnings
185
+ * @returns {boolean} True if account has send scope but not read scope
186
+ */
187
+ function isSendOnlyByScopes(provider, accountData, logger) {
188
+ const scopes = accountData?.oauth2?.accessToken?.scope || accountData?.oauth2?.scope || [];
189
+ const { hasSendScope, hasReadScope } = checkAccountScopes(provider, scopes, logger);
190
+ return hasSendScope && !hasReadScope;
191
+ }
192
+
193
+ module.exports = {
194
+ checkAccountScopes,
195
+ checkGmailScopes,
196
+ checkOutlookScopes,
197
+ isSendOnlyByScopes,
198
+ normalizeMsGraphScope,
199
+ GMAIL_API_SCOPES,
200
+ OUTLOOK_API_SCOPES,
201
+ MS_GRAPH_DOMAINS
202
+ };