emailengine-app 2.63.4 → 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.
Files changed (59) hide show
  1. package/.github/workflows/test.yml +4 -0
  2. package/CHANGELOG.md +70 -0
  3. package/copy-static-files.sh +1 -1
  4. package/data/google-crawlers.json +1 -1
  5. package/eslint.config.js +2 -0
  6. package/lib/account.js +13 -9
  7. package/lib/api-routes/account-routes.js +7 -1
  8. package/lib/consts.js +17 -1
  9. package/lib/email-client/gmail/gmail-api.js +1 -12
  10. package/lib/email-client/imap-client.js +5 -3
  11. package/lib/email-client/outlook/graph-api.js +9 -15
  12. package/lib/email-client/outlook-client.js +406 -177
  13. package/lib/export.js +17 -0
  14. package/lib/imapproxy/imap-server.js +3 -2
  15. package/lib/oauth/gmail.js +12 -1
  16. package/lib/oauth/outlook.js +99 -1
  17. package/lib/oauth/pubsub/google.js +253 -85
  18. package/lib/oauth2-apps.js +620 -389
  19. package/lib/outbox.js +1 -1
  20. package/lib/routes-ui.js +193 -238
  21. package/lib/schemas.js +189 -12
  22. package/lib/ui-routes/account-routes.js +7 -2
  23. package/lib/ui-routes/admin-entities-routes.js +3 -3
  24. package/lib/ui-routes/oauth-routes.js +27 -175
  25. package/package.json +21 -21
  26. package/sbom.json +1 -1
  27. package/server.js +54 -22
  28. package/static/licenses.html +30 -90
  29. package/translations/de.mo +0 -0
  30. package/translations/de.po +54 -42
  31. package/translations/en.mo +0 -0
  32. package/translations/en.po +55 -43
  33. package/translations/et.mo +0 -0
  34. package/translations/et.po +54 -42
  35. package/translations/fr.mo +0 -0
  36. package/translations/fr.po +54 -42
  37. package/translations/ja.mo +0 -0
  38. package/translations/ja.po +54 -42
  39. package/translations/messages.pot +93 -71
  40. package/translations/nl.mo +0 -0
  41. package/translations/nl.po +54 -42
  42. package/translations/pl.mo +0 -0
  43. package/translations/pl.po +54 -42
  44. package/views/config/oauth/app.hbs +12 -0
  45. package/views/config/oauth/edit.hbs +2 -0
  46. package/views/config/oauth/index.hbs +4 -1
  47. package/views/config/oauth/new.hbs +2 -0
  48. package/views/config/oauth/subscriptions.hbs +175 -0
  49. package/views/error.hbs +4 -4
  50. package/views/partials/oauth_form.hbs +179 -4
  51. package/views/partials/oauth_tabs.hbs +8 -0
  52. package/views/partials/scope_info.hbs +10 -0
  53. package/workers/api.js +174 -96
  54. package/workers/documents.js +1 -0
  55. package/workers/export.js +6 -2
  56. package/workers/imap.js +33 -49
  57. package/workers/smtp.js +1 -0
  58. package/workers/submit.js +1 -0
  59. package/workers/webhooks.js +42 -30
