emailengine-app 2.64.0 → 2.65.0
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/.github/workflows/test.yml +4 -0
- package/CHANGELOG.md +14 -0
- package/copy-static-files.sh +1 -1
- package/data/google-crawlers.json +1 -1
- package/lib/account.js +7 -7
- package/lib/api-routes/account-routes.js +7 -1
- package/lib/email-client/outlook/graph-api.js +2 -2
- package/lib/email-client/outlook-client.js +43 -10
- package/lib/export.js +17 -0
- package/lib/imapproxy/imap-server.js +2 -2
- package/lib/oauth/outlook.js +99 -1
- package/lib/oauth2-apps.js +66 -12
- package/lib/outbox.js +1 -1
- package/lib/routes-ui.js +7 -147
- package/lib/schemas.js +171 -11
- package/lib/ui-routes/account-routes.js +6 -1
- package/lib/ui-routes/oauth-routes.js +18 -172
- package/package.json +19 -19
- package/sbom.json +1 -1
- package/static/licenses.html +23 -83
- package/translations/messages.pot +71 -71
- package/views/config/oauth/edit.hbs +2 -0
- package/views/config/oauth/index.hbs +2 -1
- package/views/config/oauth/new.hbs +2 -0
- package/views/partials/oauth_form.hbs +179 -4
- package/views/partials/scope_info.hbs +10 -0
- package/workers/export.js +6 -2
- package/workers/imap.js +3 -2
|
@@ -80,3 +80,7 @@ jobs:
|
|
|
80
80
|
GMAIL_SENDONLY_CLIENT_SECRET: ${{ secrets.GMAIL_SENDONLY_CLIENT_SECRET }}
|
|
81
81
|
GMAIL_SENDONLY_ACCOUNT_EMAIL: ${{ secrets.GMAIL_SENDONLY_ACCOUNT_EMAIL }}
|
|
82
82
|
GMAIL_SENDONLY_ACCOUNT_REFRESH: ${{ secrets.GMAIL_SENDONLY_ACCOUNT_REFRESH }}
|
|
83
|
+
OUTLOOK_SERVICE_CLIENT_ID: ${{ secrets.OUTLOOK_SERVICE_CLIENT_ID }}
|
|
84
|
+
OUTLOOK_SERVICE_TENANT_ID: ${{ secrets.OUTLOOK_SERVICE_TENANT_ID }}
|
|
85
|
+
OUTLOOK_SERVICE_CLIENT_SECRET: ${{ secrets.OUTLOOK_SERVICE_CLIENT_SECRET }}
|
|
86
|
+
OUTLOOK_SERVICE_ACCOUNT_EMAIL: ${{ secrets.OUTLOOK_SERVICE_ACCOUNT_EMAIL }}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.65.0](https://github.com/postalsys/emailengine/compare/v2.64.0...v2.65.0) (2026-03-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add Outlook Service (client_credentials) provider for app-only Microsoft 365 access ([#587](https://github.com/postalsys/emailengine/issues/587)) ([5f906cd](https://github.com/postalsys/emailengine/commit/5f906cd540564afe2dc2024b4e17c2d5d9483ed2))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* fix export TTL expiration, cancellation cleanup, and expose truncated field ([019c05c](https://github.com/postalsys/emailengine/commit/019c05c941842ae28668711e8f5c2e8a85d3b84b))
|
|
14
|
+
* include failed jobs in /v1/outbox response ([b393676](https://github.com/postalsys/emailengine/commit/b393676f2f877046d27b06d518a03dd256d5b7d9))
|
|
15
|
+
* switch pkg targets from node22 to node24 to fix Windows builds ([12522a7](https://github.com/postalsys/emailengine/commit/12522a7b92d72684af49cd70c1c6d4bcafe1d4af))
|
|
16
|
+
|
|
3
17
|
## [2.64.0](https://github.com/postalsys/emailengine/compare/v2.63.4...v2.64.0) (2026-03-16)
|
|
4
18
|
|
|
5
19
|
|
package/copy-static-files.sh
CHANGED
|
@@ -24,7 +24,7 @@ cp node_modules/ace-builds/src-min/ext-searchbox.js static/js/ace/ext-searchbox.
|
|
|
24
24
|
|
|
25
25
|
cp node_modules/\@postalsys/ee-client/index.js static/js/ee-client.js
|
|
26
26
|
|
|
27
|
-
wget https://developers.google.com/static/
|
|
27
|
+
wget https://developers.google.com/static/crawling/ipranges/special-crawlers.json -O data/google-crawlers.json
|
|
28
28
|
node -e 'console.log("Google crawlers updated: "+require("./data/google-crawlers.json").creationTime);'
|
|
29
29
|
|
|
30
30
|
# brew install gh
|
package/lib/account.js
CHANGED
|
@@ -18,7 +18,7 @@ const { MessageChannel } = require('worker_threads');
|
|
|
18
18
|
const { MessagePortReadable } = require('./message-port-stream');
|
|
19
19
|
const { deepStrictEqual, strictEqual } = require('assert');
|
|
20
20
|
const { encrypt, decrypt } = require('./encrypt');
|
|
21
|
-
const { oauth2Apps, LEGACY_KEYS } = require('./oauth2-apps');
|
|
21
|
+
const { oauth2Apps, LEGACY_KEYS, isApiBasedApp } = require('./oauth2-apps');
|
|
22
22
|
const settings = require('./settings');
|
|
23
23
|
const redisScanDelete = require('./redis-scan-delete');
|
|
24
24
|
const { customAlphabet } = require('nanoid');
|
|
@@ -175,7 +175,7 @@ class Account {
|
|
|
175
175
|
oauthApps.set(accountData.oauth2.provider, app || null);
|
|
176
176
|
if (app) {
|
|
177
177
|
accountData.type = app.provider;
|
|
178
|
-
if (app
|
|
178
|
+
if (isApiBasedApp(app)) {
|
|
179
179
|
accountData.isApi = true;
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -203,7 +203,7 @@ class Account {
|
|
|
203
203
|
app = await oauth2Apps.get(delegatedAccountData.oauth2.provider);
|
|
204
204
|
}
|
|
205
205
|
oauthApps.set(delegatedAccountData.oauth2.provider, app || null);
|
|
206
|
-
if (app
|
|
206
|
+
if (isApiBasedApp(app)) {
|
|
207
207
|
accountData.isApi = true;
|
|
208
208
|
}
|
|
209
209
|
}
|
|
@@ -686,7 +686,7 @@ class Account {
|
|
|
686
686
|
if (accountData.oauth2 && accountData.oauth2.provider) {
|
|
687
687
|
let app = await oauth2Apps.get(accountData.oauth2.provider);
|
|
688
688
|
if (app) {
|
|
689
|
-
if (app
|
|
689
|
+
if (isApiBasedApp(app)) {
|
|
690
690
|
accountData.isApi = true;
|
|
691
691
|
}
|
|
692
692
|
|
|
@@ -709,7 +709,7 @@ class Account {
|
|
|
709
709
|
let app = await oauth2Apps.get(delegatedAccountData.oauth2.provider);
|
|
710
710
|
if (app) {
|
|
711
711
|
accountData._app = app;
|
|
712
|
-
if (app
|
|
712
|
+
if (isApiBasedApp(app)) {
|
|
713
713
|
accountData.isApi = true;
|
|
714
714
|
}
|
|
715
715
|
}
|
|
@@ -2580,7 +2580,7 @@ class Account {
|
|
|
2580
2580
|
let delegatedAccountData = this.unserializeAccountData(delegatedAccountRow);
|
|
2581
2581
|
if (delegatedAccountData?.oauth2?.provider) {
|
|
2582
2582
|
let app = await oauth2Apps.get(delegatedAccountData.oauth2.provider);
|
|
2583
|
-
return app
|
|
2583
|
+
return isApiBasedApp(app);
|
|
2584
2584
|
} else {
|
|
2585
2585
|
return false;
|
|
2586
2586
|
}
|
|
@@ -2593,7 +2593,7 @@ class Account {
|
|
|
2593
2593
|
|
|
2594
2594
|
if (accountData.oauth2?.provider) {
|
|
2595
2595
|
let app = await oauth2Apps.get(accountData.oauth2.provider);
|
|
2596
|
-
return app
|
|
2596
|
+
return isApiBasedApp(app);
|
|
2597
2597
|
}
|
|
2598
2598
|
return false;
|
|
2599
2599
|
}
|
|
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
|
|
4
4
|
const { redis } = require('../db');
|
|
5
5
|
const { Account } = require('../account');
|
|
6
6
|
const getSecret = require('../get-secret');
|
|
7
|
-
const { oauth2Apps } = require('../oauth2-apps');
|
|
7
|
+
const { oauth2Apps, SERVICE_ACCOUNT_PROVIDERS } = require('../oauth2-apps');
|
|
8
8
|
const Boom = require('@hapi/boom');
|
|
9
9
|
const Joi = require('joi');
|
|
10
10
|
const { failAction } = require('../tools');
|
|
@@ -67,6 +67,12 @@ async function init(args) {
|
|
|
67
67
|
// redirect to OAuth2 consent screen
|
|
68
68
|
|
|
69
69
|
const oAuth2Client = await oauth2Apps.getClient(request.payload.oauth2.provider);
|
|
70
|
+
|
|
71
|
+
// Service providers use client_credentials - no interactive authorization
|
|
72
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(oAuth2Client.provider)) {
|
|
73
|
+
throw Boom.badRequest('Application-only OAuth providers do not support interactive authorization');
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
const nonce = crypto.randomBytes(NONCE_BYTES).toString('base64url');
|
|
71
77
|
|
|
72
78
|
const accountData = request.payload;
|
|
@@ -94,7 +94,7 @@ async function request(context, url, method, payload, options = {}) {
|
|
|
94
94
|
// Track successful API request
|
|
95
95
|
metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
|
|
96
96
|
status: 'success',
|
|
97
|
-
provider: 'outlook',
|
|
97
|
+
provider: context.oAuth2Client?.provider || 'outlook',
|
|
98
98
|
statusCode: '200'
|
|
99
99
|
});
|
|
100
100
|
} catch (err) {
|
|
@@ -102,7 +102,7 @@ async function request(context, url, method, payload, options = {}) {
|
|
|
102
102
|
const statusCode = String(err.oauthRequest?.status || 0);
|
|
103
103
|
metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
|
|
104
104
|
status: 'failure',
|
|
105
|
-
provider: 'outlook',
|
|
105
|
+
provider: context.oAuth2Client?.provider || 'outlook',
|
|
106
106
|
statusCode
|
|
107
107
|
});
|
|
108
108
|
|
|
@@ -215,18 +215,24 @@ class OutlookClient extends BaseClient {
|
|
|
215
215
|
await this.setStateVal();
|
|
216
216
|
|
|
217
217
|
await this.getAccount();
|
|
218
|
-
await this.prepareDelegatedAccount();
|
|
219
218
|
await this.getClient(true);
|
|
219
|
+
await this.prepareDelegatedAccount();
|
|
220
220
|
|
|
221
221
|
let accountData = await this.accountObject.loadAccountData();
|
|
222
222
|
|
|
223
223
|
// Check if send-only mode
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// scope changes become common.
|
|
224
|
+
// For outlookService (client_credentials), app permissions are configured in Entra.
|
|
225
|
+
// The .default scope doesn't enumerate individual permissions, so assume full access.
|
|
226
|
+
// For delegated accounts, scopes are checked at initialization and after account updates.
|
|
228
227
|
const scopes = accountData.oauth2?.accessToken?.scope || accountData.oauth2?.scope || [];
|
|
229
|
-
|
|
228
|
+
let hasSendScope, hasReadScope;
|
|
229
|
+
if (this.oAuth2Client?.useClientCredentials) {
|
|
230
|
+
hasSendScope = true;
|
|
231
|
+
hasReadScope = true;
|
|
232
|
+
} else {
|
|
233
|
+
({ hasSendScope, hasReadScope } = checkAccountScopes('outlook', scopes, this.logger));
|
|
234
|
+
}
|
|
235
|
+
|
|
230
236
|
const isSendOnly = hasSendScope && !hasReadScope;
|
|
231
237
|
|
|
232
238
|
this.logger.debug({
|
|
@@ -270,7 +276,12 @@ class OutlookClient extends BaseClient {
|
|
|
270
276
|
}
|
|
271
277
|
|
|
272
278
|
// Update username if it has changed (e.g., after email address change)
|
|
273
|
-
if (
|
|
279
|
+
if (
|
|
280
|
+
profileRes.userPrincipalName &&
|
|
281
|
+
accountData.oauth2.auth?.user !== profileRes.userPrincipalName &&
|
|
282
|
+
!accountData.oauth2.auth?.delegatedUser &&
|
|
283
|
+
!this.oAuth2Client?.useClientCredentials
|
|
284
|
+
) {
|
|
274
285
|
updates.oauth2 = {
|
|
275
286
|
partial: true,
|
|
276
287
|
auth: Object.assign(accountData.oauth2.auth || {}, {
|
|
@@ -2776,6 +2787,20 @@ class OutlookClient extends BaseClient {
|
|
|
2776
2787
|
|
|
2777
2788
|
let accountData = await this.accountObject.loadAccountData();
|
|
2778
2789
|
|
|
2790
|
+
// For outlookService (client_credentials), always use /users/{email} path
|
|
2791
|
+
if (this.oAuth2Client?.useClientCredentials) {
|
|
2792
|
+
let email = accountData.oauth2?.auth?.delegatedUser || accountData.oauth2?.auth?.user;
|
|
2793
|
+
if (email) {
|
|
2794
|
+
this.oauth2UserPath = `users/${encodeURIComponent(email)}`;
|
|
2795
|
+
} else {
|
|
2796
|
+
let err = new Error('Application credentials require a target user email address');
|
|
2797
|
+
err.code = 'MissingTargetUser';
|
|
2798
|
+
err.authenticationFailed = true;
|
|
2799
|
+
throw err;
|
|
2800
|
+
}
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2779
2804
|
if (accountData?.oauth2?.auth?.delegatedUser && accountData?.oauth2?.auth?.delegatedAccount) {
|
|
2780
2805
|
await this.getDelegatedAccount(accountData);
|
|
2781
2806
|
if (this.delegatedAccountObject) {
|
|
@@ -2805,13 +2830,21 @@ class OutlookClient extends BaseClient {
|
|
|
2805
2830
|
|
|
2806
2831
|
// Track successful token refresh (only if token was actually refreshed, not cached)
|
|
2807
2832
|
if (!tokenData.cached) {
|
|
2808
|
-
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2833
|
+
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2834
|
+
status: 'success',
|
|
2835
|
+
provider: this.oAuth2Client?.provider || 'outlook',
|
|
2836
|
+
statusCode: '200'
|
|
2837
|
+
});
|
|
2809
2838
|
}
|
|
2810
2839
|
} catch (E) {
|
|
2811
2840
|
if (E.code === 'ETokenRefresh') {
|
|
2812
2841
|
// Track failed token refresh
|
|
2813
2842
|
const statusCode = String(E.statusCode || 0);
|
|
2814
|
-
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2843
|
+
metricsMeta({ account: this.account }, this.logger, 'oauth2TokenRefresh', 'inc', {
|
|
2844
|
+
status: 'failure',
|
|
2845
|
+
provider: this.oAuth2Client?.provider || 'outlook',
|
|
2846
|
+
statusCode
|
|
2847
|
+
});
|
|
2815
2848
|
|
|
2816
2849
|
// treat as authentication failure
|
|
2817
2850
|
this.state = 'authenticationError';
|
|
@@ -2854,8 +2887,8 @@ class OutlookClient extends BaseClient {
|
|
|
2854
2887
|
*/
|
|
2855
2888
|
async prepare() {
|
|
2856
2889
|
await this.getAccount();
|
|
2857
|
-
await this.prepareDelegatedAccount();
|
|
2858
2890
|
await this.getClient();
|
|
2891
|
+
await this.prepareDelegatedAccount();
|
|
2859
2892
|
}
|
|
2860
2893
|
|
|
2861
2894
|
/**
|
package/lib/export.js
CHANGED
|
@@ -242,6 +242,7 @@ class Export {
|
|
|
242
242
|
},
|
|
243
243
|
created: toIsoDate(data.created),
|
|
244
244
|
expiresAt: toIsoDate(data.expiresAt),
|
|
245
|
+
truncated: data.truncated === '1' || undefined,
|
|
245
246
|
error: data.error || null
|
|
246
247
|
};
|
|
247
248
|
|
|
@@ -322,6 +323,15 @@ class Export {
|
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
static async startProcessing(account, exportId) {
|
|
327
|
+
const exportKey = getExportKey(account, exportId);
|
|
328
|
+
const maxAge = await getExportMaxAge();
|
|
329
|
+
const ttl = Math.ceil(maxAge / 1000);
|
|
330
|
+
const newExpiresAt = Date.now() + maxAge;
|
|
331
|
+
|
|
332
|
+
await redis.multi().hmset(exportKey, { status: 'processing', phase: 'indexing', expiresAt: newExpiresAt }).expire(exportKey, ttl).exec();
|
|
333
|
+
}
|
|
334
|
+
|
|
325
335
|
static async queueMessage(account, exportId, messageInfo) {
|
|
326
336
|
const queueKey = getExportQueueKey(account, exportId);
|
|
327
337
|
const exportKey = getExportKey(account, exportId);
|
|
@@ -415,6 +425,13 @@ class Export {
|
|
|
415
425
|
logger.error({ msg: 'Export failed', account, exportId, error });
|
|
416
426
|
}
|
|
417
427
|
|
|
428
|
+
static async deleteFully(account, exportId) {
|
|
429
|
+
const exportKey = getExportKey(account, exportId);
|
|
430
|
+
const queueKey = getExportQueueKey(account, exportId);
|
|
431
|
+
|
|
432
|
+
await redis.multi().del(exportKey).del(queueKey).srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`).exec();
|
|
433
|
+
}
|
|
434
|
+
|
|
418
435
|
static async markInterruptedAsFailed() {
|
|
419
436
|
const activeExports = await redis.smembers(ACTIVE_EXPORTS_KEY);
|
|
420
437
|
|
|
@@ -4,7 +4,7 @@ const { parentPort } = require('worker_threads');
|
|
|
4
4
|
|
|
5
5
|
const config = require('@zone-eu/wild-config');
|
|
6
6
|
const logger = require('../logger');
|
|
7
|
-
const { oauth2Apps, oauth2ProviderData } = require('../oauth2-apps');
|
|
7
|
+
const { oauth2Apps, oauth2ProviderData, isApiBasedApp } = require('../oauth2-apps');
|
|
8
8
|
|
|
9
9
|
const { getDuration, getBoolean, resolveCredentials, hasEnvValue, readEnvValue, emitChangeEvent, loadTlsConfig } = require('../tools');
|
|
10
10
|
const { matchIp, getLocalAddress } = require('../utils/network');
|
|
@@ -214,7 +214,7 @@ async function onAuth(auth, session) {
|
|
|
214
214
|
throw respErr;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
if (accountData?._app
|
|
217
|
+
if (isApiBasedApp(accountData?._app)) {
|
|
218
218
|
let respErr = new Error('IMAP is not supported for API-based accounts');
|
|
219
219
|
respErr.authenticationFailed = true;
|
|
220
220
|
respErr.serverResponseCode = 'ACCOUNTDISABLED';
|
package/lib/oauth/outlook.js
CHANGED
|
@@ -174,7 +174,8 @@ class OutlookOauth {
|
|
|
174
174
|
this.logRaw = opts.logRaw;
|
|
175
175
|
this.logger = opts.logger;
|
|
176
176
|
|
|
177
|
-
this.
|
|
177
|
+
this.useClientCredentials = !!opts.useClientCredentials;
|
|
178
|
+
this.provider = opts.provider || 'outlook';
|
|
178
179
|
|
|
179
180
|
this.setFlag = opts.setFlag;
|
|
180
181
|
|
|
@@ -199,9 +200,18 @@ class OutlookOauth {
|
|
|
199
200
|
this.entraEndpoint = 'https://login.microsoftonline.com';
|
|
200
201
|
this.apiBase = 'https://graph.microsoft.com';
|
|
201
202
|
}
|
|
203
|
+
|
|
204
|
+
// Client credentials use a single .default scope; override whatever was set above
|
|
205
|
+
if (this.useClientCredentials) {
|
|
206
|
+
this.scopes = [`${this.apiBase}/.default`];
|
|
207
|
+
}
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
generateAuthUrl(opts) {
|
|
211
|
+
if (this.useClientCredentials) {
|
|
212
|
+
throw new Error('Client credentials flow does not support interactive authorization');
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
opts = opts || {};
|
|
206
216
|
|
|
207
217
|
const url = new URL(`${this.entraEndpoint}/${this.authority}/oauth2/v2.0/authorize`);
|
|
@@ -335,6 +345,10 @@ class OutlookOauth {
|
|
|
335
345
|
}
|
|
336
346
|
|
|
337
347
|
async refreshToken(opts) {
|
|
348
|
+
if (this.useClientCredentials) {
|
|
349
|
+
return this.getClientCredentialsToken();
|
|
350
|
+
}
|
|
351
|
+
|
|
338
352
|
opts = opts || {};
|
|
339
353
|
if (!opts.refreshToken) {
|
|
340
354
|
throw new Error('Refresh token not provided');
|
|
@@ -435,6 +449,90 @@ class OutlookOauth {
|
|
|
435
449
|
return responseJson;
|
|
436
450
|
}
|
|
437
451
|
|
|
452
|
+
async getClientCredentialsToken() {
|
|
453
|
+
const url = new URL(`${this.entraEndpoint}/${this.authority}/oauth2/v2.0/token`);
|
|
454
|
+
|
|
455
|
+
url.searchParams.set('client_id', this.clientId);
|
|
456
|
+
url.searchParams.set('client_secret', this.clientSecret);
|
|
457
|
+
url.searchParams.set('scope', this.scopes.join(' '));
|
|
458
|
+
url.searchParams.set('grant_type', 'client_credentials');
|
|
459
|
+
|
|
460
|
+
let requestUrl = url.origin + url.pathname;
|
|
461
|
+
let method = 'post';
|
|
462
|
+
|
|
463
|
+
const bodyString = url.searchParams.toString();
|
|
464
|
+
let res = await fetchCmd(requestUrl, {
|
|
465
|
+
method,
|
|
466
|
+
headers: {
|
|
467
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
468
|
+
'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
|
|
469
|
+
},
|
|
470
|
+
body: bodyString,
|
|
471
|
+
dispatcher: httpAgent.retry
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
let responseJson;
|
|
475
|
+
try {
|
|
476
|
+
responseJson = await res.json();
|
|
477
|
+
} catch (err) {
|
|
478
|
+
if (this.logger) {
|
|
479
|
+
this.logger.error({ msg: 'Failed to retrieve JSON', err });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (this.logger) {
|
|
484
|
+
this.logger.info({
|
|
485
|
+
msg: 'OAuth2 authentication request',
|
|
486
|
+
action: 'oauth2Fetch',
|
|
487
|
+
fn: 'getClientCredentialsToken',
|
|
488
|
+
method,
|
|
489
|
+
url: requestUrl,
|
|
490
|
+
success: !!res.ok,
|
|
491
|
+
status: res.status,
|
|
492
|
+
request: formatFetchBody(url.searchParams, this.logRaw),
|
|
493
|
+
response: formatFetchResponse(responseJson, this.logRaw)
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (!res.ok) {
|
|
498
|
+
let err = new Error('Token request failed');
|
|
499
|
+
err.code = 'ETokenRefresh';
|
|
500
|
+
|
|
501
|
+
err.statusCode = res.status;
|
|
502
|
+
err.tokenRequest = {
|
|
503
|
+
url: requestUrl,
|
|
504
|
+
method,
|
|
505
|
+
authority: this.authority,
|
|
506
|
+
grant: 'client_credentials',
|
|
507
|
+
provider: this.provider,
|
|
508
|
+
status: res.status,
|
|
509
|
+
clientId: this.clientId,
|
|
510
|
+
scopes: this.scopes
|
|
511
|
+
};
|
|
512
|
+
try {
|
|
513
|
+
err.tokenRequest.response = responseJson || { error: 'Failed to parse response' };
|
|
514
|
+
|
|
515
|
+
if (EXPOSE_PARTIAL_SECRET_KEY_REGEX.test(err.tokenRequest.response?.error_description)) {
|
|
516
|
+
err.tokenRequest.clientSecret = formatPartialSecretKey(this.clientSecret);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let flag = checkForFlags(err.tokenRequest.response);
|
|
520
|
+
if (flag) {
|
|
521
|
+
await this.setFlag(flag);
|
|
522
|
+
}
|
|
523
|
+
} catch (e) {
|
|
524
|
+
// ignore
|
|
525
|
+
}
|
|
526
|
+
err.message = formatTokenError(this.provider, err.tokenRequest);
|
|
527
|
+
throw err;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// clear potential auth flag
|
|
531
|
+
await this.setFlag();
|
|
532
|
+
|
|
533
|
+
return responseJson;
|
|
534
|
+
}
|
|
535
|
+
|
|
438
536
|
async request(accessToken, url, method, payload, options) {
|
|
439
537
|
options = options || {};
|
|
440
538
|
|
package/lib/oauth2-apps.js
CHANGED
|
@@ -49,10 +49,14 @@ const PUBSUB_PERM_VIEW = 'Service client does not have permission to view Pub/Su
|
|
|
49
49
|
const OAUTH_PROVIDERS = {
|
|
50
50
|
gmail: 'Gmail',
|
|
51
51
|
gmailService: 'Gmail Service Accounts',
|
|
52
|
-
outlook: 'Outlook',
|
|
52
|
+
outlook: 'Outlook (delegated)',
|
|
53
|
+
outlookService: 'Outlook (application)',
|
|
53
54
|
mailRu: 'Mail.ru'
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
// Providers that use app-only credentials (no interactive user login)
|
|
58
|
+
const SERVICE_ACCOUNT_PROVIDERS = new Set(['gmailService', 'outlookService']);
|
|
59
|
+
|
|
56
60
|
const lock = new Lock({
|
|
57
61
|
redis,
|
|
58
62
|
namespace: 'ee'
|
|
@@ -100,6 +104,7 @@ function oauth2ProviderData(provider, selector) {
|
|
|
100
104
|
};
|
|
101
105
|
break;
|
|
102
106
|
|
|
107
|
+
case 'outlookService':
|
|
103
108
|
case 'outlook':
|
|
104
109
|
{
|
|
105
110
|
let imapHost = 'outlook.office365.com';
|
|
@@ -121,8 +126,12 @@ function oauth2ProviderData(provider, selector) {
|
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
providerData.icon = 'fab fa-microsoft';
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
if (provider === 'outlook') {
|
|
130
|
+
providerData.tutorialUrl = 'https://emailengine.app/outlook-and-ms-365';
|
|
131
|
+
providerData.linkImage = '/static/providers/ms_light.svg';
|
|
132
|
+
} else {
|
|
133
|
+
providerData.tutorialUrl = 'https://learn.emailengine.app/docs/accounts/outlook-client-credentials';
|
|
134
|
+
}
|
|
126
135
|
providerData.imap = {
|
|
127
136
|
host: imapHost,
|
|
128
137
|
port: 993,
|
|
@@ -402,7 +411,7 @@ class OAuth2AppsHandler {
|
|
|
402
411
|
|
|
403
412
|
response.apps.forEach(app => {
|
|
404
413
|
app.includeInListing = !!app.enabled;
|
|
405
|
-
if (
|
|
414
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(app.provider)) {
|
|
406
415
|
// service accounts are always enabled
|
|
407
416
|
app.enabled = true;
|
|
408
417
|
app.includeInListing = false;
|
|
@@ -493,7 +502,7 @@ class OAuth2AppsHandler {
|
|
|
493
502
|
extraScopes,
|
|
494
503
|
skipScopes,
|
|
495
504
|
|
|
496
|
-
name: 'Outlook',
|
|
505
|
+
name: 'Outlook (delegated)',
|
|
497
506
|
description: 'Legacy OAuth2 app',
|
|
498
507
|
|
|
499
508
|
meta: {
|
|
@@ -615,7 +624,7 @@ class OAuth2AppsHandler {
|
|
|
615
624
|
// legacy
|
|
616
625
|
let data = await this.getLegacyApp(id);
|
|
617
626
|
data.includeInListing = !!data.enabled;
|
|
618
|
-
if (
|
|
627
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(data.provider)) {
|
|
619
628
|
// service account are always enabled
|
|
620
629
|
data.enabled = true;
|
|
621
630
|
data.includeInListing = false;
|
|
@@ -645,7 +654,7 @@ class OAuth2AppsHandler {
|
|
|
645
654
|
}
|
|
646
655
|
|
|
647
656
|
data.includeInListing = !!data.enabled;
|
|
648
|
-
if (
|
|
657
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(data.provider)) {
|
|
649
658
|
// service account are always enabled
|
|
650
659
|
data.enabled = true;
|
|
651
660
|
data.includeInListing = false;
|
|
@@ -1466,7 +1475,7 @@ class OAuth2AppsHandler {
|
|
|
1466
1475
|
let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, outlookScopes(cloud), appData.skipScopes);
|
|
1467
1476
|
|
|
1468
1477
|
if (!clientId || !clientSecret || !authority || !redirectUrl) {
|
|
1469
|
-
let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook'), { statusCode: 400 });
|
|
1478
|
+
let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook (delegated)'), { statusCode: 400 });
|
|
1470
1479
|
throw error;
|
|
1471
1480
|
}
|
|
1472
1481
|
|
|
@@ -1497,6 +1506,41 @@ class OAuth2AppsHandler {
|
|
|
1497
1506
|
);
|
|
1498
1507
|
}
|
|
1499
1508
|
|
|
1509
|
+
case 'outlookService': {
|
|
1510
|
+
let authority = appData.authority;
|
|
1511
|
+
let clientId = appData.clientId;
|
|
1512
|
+
let clientSecret = appData.clientSecret ? await this.decrypt(appData.clientSecret) : null;
|
|
1513
|
+
|
|
1514
|
+
let cloud = appData.cloud || 'global';
|
|
1515
|
+
|
|
1516
|
+
if (!clientId || !clientSecret || !authority) {
|
|
1517
|
+
let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook (application)'), { statusCode: 400 });
|
|
1518
|
+
throw error;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Client credentials use {apiBase}/.default scope; per-scope config is not applicable
|
|
1522
|
+
return new OutlookOauth(
|
|
1523
|
+
Object.assign(
|
|
1524
|
+
{
|
|
1525
|
+
authority,
|
|
1526
|
+
clientId,
|
|
1527
|
+
clientSecret,
|
|
1528
|
+
cloud,
|
|
1529
|
+
provider: 'outlookService',
|
|
1530
|
+
useClientCredentials: true,
|
|
1531
|
+
setFlag: async flag => {
|
|
1532
|
+
try {
|
|
1533
|
+
await this.setMeta(id, { authFlag: flag });
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
logger.error({ msg: 'Failed to set OAuth flag', provider: 'outlookService', err });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
},
|
|
1539
|
+
extraOpts
|
|
1540
|
+
)
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1500
1544
|
case 'mailRu': {
|
|
1501
1545
|
let clientId = appData.clientId;
|
|
1502
1546
|
let clientSecret = appData.clientSecret ? await this.decrypt(appData.clientSecret) : null;
|
|
@@ -1586,14 +1630,14 @@ class OAuth2AppsHandler {
|
|
|
1586
1630
|
let { access_token: accessToken, expires_in: expiresIn } = await client.refreshToken({ isPrincipal });
|
|
1587
1631
|
let expires = new Date(now + expiresIn * 1000);
|
|
1588
1632
|
if (!accessToken) {
|
|
1589
|
-
recordTokenMetric('failure', '
|
|
1633
|
+
recordTokenMetric('failure', appData.provider || 'unknown', '0');
|
|
1590
1634
|
return null;
|
|
1591
1635
|
}
|
|
1592
1636
|
|
|
1593
1637
|
logger.debug({ msg: 'Renewed access token for service account', app: appData.id, isPrincipal });
|
|
1594
1638
|
|
|
1595
1639
|
// Record successful token refresh
|
|
1596
|
-
recordTokenMetric('success', '
|
|
1640
|
+
recordTokenMetric('success', appData.provider || 'unknown', '200');
|
|
1597
1641
|
|
|
1598
1642
|
await this.update(
|
|
1599
1643
|
appData.id,
|
|
@@ -1609,7 +1653,7 @@ class OAuth2AppsHandler {
|
|
|
1609
1653
|
} catch (err) {
|
|
1610
1654
|
// Record failed token refresh
|
|
1611
1655
|
const statusCode = err.statusCode || err.tokenRequest?.status || 0;
|
|
1612
|
-
recordTokenMetric('failure', '
|
|
1656
|
+
recordTokenMetric('failure', appData.provider || 'unknown', statusCode);
|
|
1613
1657
|
|
|
1614
1658
|
logger.info({
|
|
1615
1659
|
msg: 'Failed to renew OAuth2 access token',
|
|
@@ -1627,10 +1671,20 @@ class OAuth2AppsHandler {
|
|
|
1627
1671
|
}
|
|
1628
1672
|
}
|
|
1629
1673
|
|
|
1674
|
+
/**
|
|
1675
|
+
* Returns true if the given OAuth2 app uses API-based access (Graph API, Gmail API)
|
|
1676
|
+
* rather than IMAP/SMTP. Service account providers always use API access.
|
|
1677
|
+
*/
|
|
1678
|
+
function isApiBasedApp(app) {
|
|
1679
|
+
return app && (app.baseScopes === 'api' || SERVICE_ACCOUNT_PROVIDERS.has(app.provider));
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1630
1682
|
module.exports = {
|
|
1631
1683
|
oauth2Apps: new OAuth2AppsHandler({ redis }),
|
|
1632
1684
|
OAUTH_PROVIDERS,
|
|
1685
|
+
SERVICE_ACCOUNT_PROVIDERS,
|
|
1633
1686
|
LEGACY_KEYS,
|
|
1634
1687
|
oauth2ProviderData,
|
|
1635
|
-
formatExtraScopes
|
|
1688
|
+
formatExtraScopes,
|
|
1689
|
+
isApiBasedApp
|
|
1636
1690
|
};
|
package/lib/outbox.js
CHANGED
|
@@ -11,7 +11,7 @@ async function list(options) {
|
|
|
11
11
|
|
|
12
12
|
let jobCounts = await submitQueue.getJobCounts();
|
|
13
13
|
|
|
14
|
-
let jobStates = ['delayed', 'paused', 'wait', 'active'];
|
|
14
|
+
let jobStates = ['delayed', 'paused', 'wait', 'active', 'failed'];
|
|
15
15
|
|
|
16
16
|
let totalJobs = jobStates.map(state => Number(jobCounts[state]) || 0).reduce((previousValue, currentValue) => previousValue + currentValue);
|
|
17
17
|
|