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.
@@ -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
 
@@ -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/search/apis/ipranges/special-crawlers.json -O data/google-crawlers.json
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2026-03-13T15:46:23.000000",
2
+ "creationTime": "2026-03-20T15:46:03.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
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.baseScopes === 'api') {
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 && app.baseScopes === 'api') {
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.baseScopes === 'api') {
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.baseScopes === 'api') {
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?.baseScopes === 'api';
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?.baseScopes === 'api';
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
- // Note: Scopes are checked at initialization and after account updates. If scopes change
225
- // during token refresh (rare - typically requires re-authorization), the account must be
226
- // reinitialized to detect the change. Consider checking scopes periodically if dynamic
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
- const { hasSendScope, hasReadScope } = checkAccountScopes('outlook', scopes, this.logger);
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 (profileRes.userPrincipalName && accountData.oauth2.auth?.user !== profileRes.userPrincipalName && !accountData.oauth2.auth?.delegatedUser) {
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', { status: 'success', provider: 'outlook', statusCode: '200' });
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', { status: 'failure', provider: 'outlook', statusCode });
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?.baseScopes === 'api') {
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';
@@ -174,7 +174,8 @@ class OutlookOauth {
174
174
  this.logRaw = opts.logRaw;
175
175
  this.logger = opts.logger;
176
176
 
177
- this.provider = 'outlook';
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
 
@@ -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
- providerData.tutorialUrl = 'https://emailengine.app/outlook-and-ms-365';
125
- providerData.linkImage = '/static/providers/ms_light.svg';
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 (['gmailService'].includes(app.provider)) {
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 (['gmailService'].includes(data.provider)) {
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 (['gmailService'].includes(data.provider)) {
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', 'gmailService', '0');
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', 'gmailService', '200');
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', 'gmailService', statusCode);
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