@@ -1,3 +1,77 @@
1
+ {{#if activeOutlook}}
2
+ <div class="card border-left-info shadow mb-4">
3
+ <div class="card-body">
4
+ <div class="row no-gutters align-items-center">
5
+ <div class="col mr-2">
6
+ <strong>Delegated</strong> access means EmailEngine acts on behalf of a signed-in user. Each
7
+ mailbox owner must complete an interactive OAuth2 login. Use this when users manage their own
8
+ accounts or when you have access to the mailbox credentials.
9
+ {{#if providerData.tutorialUrl}}
10
+ Read about setting up {{providerData.comment}} OAuth2 project from <a
11
+ href="{{providerData.tutorialUrl}}" target="_blank" rel="noopener noreferrer"
12
+ referrerpolicy="no-referrer">here</a>.
13
+ {{/if}}
14
+ </div>
15
+ <div class="col-auto">
16
+ <i class="fas fa-info-circle fa-2x text-gray-300"></i>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ {{/if}}
22
+
23
+ {{#if activeGmailService}}
24
+ <div class="card border-left-info shadow mb-4">
25
+ <div class="card-body">
26
+ <div class="row no-gutters align-items-center">
27
+ <div class="col mr-2">
28
+ <strong>Service accounts</strong> allow EmailEngine to access Gmail mailboxes using a Google Cloud
29
+ service key, with no interactive user login required. The service account must have domain-wide
30
+ delegation enabled in Google Workspace. Use this for automated integrations where you cannot
31
+ perform interactive logins.
32
+ Accounts using service access can only be added via the
33
+ <a href="/admin/swagger#/Account/postV1Account">REST API</a>,
34
+ not through the hosted authentication form.
35
+ {{#if providerData.tutorialUrl}}
36
+ Read about setting up {{providerData.comment}} from <a
37
+ href="{{providerData.tutorialUrl}}" target="_blank" rel="noopener noreferrer"
38
+ referrerpolicy="no-referrer">here</a>.
39
+ {{/if}}
40
+ </div>
41
+ <div class="col-auto">
42
+ <i class="fas fa-info-circle fa-2x text-gray-300"></i>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ {{/if}}
48
+
49
+ {{#if activeOutlookService}}
50
+ <div class="card border-left-info shadow mb-4">
51
+ <div class="card-body">
52
+ <div class="row no-gutters align-items-center">
53
+ <div class="col mr-2">
54
+ <strong>Application</strong> access means EmailEngine authenticates as the app itself using
55
+ client credentials, with no interactive user login required. The Entra app registration must have
56
+ <strong>application permissions</strong> (not delegated) with admin consent. Use this for
57
+ service integrations, shared mailboxes, or when you cannot perform interactive logins.
58
+ Accounts using application access can only be added via the
59
+ <a href="/admin/swagger#/Account/postV1Account">REST API</a>,
60
+ not through the hosted authentication form.
61
+ {{#if providerData.tutorialUrl}}
62
+ Read about setting up {{providerData.comment}} from <a
63
+ href="{{providerData.tutorialUrl}}" target="_blank" rel="noopener noreferrer"
64
+ referrerpolicy="no-referrer">here</a>.
65
+ {{/if}}
66
+ </div>
67
+ <div class="col-auto">
68
+ <i class="fas fa-info-circle fa-2x text-gray-300"></i>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ {{/if}}
74
+
1
75
  <div class="card mb-4">
2
76
 
3
77
  <div class="card-body">
@@ -42,7 +116,7 @@
42
116
  <small class="form-text text-muted">Optional application description or a comment.</small>
43
117
  </div>
44
118
 
45
- {{#unless activeGmailService}}
119
+ {{#unless activeGmailService}}{{#unless activeOutlookService}}
46
120
  <div class="form-group">
47
121
 
48
122
  <label for="title">
@@ -63,7 +137,7 @@
63
137
  <small class="form-text text-muted">Optional display title next to the application button on the account
64
138
  type selection page.</small>
65
139
  </div>
66
- {{/unless}}
140
+ {{/unless}}{{/unless}}
67
141
 
68
142
 
69
143
  {{#if activeGmail}}
@@ -82,7 +156,7 @@
82
156
  </div>
83
157
  {{/if}}
84
158
 
85
- {{#unless activeGmailService}}
159
+ {{#unless activeGmailService}}{{#unless activeOutlookService}}
86
160
  <div class="form-group form-check">
87
161
 
88
162
  <input type="checkbox" class="form-check-input {{#if errors.enabled}}is-invalid{{/if}}" id="enabled"
@@ -94,7 +168,7 @@
94
168
  <small class="form-text text-muted">If enabled, then this OAuth2 app is shown as an
95
169
  account type option in the hosted authentication form.</small>
96
170
  </div>
97
- {{/unless}}
171
+ {{/unless}}{{/unless}}
98
172
 
99
173
  </div>
100
174
  </div>
@@ -175,6 +249,66 @@
175
249
  {{/if}}
176
250
  </div>
177
251
 
252
+ {{else if activeOutlookService}}
253
+
254
+ <div class="form-group">
255
+
256
+ <label for="clientId">
257
+ Azure Application Id
258
+ </label>
259
+ <input type="text" class="form-control {{#if errors.clientId}}is-invalid{{/if}}" id="clientId"
260
+ name="clientId" value="{{values.clientId}}" placeholder="Enter application (client) ID&mldr;" required />
261
+ {{#if errors.clientId}}
262
+ <span class="invalid-feedback">{{errors.clientId}}</span>
263
+ {{/if}}
264
+ <small class="form-text text-muted">The Application (client) ID from your Entra app registration.</small>
265
+ </div>
266
+
267
+ <div class="form-group">
268
+
269
+ <label for="clientSecret">Client Secret</label>
270
+
271
+ <input type="text" class="form-control {{#if errors.clientSecret}}is-invalid{{/if}}" id="clientSecret"
272
+ name="clientSecret" value="{{values.clientSecret}}" {{#if hasClientSecret}}
273
+ placeholder="Client secret is set but not shown&mldr;" {{else}} placeholder="Enter client secret&mldr;"
274
+ {{/if}} {{#unless hasClientSecret}}required{{/unless}} />
275
+ {{#if errors.clientSecret}}
276
+ <span class="invalid-feedback">{{errors.clientSecret}}</span>
277
+ {{/if}}
278
+ <small class="form-text text-muted">Client secret value from Certificates &amp; secrets.</small>
279
+ </div>
280
+
281
+ <div class="form-group">
282
+ <label for="cloud">Azure cloud environment</label>
283
+ <select class="custom-select custom-select-sm {{#if errors.cloud}}is-invalid{{/if}}" id="cloud" name="cloud"
284
+ required>
285
+
286
+ {{#each azureClouds}}
287
+ <option value="{{id}}" {{#if selected}}selected{{/if}}>{{name}}{{#if
288
+ description}} &mdash;
289
+ {{description}}{{/if}}</option>
290
+ {{/each}}
291
+ </select>
292
+ {{#if errors.cloud}}
293
+ <span class="invalid-feedback">{{errors.cloud}}</span>
294
+ {{/if}}
295
+ <small class="form-text text-muted">Accounts hosted in different cloud environments use different OAuth2
296
+ endpoints.</small>
297
+ </div>
298
+
299
+ <div class="form-group">
300
+ <label for="authority">Directory (tenant) ID</label>
301
+
302
+ <input type="text" class="form-control {{#if errors.authority}}is-invalid{{/if}}" id="authority"
303
+ name="authority" value="{{values.authority}}"
304
+ placeholder="Enter tenant ID, eg. &quot;f8cdef31-a31e-4b4a-93e4-5f571e91255a&quot;" required />
305
+ {{#if errors.authority}}
306
+ <span class="invalid-feedback">{{errors.authority}}</span>
307
+ {{/if}}
308
+ <small class="form-text text-muted">The Directory (tenant) ID from your Entra app registration. Client
309
+ credentials flow requires a specific tenant ID.</small>
310
+ </div>
311
+
178
312
  {{else}}
179
313
 
180
314
  {{#if activeGmail}}
@@ -702,6 +836,47 @@
702
836
 
703
837
  {{/if}}
704
838
 
839
+ {{#if activeOutlookService}}
840
+
841
+ <div class="card mb-4">
842
+
843
+ <div class="card-header py-3">
844
+ <h6 class="m-0 font-weight-bold text-primary">Connection type</h6>
845
+ </div>
846
+
847
+ <div class="card-body">
848
+ <input type="hidden" name="baseScopes" value="api" />
849
+ <p><strong>MS Graph API</strong> &mdash; This app uses Microsoft Graph API with application permissions
850
+ (client credentials). Requires <code>Mail.ReadWrite</code>, <code>Mail.Send</code>, and
851
+ <code>User.Read.All</code> application permissions with admin consent in Entra.</p>
852
+ </div>
853
+
854
+ <div class="card-footer">
855
+
856
+ <p>Microsoft Graph delivers change notifications to EmailEngine at these two endpoints:</p>
857
+
858
+ <pre><code>{{mainServiceUrl}}/oauth/msg/lifecycle
859
+ {{mainServiceUrl}}/oauth/msg/notification</code></pre>
860
+
861
+ <p>
862
+ Both endpoints must be publicly reachable for anonymous HTTPS
863
+ <strong>POST</strong> requests. When a notification arrives, EmailEngine
864
+ immediately forwards the event to your application through its own webhooks.
865
+ </p>
866
+
867
+ <p>
868
+ If your EmailEngine instance cannot be exposed directly, configure a public
869
+ proxy domain in <span class="text-muted code-link"><a href="/admin/swagger#/Settings/postV1Settings"
870
+ target="_blank" rel="noopener noreferrer">notificationBaseUrl</a></span>. Microsoft Graph will then
871
+ post to <code>https://&lt;proxy-domain&gt;/oauth/msg/...</code> instead of
872
+ <code>{{mainServiceUrl}}</code>.
873
+ </p>
874
+
875
+ </div>
876
+ </div>
877
+
878
+ {{/if}}
879
+
705
880
  {{#if activeGmail}}
706
881
  <div class="card mb-4 {{#unless baseScopesApi}}d-none{{/unless}}" id="account-type-card-gmail">
707
882
  <div class="card-header py-3">
@@ -0,0 +1,8 @@
1
+ <ul class="nav nav-pills mb-4">
2
+ <li class="nav-item">
3
+ <a class="nav-link {{#if activeApplications}}active{{/if}}" href="/admin/config/oauth">Applications</a>
4
+ </li>
5
+ <li class="nav-item">
6
+ <a class="nav-link {{#if activeSubscriptions}}active{{/if}}" href="/admin/config/oauth/subscriptions">Gmail Subscriptions</a>
7
+ </li>
8
+ </ul>
@@ -119,6 +119,16 @@
119
119
  </div>
120
120
  {{/if}}
121
121
 
122
+ {{#if activeOutlookService}}
123
+ <div>Your Entra app registration <strong>must</strong> have the following <strong>application permissions</strong> (not
124
+ delegated) with admin consent:</div>
125
+ <ul>
126
+ <li><code>"Mail.ReadWrite"</code></li>
127
+ <li><code>"Mail.Send"</code></li>
128
+ <li><code>"User.Read.All"</code></li>
129
+ </ul>
130
+ {{/if}}
131
+
122
132
  {{#if activeMailRu}}
123
133
  <div>Your OAuth2 project <strong>must</strong> have the following scopes enabled:</div>
124
134
  <ul>
package/workers/api.js CHANGED
@@ -163,7 +163,8 @@ const {
163
163
  googleSubscriptionNameSchema,
164
164
  messageReferenceSchema,
165
165
  idempotencyKeySchema,
166
- headerTimeoutSchema
166
+ headerTimeoutSchema,
167
+ pubSubErrorSchema
167
168
  } = require('../lib/schemas');
168
169
 
169
170
  const OAuth2ProviderSchema = Joi.string()
@@ -180,6 +181,21 @@ const AccountTypeSchema = Joi.string()
180
181
  .required()
181
182
  .label('AccountType');
182
183
 
184
+ function flattenOAuthAppMeta(app) {
185
+ if (!app.meta) {
186
+ return;
187
+ }
188
+ let authFlag = app.meta.authFlag;
189
+ let pubSubFlag = app.meta.pubSubFlag;
190
+ delete app.meta;
191
+ if (authFlag && authFlag.message) {
192
+ app.lastError = { response: authFlag.message };
193
+ }
194
+ if (pubSubFlag && pubSubFlag.message) {
195
+ app.pubSubError = { message: pubSubFlag.message, description: pubSubFlag.description || null };
196
+ }
197
+ }
198
+
183
199
  const SUPPORTED_LOCALES = locales.map(locale => locale.locale);
184
200
 
185
201
  const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash'];
@@ -362,6 +378,7 @@ async function call(message, transferList) {
362
378
  err.statusCode = 504;
363
379
  err.code = 'Timeout';
364
380
  err.ttl = ttl;
381
+ callQueue.delete(mid);
365
382
  reject(err);
366
383
  }, ttl);
367
384
 
@@ -1557,11 +1574,12 @@ Include your token in requests using one of these methods:
1557
1574
  let checkKey = `${REDIS_PREFIX}test:${Date.now()}`;
1558
1575
  let expected = crypto.randomBytes(8).toString('hex');
1559
1576
  let res = await redis.multi().set(checkKey, expected).get(checkKey).del(checkKey).exec();
1560
- if (res[1] && res[1][1] === expected && res[2] && res[2][1] === 1) {
1561
- return { success: true };
1577
+ if (!(res[1] && res[1][1] === expected && res[2] && res[2][1] === 1)) {
1578
+ let error = Boom.boomify(new Error('Database check failed'), { statusCode: 500 });
1579
+ throw error;
1562
1580
  }
1563
- let error = Boom.boomify(new Error('Database check failed'), { statusCode: 500 });
1564
- throw error;
1581
+
1582
+ return { success: true };
1565
1583
  },
1566
1584
  options: {
1567
1585
  description: 'Health check',
@@ -1570,6 +1588,99 @@ Include your token in requests using one of these methods:
1570
1588
  }
1571
1589
  });
1572
1590
 
1591
+ server.route({
1592
+ method: 'GET',
1593
+ path: '/v1/pubsub/status',
1594
+
1595
+ async handler(request) {
1596
+ try {
1597
+ let response = await oauth2Apps.list(request.query.page, request.query.pageSize, { pubsub: true });
1598
+
1599
+ let apps = response.apps.map(app => {
1600
+ flattenOAuthAppMeta(app);
1601
+ return { id: app.id, name: app.name || null, lastError: app.lastError || null, pubSubError: app.pubSubError || null };
1602
+ });
1603
+
1604
+ return {
1605
+ total: response.total,
1606
+ page: response.page,
1607
+ pages: response.pages,
1608
+ apps
1609
+ };
1610
+ } catch (err) {
1611
+ request.logger.error({ msg: 'API request failed', err });
1612
+ if (Boom.isBoom(err)) {
1613
+ throw err;
1614
+ }
1615
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
1616
+ if (err.code) {
1617
+ error.output.payload.code = err.code;
1618
+ }
1619
+ throw error;
1620
+ }
1621
+ },
1622
+
1623
+ options: {
1624
+ description: 'List Pub/Sub status',
1625
+ notes: 'Lists Pub/Sub enabled OAuth2 applications and their subscription status',
1626
+ tags: ['api', 'OAuth2 Applications'],
1627
+
1628
+ plugins: {},
1629
+
1630
+ auth: {
1631
+ strategy: 'api-token',
1632
+ mode: 'required'
1633
+ },
1634
+ cors: CORS_CONFIG,
1635
+
1636
+ validate: {
1637
+ options: {
1638
+ stripUnknown: false,
1639
+ abortEarly: false,
1640
+ convert: true
1641
+ },
1642
+ failAction,
1643
+
1644
+ query: Joi.object({
1645
+ page: Joi.number()
1646
+ .integer()
1647
+ .min(0)
1648
+ .max(1024 * 1024)
1649
+ .default(0)
1650
+ .example(0)
1651
+ .description('Page number (zero indexed, so use 0 for first page)')
1652
+ .label('PageNumber'),
1653
+ pageSize: Joi.number().integer().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize')
1654
+ }).label('PubSubStatusFilter')
1655
+ },
1656
+
1657
+ response: {
1658
+ schema: Joi.object({
1659
+ total: Joi.number().integer().example(120).description('How many matching entries').label('TotalNumber'),
1660
+ page: Joi.number().integer().example(0).description('Current page (0-based index)').label('PageNumber'),
1661
+ pages: Joi.number().integer().example(24).description('Total page count').label('PagesNumber'),
1662
+
1663
+ apps: Joi.array()
1664
+ .items(
1665
+ Joi.object({
1666
+ id: Joi.string().max(256).required().example('AAABhaBPHscAAAAH').description('OAuth2 application ID'),
1667
+ name: Joi.string().allow(null).max(256).example('My Gmail App').description('Display name for the app'),
1668
+ lastError: Joi.object({
1669
+ response: Joi.string().example('Enable the Cloud Pub/Sub API').description('Setup error message')
1670
+ })
1671
+ .allow(null)
1672
+ .description('Setup error from ensurePubsub, if any')
1673
+ .label('PubSubSetupError'),
1674
+ pubSubError: pubSubErrorSchema.allow(null)
1675
+ }).label('PubSubAppStatus')
1676
+ )
1677
+ .label('PubSubAppStatusList')
1678
+ }).label('PubSubStatusResponse'),
1679
+ failAction: 'log'
1680
+ }
1681
+ }
1682
+ });
1683
+
1573
1684
  server.route({
1574
1685
  method: 'GET',
1575
1686
  path: '/redirect',
@@ -1936,6 +2047,10 @@ Include your token in requests using one of these methods:
1936
2047
 
1937
2048
  const outlookSubscription = accountData.outlookSubscription;
1938
2049
 
2050
+ // Deduplicate lifecycle events within the same batch to prevent
2051
+ // concurrent handlers racing (e.g., two subscriptionRemoved entries)
2052
+ const seenLifecycleEvents = new Set();
2053
+
1939
2054
  for (let entry of (request.payload && request.payload.value) || []) {
1940
2055
  request.logger.debug({
1941
2056
  msg: 'MS Graph subscription event',
@@ -1960,82 +2075,43 @@ Include your token in requests using one of these methods:
1960
2075
  continue;
1961
2076
  }
1962
2077
 
1963
- switch (entry.lifecycleEvent) {
1964
- case 'reauthorizationRequired': {
1965
- // Microsoft is requesting reauthorization - force renewal immediately
1966
- request.logger.info({
1967
- msg: 'Received reauthorizationRequired lifecycle event',
1968
- subscriptionId: outlookSubscription.id,
2078
+ // Route recognized lifecycle events to the IMAP worker
2079
+ // so the live client with its OAuth state handles them
2080
+ if (entry.lifecycleEvent === 'reauthorizationRequired' || entry.lifecycleEvent === 'subscriptionRemoved') {
2081
+ const dedupeKey = `${entry.lifecycleEvent}:${entry.subscriptionId}`;
2082
+ if (seenLifecycleEvents.has(dedupeKey)) {
2083
+ request.logger.debug({
2084
+ msg: 'Skipping duplicate lifecycle event in batch',
2085
+ lifecycleEvent: entry.lifecycleEvent,
2086
+ subscriptionId: entry.subscriptionId,
1969
2087
  account: request.query.account
1970
2088
  });
1971
-
1972
- // Use the unified renewal method from OutlookClient
1973
- // We need to create a client instance to call the renewal method
1974
- const { OutlookClient } = require('../lib/email-client/outlook-client');
1975
- const client = new OutlookClient(accountData, {
1976
- redis,
1977
- secret: await getSecret(),
1978
- logger: request.logger
1979
- });
1980
-
1981
- try {
1982
- // Force renewal when we get reauthorizationRequired
1983
- const renewalResult = await client.renewSubscription(true);
1984
-
1985
- if (renewalResult.success) {
1986
- request.logger.info({
1987
- msg: 'Successfully renewed subscription from lifecycle event',
1988
- subscriptionId: outlookSubscription.id,
1989
- account: request.query.account,
1990
- newExpirationDateTime: renewalResult.expirationDateTime
1991
- });
1992
- } else {
1993
- request.logger.error({
1994
- msg: 'Failed to renew subscription from lifecycle event',
1995
- subscriptionId: outlookSubscription.id,
1996
- account: request.query.account,
1997
- reason: renewalResult.reason,
1998
- error: renewalResult.error
1999
- });
2000
- }
2001
- } catch (err) {
2002
- request.logger.error({
2003
- msg: 'Exception while renewing subscription from lifecycle event',
2004
- subscriptionId: outlookSubscription.id,
2005
- account: request.query.account,
2006
- err
2007
- });
2008
- } finally {
2009
- // Clean up client instance
2010
- if (client && typeof client.close === 'function') {
2011
- try {
2012
- await client.close();
2013
- } catch (cleanupErr) {
2014
- request.logger.debug({
2015
- msg: 'Error closing client after lifecycle renewal',
2016
- account: request.query.account,
2017
- err: cleanupErr
2018
- });
2019
- }
2020
- }
2021
- }
2022
-
2023
- break;
2089
+ continue;
2024
2090
  }
2091
+ seenLifecycleEvents.add(dedupeKey);
2025
2092
 
2026
- case 'subscriptionRemoved': {
2027
- // subscription was removed, should we recreate it?
2028
- await accountObject.update({
2029
- outlookSubscription: {
2030
- state: {
2031
- state: 'error',
2032
- error: `Subscription removed`,
2033
- time: Date.now()
2034
- }
2035
- }
2093
+ request.logger.info({
2094
+ msg: 'Received lifecycle event',
2095
+ lifecycleEvent: entry.lifecycleEvent,
2096
+ subscriptionId: outlookSubscription.id,
2097
+ account: request.query.account
2098
+ });
2099
+
2100
+ // Fire-and-forget: return HTTP 202 immediately so Microsoft
2101
+ // does not time out the lifecycle webhook delivery
2102
+ call({
2103
+ cmd: 'subscriptionLifecycle',
2104
+ account: request.query.account,
2105
+ event: entry.lifecycleEvent,
2106
+ timeout: consts.OUTLOOK_SUBSCRIPTION_LOCK_TTL
2107
+ }).catch(err => {
2108
+ request.logger.error({
2109
+ msg: 'Failed to handle lifecycle event via worker',
2110
+ account: request.query.account,
2111
+ lifecycleEvent: entry.lifecycleEvent,
2112
+ err
2036
2113
  });
2037
- break;
2038
- }
2114
+ });
2039
2115
  }
2040
2116
  }
2041
2117
 
@@ -4876,13 +4952,7 @@ Include your token in requests using one of these methods:
4876
4952
  delete app.app;
4877
4953
  }
4878
4954
 
4879
- if (app.meta) {
4880
- let authFlag = app.meta.authFlag;
4881
- delete app.meta;
4882
- if (authFlag && authFlag.message) {
4883
- app.lastError = { response: authFlag.message };
4884
- }
4885
- }
4955
+ flattenOAuthAppMeta(app);
4886
4956
  }
4887
4957
 
4888
4958
  return response;
@@ -5002,7 +5072,8 @@ Include your token in requests using one of these methods:
5002
5072
  .example('******')
5003
5073
  .description('PEM formatted service secret for 2-legged OAuth2 applications. Actual value is not revealed.'),
5004
5074
 
5005
- lastError: lastErrorSchema.allow(null)
5075
+ lastError: lastErrorSchema.allow(null),
5076
+ pubSubError: pubSubErrorSchema.allow(null)
5006
5077
  }).label('OAuth2ResponseItem')
5007
5078
  )
5008
5079
  .label('OAuth2Entries')
@@ -5035,13 +5106,7 @@ Include your token in requests using one of these methods:
5035
5106
  delete app.app;
5036
5107
  }
5037
5108
 
5038
- if (app.meta) {
5039
- let authFlag = app.meta.authFlag;
5040
- delete app.meta;
5041
- if (authFlag && authFlag.message) {
5042
- app.lastError = { response: authFlag.message };
5043
- }
5044
- }
5109
+ flattenOAuthAppMeta(app);
5045
5110
 
5046
5111
  return app;
5047
5112
  } catch (err) {
@@ -5145,7 +5210,8 @@ Include your token in requests using one of these methods:
5145
5210
  .example(12)
5146
5211
  .description('The number of accounts registered with this application. Not available for legacy apps.'),
5147
5212
 
5148
- lastError: lastErrorSchema.allow(null)
5213
+ lastError: lastErrorSchema.allow(null),
5214
+ pubSubError: pubSubErrorSchema.allow(null)
5149
5215
  }).label('ApplicationResponse'),
5150
5216
  failAction: 'log'
5151
5217
  }
@@ -5160,8 +5226,9 @@ Include your token in requests using one of these methods:
5160
5226
  try {
5161
5227
  let result = await oauth2Apps.create(request.payload);
5162
5228
 
5163
- if (result && result.pubsubUpdates && result.pubsubUpdates.pubSubSubscription) {
5229
+ if (result && result.pubsubUpdates && Object.keys(result.pubsubUpdates).length > 0) {
5164
5230
  await call({ cmd: 'googlePubSub', app: result.id });
5231
+ delete result.pubsubUpdates;
5165
5232
  }
5166
5233
 
5167
5234
  return result;
@@ -5220,8 +5287,9 @@ Include your token in requests using one of these methods:
5220
5287
  try {
5221
5288
  let result = await oauth2Apps.update(request.params.app, request.payload);
5222
5289
 
5223
- if (result && result.pubsubUpdates && result.pubsubUpdates.pubSubSubscription) {
5290
+ if (result && result.pubsubUpdates && Object.keys(result.pubsubUpdates).length > 0) {
5224
5291
  await call({ cmd: 'googlePubSub', app: result.id });
5292
+ delete result.pubsubUpdates;
5225
5293
  }
5226
5294
 
5227
5295
  return result;
@@ -5369,7 +5437,15 @@ Include your token in requests using one of these methods:
5369
5437
 
5370
5438
  async handler(request) {
5371
5439
  try {
5372
- return await oauth2Apps.del(request.params.app);
5440
+ let result = await oauth2Apps.del(request.params.app);
5441
+
5442
+ try {
5443
+ await call({ cmd: 'googlePubSubRemove', app: request.params.app });
5444
+ } catch (err) {
5445
+ request.logger.error({ msg: 'Failed to notify workers about OAuth2 app deletion', err, app: request.params.app });
5446
+ }
5447
+
5448
+ return result;
5373
5449
  } catch (err) {
5374
5450
  request.logger.error({ msg: 'API request failed', err });
5375
5451
  if (Boom.isBoom(err)) {
@@ -6942,11 +7018,13 @@ ${now}`,
6942
7018
  // Replace error with friendly HTML
6943
7019
  const error = response;
6944
7020
  const ctx = {
7021
+ statusCode: error.output.statusCode,
6945
7022
  message:
6946
7023
  error.output.statusCode === 404
6947
7024
  ? request.app.gt.gettext('Requested page not found')
6948
7025
  : (error.output && error.output.payload && error.output.payload.message) || request.app.gt.gettext('Something went wrong'),
6949
- details: error.output && error.output.payload && error.output.payload.details
7026
+ details: error.output && error.output.payload && error.output.payload.details,
7027
+ templateLocale: request.app.locale
6950
7028
  };
6951
7029
 
6952
7030
  if (error.output && error.output.payload) {
@@ -72,6 +72,7 @@ async function call(message, transferList) {
72
72
  err.statusCode = 504;
73
73
  err.code = 'Timeout';
74
74
  err.ttl = ttl;
75
+ callQueue.delete(mid);
75
76
  reject(err);
76
77
  }, ttl);
77
78
 
package/workers/export.js CHANGED
@@ -755,7 +755,7 @@ const exportWorker = new Worker(
755
755
  throw new Error('Export not found');
756
756
  }
757
757
 
758
- await Export.update(account, exportId, { status: 'processing', phase: 'indexing' });
758
+ await Export.startProcessing(account, exportId);
759
759
  await indexMessages(job, exportData);
760
760
  await Export.update(account, exportId, { phase: 'exporting' });
761
761
 
@@ -788,7 +788,11 @@ const exportWorker = new Worker(
788
788
  await fs.promises.unlink(exportData.filePath).catch(() => {});
789
789
  }
790
790
 
791
- await Export.fail(account, exportId, err.message);
791
+ if (err.code === 'ExportCancelled') {
792
+ await Export.deleteFully(account, exportId);
793
+ } else {
794
+ await Export.fail(account, exportId, err.message);
795
+ }
792
796
 
793
797
  if (err.code !== 'AccountDeleted' && err.code !== 'AccountNotFound' && err.code !== 'ExportCancelled') {
794
798
  await notify(account, EXPORT_FAILED_NOTIFY, {