emailengine-app 2.61.0 → 2.61.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account/account-state.js +248 -0
- package/lib/account.js +17 -178
- package/lib/api-routes/account-routes.js +1006 -0
- package/lib/api-routes/message-routes.js +1377 -0
- package/lib/consts.js +12 -2
- package/lib/email-client/base-client.js +282 -771
- package/lib/email-client/gmail/gmail-api.js +243 -0
- package/lib/email-client/gmail-client.js +145 -53
- package/lib/email-client/imap/mailbox.js +24 -698
- package/lib/email-client/imap/sync-operations.js +812 -0
- package/lib/email-client/imap-client.js +3 -1
- package/lib/email-client/message-builder.js +566 -0
- package/lib/email-client/notification-handler.js +314 -0
- package/lib/email-client/outlook/graph-api.js +326 -0
- package/lib/email-client/outlook-client.js +159 -113
- package/lib/email-client/smtp-pool-manager.js +196 -0
- package/lib/imapproxy/imap-server.js +3 -12
- package/lib/oauth/gmail.js +4 -4
- package/lib/oauth/mail-ru.js +30 -5
- package/lib/oauth/outlook.js +57 -3
- package/lib/oauth/pubsub/google.js +30 -11
- package/lib/oauth/scope-checker.js +202 -0
- package/lib/oauth2-apps.js +8 -4
- package/lib/redis-operations.js +484 -0
- package/lib/routes-ui.js +283 -2582
- package/lib/tools.js +5 -196
- package/lib/ui-routes/account-routes.js +1931 -0
- package/lib/ui-routes/admin-config-routes.js +1233 -0
- package/lib/ui-routes/admin-entities-routes.js +2367 -0
- package/lib/ui-routes/oauth-routes.js +992 -0
- package/lib/utils/network.js +237 -0
- package/package.json +12 -12
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +91 -21
- package/translations/de.mo +0 -0
- package/translations/de.po +85 -82
- package/translations/en.mo +0 -0
- package/translations/en.po +63 -71
- package/translations/et.mo +0 -0
- package/translations/et.po +84 -82
- package/translations/fr.mo +0 -0
- package/translations/fr.po +85 -82
- package/translations/ja.mo +0 -0
- package/translations/ja.po +84 -82
- package/translations/messages.pot +67 -80
- package/translations/nl.mo +0 -0
- package/translations/nl.po +86 -82
- package/translations/pl.mo +0 -0
- package/translations/pl.po +84 -82
- package/views/account/security.hbs +4 -4
- package/views/accounts/account.hbs +13 -13
- package/views/accounts/register/imap-server.hbs +12 -12
- package/views/config/document-store/pre-processing/index.hbs +4 -2
- package/views/config/oauth/app.hbs +6 -7
- package/views/config/oauth/index.hbs +2 -2
- package/views/config/service.hbs +3 -4
- package/views/dashboard.hbs +5 -7
- package/views/error.hbs +22 -7
- package/views/gateways/gateway.hbs +2 -2
- package/views/partials/add_account_modal.hbs +7 -10
- package/views/partials/document_store_header.hbs +1 -1
- package/views/partials/editor_scope_info.hbs +0 -1
- package/views/partials/oauth_config_header.hbs +1 -1
- package/views/partials/side_menu.hbs +3 -3
- package/views/partials/webhook_form.hbs +2 -2
- package/views/templates/index.hbs +1 -1
- package/views/templates/template.hbs +8 -8
- package/views/tokens/index.hbs +6 -6
- package/views/tokens/new.hbs +1 -1
- package/views/webhooks/index.hbs +4 -4
- package/views/webhooks/webhook.hbs +7 -7
- package/workers/api.js +148 -2436
- package/workers/smtp.js +2 -1
- package/workers/webhooks.js +6 -0
- package/lib/imapproxy/imap-core/test/client.js +0 -46
- package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
- package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
- package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
- package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
- package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
- package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
- package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
- package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
- package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
- package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
- package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
- package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
- package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
- package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
- package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
- package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
- package/lib/imapproxy/imap-core/test/test-client.js +0 -152
- package/lib/imapproxy/imap-core/test/test-server.js +0 -623
- package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
- package/test/api-test.js +0 -899
- package/test/autoreply-test.js +0 -327
- package/test/bounce-test.js +0 -151
- package/test/complaint-test.js +0 -256
- package/test/fixtures/autoreply/LICENSE +0 -27
- package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
- package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
- package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
- package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
- package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
- package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
- package/test/fixtures/bounces/163.eml +0 -2521
- package/test/fixtures/bounces/fastmail.eml +0 -242
- package/test/fixtures/bounces/gmail.eml +0 -252
- package/test/fixtures/bounces/hotmail.eml +0 -655
- package/test/fixtures/bounces/mailru.eml +0 -121
- package/test/fixtures/bounces/outlook.eml +0 -1107
- package/test/fixtures/bounces/postfix.eml +0 -101
- package/test/fixtures/bounces/rambler.eml +0 -116
- package/test/fixtures/bounces/workmail.eml +0 -142
- package/test/fixtures/bounces/yahoo.eml +0 -139
- package/test/fixtures/bounces/zoho.eml +0 -83
- package/test/fixtures/bounces/zonemta.eml +0 -100
- package/test/fixtures/complaints/LICENSE +0 -27
- package/test/fixtures/complaints/amazonses.eml +0 -72
- package/test/fixtures/complaints/dmarc.eml +0 -59
- package/test/fixtures/complaints/hotmail.eml +0 -49
- package/test/fixtures/complaints/optout.eml +0 -40
- package/test/fixtures/complaints/standard-arf.eml +0 -68
- package/test/fixtures/complaints/yahoo.eml +0 -68
- package/test/oauth2-apps-test.js +0 -301
- package/test/sendonly-test.js +0 -160
- package/test/test-config.js +0 -34
- package/test/webhooks-server.js +0 -39
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.61.2](https://github.com/postalsys/emailengine/compare/v2.61.1...v2.61.2) (2026-01-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* add error logging for MS Graph subscription creation failures ([ba928e4](https://github.com/postalsys/emailengine/commit/ba928e495acb7fcc8c18e6cf34c917a2f4ad3337))
|
|
9
|
+
* add forced exit to prevent test timeout in CI ([aeb7261](https://github.com/postalsys/emailengine/commit/aeb726169b26e7263acb55041112f5a97a818b8a))
|
|
10
|
+
* handle empty or invalid JSON responses from OAuth APIs ([57d8886](https://github.com/postalsys/emailengine/commit/57d8886a0d29b20df4b575bac8210f6ed97f7fa3))
|
|
11
|
+
|
|
12
|
+
## [2.61.1](https://github.com/postalsys/emailengine/compare/v2.61.0...v2.61.1) (2025-12-28)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* Memory leak fixes for IMAP client and webhooks worker ([b749f96](https://github.com/postalsys/emailengine/commit/b749f964f7e8de6828d23b8c3c5a3ca11e15898a))
|
|
18
|
+
|
|
3
19
|
## [2.61.0](https://github.com/postalsys/emailengine/compare/v2.60.1...v2.61.0) (2025-12-22)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -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
|
/**
|
|
@@ -342,10 +230,7 @@ class Account {
|
|
|
342
230
|
accountData.oauth2 && accountData.oauth2.provider && accountData.oauth2.provider !== accountData.type
|
|
343
231
|
? accountData.oauth2.provider
|
|
344
232
|
: undefined,
|
|
345
|
-
state:
|
|
346
|
-
!runIndex || runIndex <= accountData.runIndex || ['init', 'unset'].includes(accountData.state) || accountData.isApi
|
|
347
|
-
? accountData.state
|
|
348
|
-
: 'init',
|
|
233
|
+
state: getDisplayState(accountData, runIndex),
|
|
349
234
|
|
|
350
235
|
webhooks: accountData.webhooks || undefined,
|
|
351
236
|
proxy: accountData.proxy || undefined,
|
|
@@ -355,10 +240,7 @@ class Account {
|
|
|
355
240
|
|
|
356
241
|
syncTime: accountData.sync,
|
|
357
242
|
|
|
358
|
-
lastError:
|
|
359
|
-
accountData.state === 'connected' || !accountData.lastErrorState || !Object.keys(accountData.lastErrorState).length
|
|
360
|
-
? null
|
|
361
|
-
: accountData.lastErrorState
|
|
243
|
+
lastError: formatLastError(accountData)
|
|
362
244
|
}))
|
|
363
245
|
};
|
|
364
246
|
|
|
@@ -819,53 +701,10 @@ class Account {
|
|
|
819
701
|
}
|
|
820
702
|
}
|
|
821
703
|
|
|
822
|
-
accountData.state =
|
|
823
|
-
!runIndex || runIndex <= accountData.runIndex || ['init', 'unset'].includes(accountData.state) || accountData.isApi ? accountData.state : 'init';
|
|
704
|
+
accountData.state = calculateEffectiveState(accountData, runIndex);
|
|
824
705
|
|
|
825
|
-
if (requireValid
|
|
826
|
-
|
|
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
|
-
}
|
|
860
|
-
|
|
861
|
-
let error = Boom.boomify(err, { statusCode: 503 });
|
|
862
|
-
if (accountData.state) {
|
|
863
|
-
error.output.payload.state = accountData.state;
|
|
864
|
-
}
|
|
865
|
-
if (err.code) {
|
|
866
|
-
error.output.payload.code = err.code;
|
|
867
|
-
}
|
|
868
|
-
throw error;
|
|
706
|
+
if (requireValid) {
|
|
707
|
+
validateAccountState(accountData);
|
|
869
708
|
}
|
|
870
709
|
|
|
871
710
|
return accountData;
|
|
@@ -1059,9 +898,9 @@ class Account {
|
|
|
1059
898
|
.multi()
|
|
1060
899
|
.hgetall(this.getAccountKey())
|
|
1061
900
|
.hmset(this.getAccountKey(), this.serializeAccountData(accountData))
|
|
1062
|
-
.hsetnx(this.getAccountKey(), 'state',
|
|
901
|
+
.hsetnx(this.getAccountKey(), 'state', ACCOUNT_STATES.INIT)
|
|
1063
902
|
.hsetnx(this.getAccountKey(), 'runIndex', runIndex.toString())
|
|
1064
|
-
.hsetnx(this.getAccountKey(), `state:count
|
|
903
|
+
.hsetnx(this.getAccountKey(), `state:count:${ACCOUNT_STATES.CONNECTED}`, '0')
|
|
1065
904
|
.sadd(`${REDIS_PREFIX}ia:accounts`, this.account);
|
|
1066
905
|
|
|
1067
906
|
if (accountData.oauth2 && accountData.oauth2.provider) {
|
|
@@ -1282,7 +1121,7 @@ class Account {
|
|
|
1282
1121
|
|
|
1283
1122
|
// just pass through, do nothing
|
|
1284
1123
|
return mailboxListing;
|
|
1285
|
-
} else if (accountData.state ===
|
|
1124
|
+
} else if (accountData.state === ACCOUNT_STATES.CONNECTED || query.counters) {
|
|
1286
1125
|
// run LIST
|
|
1287
1126
|
mailboxListing = await this.listMailboxes(query);
|
|
1288
1127
|
if (mailboxListing && mailboxListing.error) {
|
|
@@ -1292,7 +1131,7 @@ class Account {
|
|
|
1292
1131
|
}
|
|
1293
1132
|
throw error;
|
|
1294
1133
|
}
|
|
1295
|
-
} else if (accountData.state ===
|
|
1134
|
+
} else if (accountData.state === ACCOUNT_STATES.UNSET) {
|
|
1296
1135
|
// account has not been set up yet
|
|
1297
1136
|
let error = Boom.boomify(new Error('Syncing is disabled for the requested account'), { statusCode: 503 });
|
|
1298
1137
|
if (accountData.state) {
|
|
@@ -1300,7 +1139,7 @@ class Account {
|
|
|
1300
1139
|
}
|
|
1301
1140
|
error.output.payload.code = 'NotSyncing';
|
|
1302
1141
|
throw error;
|
|
1303
|
-
} else if (accountData.state ===
|
|
1142
|
+
} else if (accountData.state === ACCOUNT_STATES.INIT || !(await this.redis.exists(this.getMailboxListKey()))) {
|
|
1304
1143
|
// account has not been set up yet
|
|
1305
1144
|
let error = Boom.boomify(new Error('Requested account is not yet initialized'), { statusCode: 503 });
|
|
1306
1145
|
if (accountData.state) {
|
|
@@ -2413,7 +2252,7 @@ class Account {
|
|
|
2413
2252
|
// start syncing new messages from current time
|
|
2414
2253
|
.hset(this.getAccountKey(), 'notifyFrom', notifyFrom.toISOString())
|
|
2415
2254
|
// mark connection count to 0 to trigger `accountInitialized` event
|
|
2416
|
-
.hset(this.getAccountKey(), `state:count
|
|
2255
|
+
.hset(this.getAccountKey(), `state:count:${ACCOUNT_STATES.CONNECTED}`, '0')
|
|
2417
2256
|
.del(this.getMailboxListKey()) // mailbox list
|
|
2418
2257
|
// Note: mailbox ID hash (iah:) and listRegistry are intentionally NOT deleted
|
|
2419
2258
|
// to preserve message ID stability across reconnections. Message IDs depend on
|