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.
Files changed (137) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +17 -178
  5. package/lib/api-routes/account-routes.js +1006 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +3 -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 +5 -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 +12 -12
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +91 -21
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +85 -82
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +63 -71
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +84 -82
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +85 -82
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +84 -82
  48. package/translations/messages.pot +67 -80
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +86 -82
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +84 -82
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/workers/webhooks.js +6 -0
  78. package/lib/imapproxy/imap-core/test/client.js +0 -46
  79. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  80. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  81. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  82. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  83. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  84. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  88. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  89. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  90. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  92. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  93. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  94. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  95. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  96. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  97. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  98. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  99. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  100. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  101. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  102. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  103. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  104. package/test/api-test.js +0 -899
  105. package/test/autoreply-test.js +0 -327
  106. package/test/bounce-test.js +0 -151
  107. package/test/complaint-test.js +0 -256
  108. package/test/fixtures/autoreply/LICENSE +0 -27
  109. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  110. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  111. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  112. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  113. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  114. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  115. package/test/fixtures/bounces/163.eml +0 -2521
  116. package/test/fixtures/bounces/fastmail.eml +0 -242
  117. package/test/fixtures/bounces/gmail.eml +0 -252
  118. package/test/fixtures/bounces/hotmail.eml +0 -655
  119. package/test/fixtures/bounces/mailru.eml +0 -121
  120. package/test/fixtures/bounces/outlook.eml +0 -1107
  121. package/test/fixtures/bounces/postfix.eml +0 -101
  122. package/test/fixtures/bounces/rambler.eml +0 -116
  123. package/test/fixtures/bounces/workmail.eml +0 -142
  124. package/test/fixtures/bounces/yahoo.eml +0 -139
  125. package/test/fixtures/bounces/zoho.eml +0 -83
  126. package/test/fixtures/bounces/zonemta.eml +0 -100
  127. package/test/fixtures/complaints/LICENSE +0 -27
  128. package/test/fixtures/complaints/amazonses.eml +0 -72
  129. package/test/fixtures/complaints/dmarc.eml +0 -59
  130. package/test/fixtures/complaints/hotmail.eml +0 -49
  131. package/test/fixtures/complaints/optout.eml +0 -40
  132. package/test/fixtures/complaints/standard-arf.eml +0 -68
  133. package/test/fixtures/complaints/yahoo.eml +0 -68
  134. package/test/oauth2-apps-test.js +0 -301
  135. package/test/sendonly-test.js +0 -160
  136. package/test/test-config.js +0 -34
  137. 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
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2025-12-18T15:45:44.000000",
2
+ "creationTime": "2026-01-09T15:45:52.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
  /**
@@ -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 && !['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
- }
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', 'init')
901
+ .hsetnx(this.getAccountKey(), 'state', ACCOUNT_STATES.INIT)
1063
902
  .hsetnx(this.getAccountKey(), 'runIndex', runIndex.toString())
1064
- .hsetnx(this.getAccountKey(), `state:count:connected`, '0')
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 === 'connected' || query.counters) {
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 === 'unset') {
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 === 'init' || !(await this.redis.exists(this.getMailboxListKey()))) {
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:connected`, '0')
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