emailengine-app 2.61.1 → 2.61.3
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 +17 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account/account-state.js +248 -0
- package/lib/account.js +45 -193
- package/lib/api-routes/account-routes.js +1023 -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 +10 -10
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +79 -19
- package/translations/de.mo +0 -0
- package/translations/de.po +97 -86
- package/translations/en.mo +0 -0
- package/translations/en.po +80 -75
- package/translations/et.mo +0 -0
- package/translations/et.po +96 -86
- package/translations/fr.mo +0 -0
- package/translations/fr.po +97 -86
- package/translations/ja.mo +0 -0
- package/translations/ja.po +96 -86
- package/translations/messages.pot +105 -91
- package/translations/nl.mo +0 -0
- package/translations/nl.po +98 -86
- package/translations/pl.mo +0 -0
- package/translations/pl.po +96 -86
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.61.3](https://github.com/postalsys/emailengine/compare/v2.61.2...v2.61.3) (2026-01-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* prevent 500 error when listing accounts with invalid delegation config ([8651bcc](https://github.com/postalsys/emailengine/commit/8651bcc3c18cd54ee5309b3392915773c355e6c4))
|
|
9
|
+
* update gettext script to include refactored UI route files ([5b2e4a5](https://github.com/postalsys/emailengine/commit/5b2e4a55771f192fb14d030b5e5d08ba860c90ab))
|
|
10
|
+
|
|
11
|
+
## [2.61.2](https://github.com/postalsys/emailengine/compare/v2.61.1...v2.61.2) (2026-01-12)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* add error logging for MS Graph subscription creation failures ([ba928e4](https://github.com/postalsys/emailengine/commit/ba928e495acb7fcc8c18e6cf34c917a2f4ad3337))
|
|
17
|
+
* add forced exit to prevent test timeout in CI ([aeb7261](https://github.com/postalsys/emailengine/commit/aeb726169b26e7263acb55041112f5a97a818b8a))
|
|
18
|
+
* handle empty or invalid JSON responses from OAuth APIs ([57d8886](https://github.com/postalsys/emailengine/commit/57d8886a0d29b20df4b575bac8210f6ed97f7fa3))
|
|
19
|
+
|
|
3
20
|
## [2.61.1](https://github.com/postalsys/emailengine/compare/v2.61.0...v2.61.1) (2025-12-28)
|
|
4
21
|
|
|
5
22
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Boom = require('@hapi/boom');
|
|
4
|
+
const { REDIS_PREFIX } = require('../consts');
|
|
5
|
+
|
|
6
|
+
// Account state constants
|
|
7
|
+
const ACCOUNT_STATES = {
|
|
8
|
+
INIT: 'init',
|
|
9
|
+
UNSET: 'unset',
|
|
10
|
+
CONNECTED: 'connected',
|
|
11
|
+
CONNECTING: 'connecting',
|
|
12
|
+
SYNCING: 'syncing',
|
|
13
|
+
AUTHENTICATION_ERROR: 'authenticationError',
|
|
14
|
+
CONNECT_ERROR: 'connectError'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// States that indicate the account is operational
|
|
18
|
+
const VALID_STATES = [ACCOUNT_STATES.CONNECTED, ACCOUNT_STATES.CONNECTING, ACCOUNT_STATES.SYNCING];
|
|
19
|
+
|
|
20
|
+
// States that bypass runIndex checking
|
|
21
|
+
const BYPASS_RUN_INDEX_STATES = [ACCOUNT_STATES.INIT, ACCOUNT_STATES.UNSET];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Calculates the effective account state based on runIndex and current state
|
|
25
|
+
* @param {Object} accountData - The account data object
|
|
26
|
+
* @param {number} runIndex - Current run index from the worker
|
|
27
|
+
* @returns {string} The effective state value
|
|
28
|
+
*/
|
|
29
|
+
function calculateEffectiveState(accountData, runIndex) {
|
|
30
|
+
const currentState = accountData.state;
|
|
31
|
+
const accountRunIndex = accountData.runIndex;
|
|
32
|
+
const isApiAccount = accountData.isApi;
|
|
33
|
+
|
|
34
|
+
// API accounts and special states bypass runIndex checking
|
|
35
|
+
if (!runIndex || runIndex <= accountRunIndex || BYPASS_RUN_INDEX_STATES.includes(currentState) || isApiAccount) {
|
|
36
|
+
return currentState;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Account hasn't been processed by current worker yet
|
|
40
|
+
return ACCOUNT_STATES.INIT;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validates that an account is in a state suitable for operations
|
|
45
|
+
* @param {Object} accountData - The account data object with state
|
|
46
|
+
* @throws {Boom} When the account state is not valid for operations
|
|
47
|
+
*/
|
|
48
|
+
function validateAccountState(accountData) {
|
|
49
|
+
if (VALID_STATES.includes(accountData.state)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let err;
|
|
54
|
+
switch (accountData.state) {
|
|
55
|
+
case ACCOUNT_STATES.INIT:
|
|
56
|
+
err = new Error('Requested account is not yet initialized');
|
|
57
|
+
err.code = 'NotYetConnected';
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case ACCOUNT_STATES.AUTHENTICATION_ERROR:
|
|
61
|
+
err = new Error('Requested account can not be authenticated');
|
|
62
|
+
err.code = 'AuthenticationFails';
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case ACCOUNT_STATES.CONNECT_ERROR:
|
|
66
|
+
err = new Error('Can not establish server connection for requested account');
|
|
67
|
+
err.code = 'ConnectionError';
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case ACCOUNT_STATES.UNSET:
|
|
71
|
+
err = new Error('Syncing is disabled for the requested account');
|
|
72
|
+
err.code = 'NotSyncing';
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
default:
|
|
76
|
+
err = new Error('Requested account currently not available');
|
|
77
|
+
err.code = 'NoAvailable';
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const error = Boom.boomify(err, { statusCode: 503 });
|
|
82
|
+
if (accountData.state) {
|
|
83
|
+
error.output.payload.state = accountData.state;
|
|
84
|
+
}
|
|
85
|
+
if (err.code) {
|
|
86
|
+
error.output.payload.code = err.code;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets the account state from Redis
|
|
93
|
+
* @param {Object} redis - Redis client
|
|
94
|
+
* @param {string} account - Account ID
|
|
95
|
+
* @returns {Promise<string|null>} The current state value
|
|
96
|
+
*/
|
|
97
|
+
async function getAccountState(redis, account) {
|
|
98
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
99
|
+
return await redis.hget(accountKey, 'state');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sets the account state in Redis
|
|
104
|
+
* Only updates if the account exists
|
|
105
|
+
* @param {Object} redis - Redis client
|
|
106
|
+
* @param {string} account - Account ID
|
|
107
|
+
* @param {string} state - New state value
|
|
108
|
+
* @returns {Promise<boolean>} True if state was set
|
|
109
|
+
*/
|
|
110
|
+
async function setAccountState(redis, account, state) {
|
|
111
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
112
|
+
const result = await redis.hSetExists(accountKey, 'state', state);
|
|
113
|
+
return result === 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Gets the last error state for an account
|
|
118
|
+
* @param {Object} redis - Redis client
|
|
119
|
+
* @param {string} account - Account ID
|
|
120
|
+
* @returns {Promise<Object|null>} Parsed error state object or null
|
|
121
|
+
*/
|
|
122
|
+
async function getLastErrorState(redis, account) {
|
|
123
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
124
|
+
const errorState = await redis.hget(accountKey, 'lastErrorState');
|
|
125
|
+
|
|
126
|
+
if (!errorState) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(errorState);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sets the last error state for an account
|
|
139
|
+
* @param {Object} redis - Redis client
|
|
140
|
+
* @param {string} account - Account ID
|
|
141
|
+
* @param {Object} errorData - Error state data
|
|
142
|
+
* @returns {Promise<boolean>} True if error state was set
|
|
143
|
+
*/
|
|
144
|
+
async function setLastErrorState(redis, account, errorData) {
|
|
145
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
146
|
+
const result = await redis.hSetExists(accountKey, 'lastErrorState', JSON.stringify(errorData));
|
|
147
|
+
return result === 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Clears the last error state for an account
|
|
152
|
+
* @param {Object} redis - Redis client
|
|
153
|
+
* @param {string} account - Account ID
|
|
154
|
+
* @returns {Promise<number>} Number of fields removed
|
|
155
|
+
*/
|
|
156
|
+
async function clearLastErrorState(redis, account) {
|
|
157
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
158
|
+
return await redis.hdel(accountKey, 'lastErrorState');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Gets the connection count for a specific state
|
|
163
|
+
* @param {Object} redis - Redis client
|
|
164
|
+
* @param {string} account - Account ID
|
|
165
|
+
* @param {string} state - State to get count for
|
|
166
|
+
* @returns {Promise<number>} Connection count
|
|
167
|
+
*/
|
|
168
|
+
async function getStateCount(redis, account, state) {
|
|
169
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
170
|
+
const count = await redis.hget(accountKey, `state:count:${state}`);
|
|
171
|
+
return parseInt(count, 10) || 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resets the connection count for a specific state
|
|
176
|
+
* @param {Object} redis - Redis client
|
|
177
|
+
* @param {string} account - Account ID
|
|
178
|
+
* @param {string} state - State to reset count for
|
|
179
|
+
* @returns {Promise<boolean>} True if count was reset
|
|
180
|
+
*/
|
|
181
|
+
async function resetStateCount(redis, account, state) {
|
|
182
|
+
const accountKey = `${REDIS_PREFIX}iad:${account}`;
|
|
183
|
+
const result = await redis.hset(accountKey, `state:count:${state}`, '0');
|
|
184
|
+
return result >= 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Formats the last error for API responses
|
|
189
|
+
* Returns null if account is connected or no error exists
|
|
190
|
+
* @param {Object} accountData - Account data object
|
|
191
|
+
* @returns {Object|null} Formatted error object or null
|
|
192
|
+
*/
|
|
193
|
+
function formatLastError(accountData) {
|
|
194
|
+
if (accountData.state === ACCOUNT_STATES.CONNECTED) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!accountData.lastErrorState || !Object.keys(accountData.lastErrorState).length) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return accountData.lastErrorState;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Determines if account state should be reported as 'init' based on runIndex
|
|
207
|
+
* Used for account listings when the worker hasn't processed the account yet
|
|
208
|
+
* @param {Object} accountData - Account data with state and runIndex
|
|
209
|
+
* @param {number} currentRunIndex - Current worker run index
|
|
210
|
+
* @returns {string} Effective state for display
|
|
211
|
+
*/
|
|
212
|
+
function getDisplayState(accountData, currentRunIndex) {
|
|
213
|
+
const currentState = accountData.state;
|
|
214
|
+
const accountRunIndex = accountData.runIndex;
|
|
215
|
+
const isApiAccount = accountData.isApi;
|
|
216
|
+
|
|
217
|
+
if (!currentRunIndex || currentRunIndex <= accountRunIndex || BYPASS_RUN_INDEX_STATES.includes(currentState) || isApiAccount) {
|
|
218
|
+
return currentState;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return ACCOUNT_STATES.INIT;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
// Constants
|
|
226
|
+
ACCOUNT_STATES,
|
|
227
|
+
VALID_STATES,
|
|
228
|
+
BYPASS_RUN_INDEX_STATES,
|
|
229
|
+
|
|
230
|
+
// State calculation
|
|
231
|
+
calculateEffectiveState,
|
|
232
|
+
validateAccountState,
|
|
233
|
+
getDisplayState,
|
|
234
|
+
|
|
235
|
+
// Redis operations
|
|
236
|
+
getAccountState,
|
|
237
|
+
setAccountState,
|
|
238
|
+
|
|
239
|
+
// Error state management
|
|
240
|
+
getLastErrorState,
|
|
241
|
+
setLastErrorState,
|
|
242
|
+
clearLastErrorState,
|
|
243
|
+
formatLastError,
|
|
244
|
+
|
|
245
|
+
// State counters
|
|
246
|
+
getStateCount,
|
|
247
|
+
resetStateCount
|
|
248
|
+
};
|
package/lib/account.js
CHANGED
|
@@ -26,8 +26,8 @@ const Lock = require('ioredfour');
|
|
|
26
26
|
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16);
|
|
27
27
|
const { REDIS_PREFIX, ACCOUNT_DELETED_NOTIFY, MAILBOX_HASH } = require('./consts');
|
|
28
28
|
const { mimeHtml } = require('@postalsys/email-text-tools');
|
|
29
|
-
const {
|
|
30
|
-
const {
|
|
29
|
+
const { checkAccountScopes: checkScopes } = require('./oauth/scope-checker');
|
|
30
|
+
const { ACCOUNT_STATES, calculateEffectiveState, validateAccountState, getDisplayState, formatLastError } = require('./account/account-state');
|
|
31
31
|
|
|
32
32
|
class Account {
|
|
33
33
|
constructor(options) {
|
|
@@ -58,127 +58,15 @@ class Account {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Checks OAuth2 scopes to determine account capabilities
|
|
61
|
+
* Checks OAuth2 scopes to determine account capabilities.
|
|
62
|
+
* Delegates to centralized scope-checker module.
|
|
62
63
|
*
|
|
63
|
-
* @param {string} provider - OAuth2 provider name (
|
|
64
|
+
* @param {string} provider - OAuth2 provider name ('gmail' or 'outlook')
|
|
64
65
|
* @param {Array<string>} scopes - Array of OAuth2 scope strings
|
|
65
66
|
* @returns {{hasSendScope: boolean, hasReadScope: boolean}} Object indicating send and read capabilities
|
|
66
|
-
*
|
|
67
|
-
* @example
|
|
68
|
-
* // Gmail send-only account
|
|
69
|
-
* checkAccountScopes('gmail', ['https://www.googleapis.com/auth/gmail.send'])
|
|
70
|
-
* // Returns: { hasSendScope: true, hasReadScope: false }
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* // Gmail full access account
|
|
74
|
-
* checkAccountScopes('gmail', ['https://www.googleapis.com/auth/gmail.modify'])
|
|
75
|
-
* // Returns: { hasSendScope: false, hasReadScope: true }
|
|
76
|
-
*
|
|
77
|
-
* @example
|
|
78
|
-
* // Outlook send-only account (global cloud)
|
|
79
|
-
* checkAccountScopes('outlook', ['https://graph.microsoft.com/Mail.Send', 'offline_access'])
|
|
80
|
-
* // Returns: { hasSendScope: true, hasReadScope: false }
|
|
81
|
-
*
|
|
82
|
-
* @example
|
|
83
|
-
* // Outlook full access account (GCC-High cloud)
|
|
84
|
-
* checkAccountScopes('outlook', ['https://graph.microsoft.us/Mail.ReadWrite', 'https://graph.microsoft.us/Mail.Send'])
|
|
85
|
-
* // Returns: { hasSendScope: true, hasReadScope: true }
|
|
86
|
-
*
|
|
87
|
-
* @example
|
|
88
|
-
* // Outlook send-only account (DoD cloud)
|
|
89
|
-
* checkAccountScopes('outlook', ['https://dod-graph.microsoft.us/Mail.Send', 'offline_access'])
|
|
90
|
-
* // Returns: { hasSendScope: true, hasReadScope: false }
|
|
91
67
|
*/
|
|
92
68
|
checkAccountScopes(provider, scopes) {
|
|
93
|
-
|
|
94
|
-
return { hasSendScope: false, hasReadScope: false };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (provider === 'gmail') {
|
|
98
|
-
const hasSendScope = scopes.some(s => s.includes(GMAIL_API_SCOPES.send));
|
|
99
|
-
const hasReadScope = scopes.some(
|
|
100
|
-
s =>
|
|
101
|
-
s.includes(GMAIL_API_SCOPES.modify) ||
|
|
102
|
-
s.includes(GMAIL_API_SCOPES.readonly) ||
|
|
103
|
-
s.includes(GMAIL_API_SCOPES.labels) ||
|
|
104
|
-
s.includes('mail.google.com')
|
|
105
|
-
);
|
|
106
|
-
return { hasSendScope, hasReadScope };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (provider === 'outlook') {
|
|
110
|
-
// Known Microsoft Graph API endpoints across different cloud environments
|
|
111
|
-
const msGraphDomains = [
|
|
112
|
-
'graph.microsoft.com', // Global cloud
|
|
113
|
-
'graph.microsoft.us', // GCC-High cloud
|
|
114
|
-
'dod-graph.microsoft.us', // DoD cloud
|
|
115
|
-
'microsoftgraph.chinacloudapi.cn' // China cloud
|
|
116
|
-
];
|
|
117
|
-
|
|
118
|
-
// Normalize scopes by extracting the scope name from the full URL
|
|
119
|
-
// Supports multiple MS Graph endpoints (global, GCC-High, DoD, China)
|
|
120
|
-
// Examples:
|
|
121
|
-
// https://graph.microsoft.com/Mail.Send -> Mail.Send
|
|
122
|
-
// https://graph.microsoft.us/Mail.ReadWrite -> Mail.ReadWrite
|
|
123
|
-
// https://dod-graph.microsoft.us/Mail.Send -> Mail.Send
|
|
124
|
-
// https://microsoftgraph.chinacloudapi.cn/Mail.Send -> Mail.Send
|
|
125
|
-
// offline_access -> offline_access (passed through)
|
|
126
|
-
const normalizedScopes = scopes.map(s => {
|
|
127
|
-
// Handle plain scope names (e.g., offline_access, openid)
|
|
128
|
-
// These are not URLs and should be passed through as-is
|
|
129
|
-
if (!s.includes('://')) {
|
|
130
|
-
return s;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Try to parse as URL to validate it's a Microsoft Graph endpoint
|
|
134
|
-
try {
|
|
135
|
-
const url = new URL(s);
|
|
136
|
-
|
|
137
|
-
// Validate protocol - must be https
|
|
138
|
-
if (url.protocol !== 'https:') {
|
|
139
|
-
this.logger.warn({
|
|
140
|
-
msg: 'Invalid protocol in MS Graph scope URL, expected https',
|
|
141
|
-
scope: s,
|
|
142
|
-
protocol: url.protocol
|
|
143
|
-
});
|
|
144
|
-
return s; // Return as-is for non-https URLs
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Check if this is a recognized MS Graph domain
|
|
148
|
-
if (msGraphDomains.includes(url.hostname)) {
|
|
149
|
-
// Extract scope name from path only (ignoring query params and fragments)
|
|
150
|
-
// Examples:
|
|
151
|
-
// /Mail.Send -> Mail.Send
|
|
152
|
-
// /Mail.Send?foo=bar -> Mail.Send
|
|
153
|
-
// /Mail.Send#section -> Mail.Send
|
|
154
|
-
// /Mail.Send/ -> Mail.Send (removes trailing slash)
|
|
155
|
-
const scopeName = url.pathname.substring(1).replace(/\/$/, '');
|
|
156
|
-
if (scopeName) {
|
|
157
|
-
return scopeName;
|
|
158
|
-
}
|
|
159
|
-
this.logger.warn({
|
|
160
|
-
msg: 'MS Graph scope URL has no scope name in path',
|
|
161
|
-
scope: s
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
} catch (err) {
|
|
165
|
-
// Invalid URL format
|
|
166
|
-
this.logger.warn({
|
|
167
|
-
msg: 'Failed to parse MS Graph scope URL',
|
|
168
|
-
scope: s,
|
|
169
|
-
err: err.message
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
// Return as-is if not a recognized Graph URL or parsing failed
|
|
173
|
-
return s;
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
const hasSendScope = normalizedScopes.some(s => s === OUTLOOK_API_SCOPES.send);
|
|
177
|
-
const hasReadScope = normalizedScopes.some(s => s === OUTLOOK_API_SCOPES.read || s === OUTLOOK_API_SCOPES.readWrite);
|
|
178
|
-
return { hasSendScope, hasReadScope };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return { hasSendScope: false, hasReadScope: false };
|
|
69
|
+
return checkScopes(provider, scopes, this.logger);
|
|
182
70
|
}
|
|
183
71
|
|
|
184
72
|
/**
|
|
@@ -299,23 +187,34 @@ class Account {
|
|
|
299
187
|
}
|
|
300
188
|
} else if (accountData.oauth2 && accountData.oauth2.auth && accountData.oauth2.auth.delegatedAccount) {
|
|
301
189
|
accountData.type = 'delegated';
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
190
|
+
try {
|
|
191
|
+
let delegatedAccount = await resolveDelegatedAccount(this.redis, accountData.account);
|
|
192
|
+
if (delegatedAccount) {
|
|
193
|
+
accountData.delegatedAccount = delegatedAccount;
|
|
194
|
+
let delegatedAccountRow = await this.redis.hgetall(`${REDIS_PREFIX}iad:${delegatedAccount}`);
|
|
195
|
+
let delegatedAccountData = this.unserializeAccountData(delegatedAccountRow);
|
|
196
|
+
if (delegatedAccountData.oauth2 && delegatedAccountData.oauth2.provider) {
|
|
197
|
+
let app;
|
|
198
|
+
if (oauthApps.has(delegatedAccountData.oauth2.provider)) {
|
|
199
|
+
app = oauthApps.get(delegatedAccountData.oauth2.provider);
|
|
200
|
+
} else {
|
|
201
|
+
app = await oauth2Apps.get(delegatedAccountData.oauth2.provider);
|
|
202
|
+
}
|
|
203
|
+
oauthApps.set(delegatedAccountData.oauth2.provider, app || null);
|
|
204
|
+
if (app && app.baseScopes === 'api') {
|
|
205
|
+
accountData.isApi = true;
|
|
206
|
+
}
|
|
317
207
|
}
|
|
318
208
|
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
this.logger.warn({
|
|
211
|
+
msg: 'Failed to resolve delegated account',
|
|
212
|
+
account: accountData.account,
|
|
213
|
+
delegatedAccount: accountData.oauth2.auth.delegatedAccount,
|
|
214
|
+
err
|
|
215
|
+
});
|
|
216
|
+
accountData.type = 'invalid';
|
|
217
|
+
accountData.delegationError = err.message;
|
|
319
218
|
}
|
|
320
219
|
} else if (accountData.imap && !accountData.imap.disabled) {
|
|
321
220
|
accountData.type = 'imap';
|
|
@@ -342,10 +241,7 @@ class Account {
|
|
|
342
241
|
accountData.oauth2 && accountData.oauth2.provider && accountData.oauth2.provider !== accountData.type
|
|
343
242
|
? accountData.oauth2.provider
|
|
344
243
|
: undefined,
|
|
345
|
-
state:
|
|
346
|
-
!runIndex || runIndex <= accountData.runIndex || ['init', 'unset'].includes(accountData.state) || accountData.isApi
|
|
347
|
-
? accountData.state
|
|
348
|
-
: 'init',
|
|
244
|
+
state: getDisplayState(accountData, runIndex),
|
|
349
245
|
|
|
350
246
|
webhooks: accountData.webhooks || undefined,
|
|
351
247
|
proxy: accountData.proxy || undefined,
|
|
@@ -355,10 +251,9 @@ class Account {
|
|
|
355
251
|
|
|
356
252
|
syncTime: accountData.sync,
|
|
357
253
|
|
|
358
|
-
lastError:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
: accountData.lastErrorState
|
|
254
|
+
lastError: formatLastError(accountData),
|
|
255
|
+
|
|
256
|
+
delegationError: accountData.delegationError || undefined
|
|
362
257
|
}))
|
|
363
258
|
};
|
|
364
259
|
|
|
@@ -819,53 +714,10 @@ class Account {
|
|
|
819
714
|
}
|
|
820
715
|
}
|
|
821
716
|
|
|
822
|
-
accountData.state =
|
|
823
|
-
!runIndex || runIndex <= accountData.runIndex || ['init', 'unset'].includes(accountData.state) || accountData.isApi ? accountData.state : 'init';
|
|
824
|
-
|
|
825
|
-
if (requireValid && !['connected', 'connecting', 'syncing'].includes(accountData.state)) {
|
|
826
|
-
let err;
|
|
827
|
-
switch (accountData.state) {
|
|
828
|
-
case 'init':
|
|
829
|
-
err = new Error('Requested account is not yet initialized');
|
|
830
|
-
err.code = 'NotYetConnected';
|
|
831
|
-
break;
|
|
832
|
-
/*
|
|
833
|
-
// Check disabled for the following states - allow commands to go through.
|
|
834
|
-
// A secondary IMAP connection is opened if possible.
|
|
835
|
-
*/
|
|
836
|
-
/*
|
|
837
|
-
case 'connecting':
|
|
838
|
-
case 'syncing':
|
|
839
|
-
err = new Error('Requested account is not yet connected');
|
|
840
|
-
err.code = 'NotYetConnected';
|
|
841
|
-
break;
|
|
842
|
-
*/
|
|
843
|
-
case 'authenticationError':
|
|
844
|
-
err = new Error('Requested account can not be authenticated');
|
|
845
|
-
err.code = 'AuthenticationFails';
|
|
846
|
-
break;
|
|
847
|
-
case 'connectError':
|
|
848
|
-
err = new Error('Can not establish server connection for requested account');
|
|
849
|
-
err.code = 'ConnectionError';
|
|
850
|
-
break;
|
|
851
|
-
case 'unset':
|
|
852
|
-
err = new Error('Syncing is disabled for the requested account');
|
|
853
|
-
err.code = 'NotSyncing';
|
|
854
|
-
break;
|
|
855
|
-
default:
|
|
856
|
-
err = new Error('Requested account currently not available');
|
|
857
|
-
err.code = 'NoAvailable';
|
|
858
|
-
break;
|
|
859
|
-
}
|
|
717
|
+
accountData.state = calculateEffectiveState(accountData, runIndex);
|
|
860
718
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
error.output.payload.state = accountData.state;
|
|
864
|
-
}
|
|
865
|
-
if (err.code) {
|
|
866
|
-
error.output.payload.code = err.code;
|
|
867
|
-
}
|
|
868
|
-
throw error;
|
|
719
|
+
if (requireValid) {
|
|
720
|
+
validateAccountState(accountData);
|
|
869
721
|
}
|
|
870
722
|
|
|
871
723
|
return accountData;
|
|
@@ -1059,9 +911,9 @@ class Account {
|
|
|
1059
911
|
.multi()
|
|
1060
912
|
.hgetall(this.getAccountKey())
|
|
1061
913
|
.hmset(this.getAccountKey(), this.serializeAccountData(accountData))
|
|
1062
|
-
.hsetnx(this.getAccountKey(), 'state',
|
|
914
|
+
.hsetnx(this.getAccountKey(), 'state', ACCOUNT_STATES.INIT)
|
|
1063
915
|
.hsetnx(this.getAccountKey(), 'runIndex', runIndex.toString())
|
|
1064
|
-
.hsetnx(this.getAccountKey(), `state:count
|
|
916
|
+
.hsetnx(this.getAccountKey(), `state:count:${ACCOUNT_STATES.CONNECTED}`, '0')
|
|
1065
917
|
.sadd(`${REDIS_PREFIX}ia:accounts`, this.account);
|
|
1066
918
|
|
|
1067
919
|
if (accountData.oauth2 && accountData.oauth2.provider) {
|
|
@@ -1282,7 +1134,7 @@ class Account {
|
|
|
1282
1134
|
|
|
1283
1135
|
// just pass through, do nothing
|
|
1284
1136
|
return mailboxListing;
|
|
1285
|
-
} else if (accountData.state ===
|
|
1137
|
+
} else if (accountData.state === ACCOUNT_STATES.CONNECTED || query.counters) {
|
|
1286
1138
|
// run LIST
|
|
1287
1139
|
mailboxListing = await this.listMailboxes(query);
|
|
1288
1140
|
if (mailboxListing && mailboxListing.error) {
|
|
@@ -1292,7 +1144,7 @@ class Account {
|
|
|
1292
1144
|
}
|
|
1293
1145
|
throw error;
|
|
1294
1146
|
}
|
|
1295
|
-
} else if (accountData.state ===
|
|
1147
|
+
} else if (accountData.state === ACCOUNT_STATES.UNSET) {
|
|
1296
1148
|
// account has not been set up yet
|
|
1297
1149
|
let error = Boom.boomify(new Error('Syncing is disabled for the requested account'), { statusCode: 503 });
|
|
1298
1150
|
if (accountData.state) {
|
|
@@ -1300,7 +1152,7 @@ class Account {
|
|
|
1300
1152
|
}
|
|
1301
1153
|
error.output.payload.code = 'NotSyncing';
|
|
1302
1154
|
throw error;
|
|
1303
|
-
} else if (accountData.state ===
|
|
1155
|
+
} else if (accountData.state === ACCOUNT_STATES.INIT || !(await this.redis.exists(this.getMailboxListKey()))) {
|
|
1304
1156
|
// account has not been set up yet
|
|
1305
1157
|
let error = Boom.boomify(new Error('Requested account is not yet initialized'), { statusCode: 503 });
|
|
1306
1158
|
if (accountData.state) {
|
|
@@ -2413,7 +2265,7 @@ class Account {
|
|
|
2413
2265
|
// start syncing new messages from current time
|
|
2414
2266
|
.hset(this.getAccountKey(), 'notifyFrom', notifyFrom.toISOString())
|
|
2415
2267
|
// mark connection count to 0 to trigger `accountInitialized` event
|
|
2416
|
-
.hset(this.getAccountKey(), `state:count
|
|
2268
|
+
.hset(this.getAccountKey(), `state:count:${ACCOUNT_STATES.CONNECTED}`, '0')
|
|
2417
2269
|
.del(this.getMailboxListKey()) // mailbox list
|
|
2418
2270
|
// Note: mailbox ID hash (iah:) and listRegistry are intentionally NOT deleted
|
|
2419
2271
|
// to preserve message ID stability across reconnections. Message IDs depend on
|