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.
Files changed (136) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +45 -193
  5. package/lib/api-routes/account-routes.js +1023 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +1 -1
  14. package/lib/email-client/message-builder.js +566 -0
  15. package/lib/email-client/notification-handler.js +314 -0
  16. package/lib/email-client/outlook/graph-api.js +326 -0
  17. package/lib/email-client/outlook-client.js +159 -113
  18. package/lib/email-client/smtp-pool-manager.js +196 -0
  19. package/lib/imapproxy/imap-server.js +3 -12
  20. package/lib/oauth/gmail.js +4 -4
  21. package/lib/oauth/mail-ru.js +30 -5
  22. package/lib/oauth/outlook.js +57 -3
  23. package/lib/oauth/pubsub/google.js +30 -11
  24. package/lib/oauth/scope-checker.js +202 -0
  25. package/lib/oauth2-apps.js +8 -4
  26. package/lib/redis-operations.js +484 -0
  27. package/lib/routes-ui.js +283 -2582
  28. package/lib/tools.js +4 -196
  29. package/lib/ui-routes/account-routes.js +1931 -0
  30. package/lib/ui-routes/admin-config-routes.js +1233 -0
  31. package/lib/ui-routes/admin-entities-routes.js +2367 -0
  32. package/lib/ui-routes/oauth-routes.js +992 -0
  33. package/lib/utils/network.js +237 -0
  34. package/package.json +10 -10
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +79 -19
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +97 -86
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +80 -75
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +96 -86
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +97 -86
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +96 -86
  48. package/translations/messages.pot +105 -91
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +98 -86
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +96 -86
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/lib/imapproxy/imap-core/test/client.js +0 -46
  78. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  79. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  80. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  81. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  82. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  83. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  84. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  88. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  89. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  90. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  92. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  93. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  94. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  95. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  96. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  97. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  98. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  99. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  100. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  101. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  102. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  103. package/test/api-test.js +0 -899
  104. package/test/autoreply-test.js +0 -327
  105. package/test/bounce-test.js +0 -151
  106. package/test/complaint-test.js +0 -256
  107. package/test/fixtures/autoreply/LICENSE +0 -27
  108. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  109. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  110. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  111. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  112. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  113. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  114. package/test/fixtures/bounces/163.eml +0 -2521
  115. package/test/fixtures/bounces/fastmail.eml +0 -242
  116. package/test/fixtures/bounces/gmail.eml +0 -252
  117. package/test/fixtures/bounces/hotmail.eml +0 -655
  118. package/test/fixtures/bounces/mailru.eml +0 -121
  119. package/test/fixtures/bounces/outlook.eml +0 -1107
  120. package/test/fixtures/bounces/postfix.eml +0 -101
  121. package/test/fixtures/bounces/rambler.eml +0 -116
  122. package/test/fixtures/bounces/workmail.eml +0 -142
  123. package/test/fixtures/bounces/yahoo.eml +0 -139
  124. package/test/fixtures/bounces/zoho.eml +0 -83
  125. package/test/fixtures/bounces/zonemta.eml +0 -100
  126. package/test/fixtures/complaints/LICENSE +0 -27
  127. package/test/fixtures/complaints/amazonses.eml +0 -72
  128. package/test/fixtures/complaints/dmarc.eml +0 -59
  129. package/test/fixtures/complaints/hotmail.eml +0 -49
  130. package/test/fixtures/complaints/optout.eml +0 -40
  131. package/test/fixtures/complaints/standard-arf.eml +0 -68
  132. package/test/fixtures/complaints/yahoo.eml +0 -68
  133. package/test/oauth2-apps-test.js +0 -301
  134. package/test/sendonly-test.js +0 -160
  135. package/test/test-config.js +0 -34
  136. package/test/webhooks-server.js +0 -39
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
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2025-12-26T15:45:48.000000",
2
+ "creationTime": "2026-01-13T15:46:00.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
@@ -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 { GMAIL_API_SCOPES } = require('./oauth/gmail');
30
- const { OUTLOOK_API_SCOPES } = require('./oauth/outlook');
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 (e.g., 'gmail', 'outlook')
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
- if (!scopes || !Array.isArray(scopes)) {
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
- let delegatedAccount = await resolveDelegatedAccount(this.redis, accountData.account);
303
- if (delegatedAccount) {
304
- accountData.delegatedAccount = delegatedAccount;
305
- let delegatedAccountRow = await this.redis.hgetall(`${REDIS_PREFIX}iad:${delegatedAccount}`);
306
- let delegatedAccountData = this.unserializeAccountData(delegatedAccountRow);
307
- if (delegatedAccountData.oauth2 && delegatedAccountData.oauth2.provider) {
308
- let app;
309
- if (oauthApps.has(delegatedAccountData.oauth2.provider)) {
310
- app = oauthApps.get(delegatedAccountData.oauth2.provider);
311
- } else {
312
- app = await oauth2Apps.get(delegatedAccountData.oauth2.provider);
313
- }
314
- oauthApps.set(delegatedAccountData.oauth2.provider, app || null);
315
- if (app && app.baseScopes === 'api') {
316
- accountData.isApi = true;
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
- accountData.state === 'connected' || !accountData.lastErrorState || !Object.keys(accountData.lastErrorState).length
360
- ? null
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
- 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;
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', 'init')
914
+ .hsetnx(this.getAccountKey(), 'state', ACCOUNT_STATES.INIT)
1063
915
  .hsetnx(this.getAccountKey(), 'runIndex', runIndex.toString())
1064
- .hsetnx(this.getAccountKey(), `state:count:connected`, '0')
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 === 'connected' || query.counters) {
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 === 'unset') {
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 === 'init' || !(await this.redis.exists(this.getMailboxListKey()))) {
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:connected`, '0')
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