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.
- package/CHANGELOG.md +9 -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 +1 -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 +4 -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 +9 -9
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +78 -18
- 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 +74 -87
- 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/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,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
|
-
|
|
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
|
|
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();
|
package/lib/oauth/gmail.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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 (
|
|
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.
|
|
541
|
+
err.oauthRequest.flag = flag;
|
|
542
542
|
if (flag.code && !err.code) {
|
|
543
543
|
err.code = flag.code;
|
|
544
544
|
}
|
package/lib/oauth/mail-ru.js
CHANGED
|
@@ -161,7 +161,7 @@ class MailRuOauth {
|
|
|
161
161
|
await this.setFlag(flag);
|
|
162
162
|
err.tokenRequest.flag = flag;
|
|
163
163
|
}
|
|
164
|
-
} catch (
|
|
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 (
|
|
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.
|
|
294
|
+
err.oauthRequest.flag = flag;
|
|
295
295
|
}
|
|
296
|
-
} catch (
|
|
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
|
-
|
|
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
|
|
package/lib/oauth/outlook.js
CHANGED
|
@@ -316,7 +316,7 @@ class OutlookOauth {
|
|
|
316
316
|
await this.setFlag(flag);
|
|
317
317
|
err.tokenRequest.flag = flag;
|
|
318
318
|
}
|
|
319
|
-
} catch (
|
|
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 (
|
|
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
|
-
|
|
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 || '', '
|
|
160
|
+
Buffer.from(receivedMessage?.message?.data || '', 'base64').toString()
|
|
154
161
|
);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
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
|
+
};
|