emailengine-app 2.68.0 → 2.69.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 (74) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +8 -0
  4. package/.github/workflows/release.yaml +4 -0
  5. package/.github/workflows/test.yml +3 -0
  6. package/CHANGELOG.md +49 -0
  7. package/SECURITY.md +80 -0
  8. package/SECURITY.txt +27 -0
  9. package/config/default.toml +2 -0
  10. package/data/google-crawlers.json +13 -1
  11. package/lib/account.js +62 -25
  12. package/lib/api-routes/account-routes.js +493 -75
  13. package/lib/api-routes/blocklist-routes.js +337 -0
  14. package/lib/api-routes/delivery-test-routes.js +321 -0
  15. package/lib/api-routes/export-routes.js +1 -12
  16. package/lib/api-routes/gateway-routes.js +376 -0
  17. package/lib/api-routes/license-routes.js +142 -0
  18. package/lib/api-routes/mailbox-routes.js +318 -0
  19. package/lib/api-routes/message-routes.js +21 -129
  20. package/lib/api-routes/oauth2-app-routes.js +631 -0
  21. package/lib/api-routes/outbox-routes.js +173 -0
  22. package/lib/api-routes/pubsub-routes.js +98 -0
  23. package/lib/api-routes/route-helpers.js +45 -0
  24. package/lib/api-routes/settings-routes.js +331 -0
  25. package/lib/api-routes/stats-routes.js +77 -0
  26. package/lib/api-routes/submit-routes.js +472 -0
  27. package/lib/api-routes/template-routes.js +7 -55
  28. package/lib/api-routes/token-routes.js +297 -0
  29. package/lib/api-routes/webhook-route-routes.js +152 -0
  30. package/lib/email-client/gmail-client.js +14 -0
  31. package/lib/email-client/imap/mailbox.js +34 -11
  32. package/lib/email-client/imap/subconnection.js +20 -12
  33. package/lib/email-client/imap/sync-operations.js +130 -2
  34. package/lib/email-client/imap-client.js +116 -58
  35. package/lib/email-client/outlook-client.js +85 -13
  36. package/lib/export.js +60 -19
  37. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  38. package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
  39. package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
  40. package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
  41. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  42. package/lib/imapproxy/imap-server.js +92 -29
  43. package/lib/message-port-stream.js +113 -16
  44. package/lib/reject-worker-calls.js +42 -0
  45. package/lib/routes-ui.js +37 -8778
  46. package/lib/schemas.js +26 -1
  47. package/lib/tools.js +73 -0
  48. package/lib/ui-routes/account-routes.js +40 -210
  49. package/lib/ui-routes/admin-config-routes.js +913 -487
  50. package/lib/ui-routes/admin-entities-routes.js +1 -0
  51. package/lib/ui-routes/auth-routes.js +1339 -0
  52. package/lib/ui-routes/dashboard-routes.js +188 -0
  53. package/lib/ui-routes/document-store-routes.js +800 -0
  54. package/lib/ui-routes/export-routes.js +217 -0
  55. package/lib/ui-routes/internals-routes.js +354 -0
  56. package/lib/ui-routes/network-config-routes.js +759 -0
  57. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  58. package/lib/ui-routes/route-helpers.js +316 -0
  59. package/lib/ui-routes/smtp-test-routes.js +236 -0
  60. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  61. package/lib/webhook-request.js +36 -0
  62. package/package.json +17 -17
  63. package/sbom.json +1 -1
  64. package/server.js +217 -19
  65. package/static/licenses.html +52 -182
  66. package/translations/messages.pot +131 -151
  67. package/views/dashboard.hbs +7 -26
  68. package/views/internals/index.hbs +15 -0
  69. package/views/tokens/index.hbs +9 -0
  70. package/workers/api.js +198 -4401
  71. package/workers/export.js +87 -54
  72. package/workers/imap.js +29 -13
  73. package/workers/submit.js +20 -11
  74. package/workers/webhooks.js +6 -20
package/lib/schemas.js CHANGED
@@ -1285,6 +1285,23 @@ const searchSchema = Joi.object({
1285
1285
 
1286
1286
  gmailRaw: Joi.string().max(1024).example('has:attachment in:unread').description('Gmail search syntax (only works with Gmail accounts)'),
1287
1287
 
1288
+ labels: Joi.object({
1289
+ has: Joi.array()
1290
+ .items(Joi.string().max(128))
1291
+ .single()
1292
+ .description('Match messages that contain ALL of these labels/categories. Supported for Gmail and MS Graph (Outlook) accounts only.')
1293
+ .example(['Horizon'])
1294
+ .label('HasLabels'),
1295
+ not: Joi.array()
1296
+ .items(Joi.string().max(128))
1297
+ .single()
1298
+ .description('Match messages that contain NONE of these labels/categories. Supported for Gmail and MS Graph (Outlook) accounts only.')
1299
+ .example(['Horizon'])
1300
+ .label('NotLabels')
1301
+ })
1302
+ .description('Filter by Gmail labels or MS Graph (Outlook) categories. Not supported for generic IMAP accounts.')
1303
+ .label('LabelFilter'),
1304
+
1288
1305
  emailIds: Joi.array()
1289
1306
  .items(emailIdSchema)
1290
1307
  .single()
@@ -1855,6 +1872,13 @@ const ipSchema = Joi.string()
1855
1872
  })
1856
1873
  .example('127.0.0.1');
1857
1874
 
1875
+ const tokenIdSchema = Joi.string()
1876
+ .length(64)
1877
+ .hex()
1878
+ .example('1bc12baf7f0d5e51fe0a4e0eda06e1be5b8d6cc2c66b95dc0fbe4d2e9f5d5e1a')
1879
+ .description('Token identifier (SHA-256 hash of the token)')
1880
+ .label('TokenId');
1881
+
1858
1882
  const accountCountersSchema = Joi.object({
1859
1883
  events: Joi.object()
1860
1884
  .unknown()
@@ -2011,7 +2035,7 @@ const exportRequestSchema = Joi.object({
2011
2035
  .valid('plain', 'html', '*')
2012
2036
  .default('*')
2013
2037
  .example('*')
2014
- .description('Text content to include: "plain", "html", "*" (both), or omit for metadata only')
2038
+ .description('Text content to include: "plain", "html", or "*" (both)')
2015
2039
  .label('ExportTextType'),
2016
2040
  maxBytes: Joi.number()
2017
2041
  .integer()
@@ -2117,6 +2141,7 @@ module.exports = {
2117
2141
  tokenRestrictionsSchema,
2118
2142
  accountIdSchema,
2119
2143
  ipSchema,
2144
+ tokenIdSchema,
2120
2145
  accountCountersSchema,
2121
2146
  accountPathSchema,
2122
2147
  messageSpecialUseSchema,
package/lib/tools.js CHANGED
@@ -145,6 +145,14 @@ async function reloadHttpProxyAgent() {
145
145
  }
146
146
  }
147
147
 
148
+ // Reload the shared HTTP proxy agent, but only when proxy-related settings actually changed.
149
+ // Centralizes the 'settings' message handling shared by the main thread and every worker.
150
+ function maybeReloadHttpProxyAgent(data) {
151
+ if (data && ('httpProxyEnabled' in data || 'httpProxyUrl' in data)) {
152
+ reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
153
+ }
154
+ }
155
+
148
156
  async function _doReloadHttpProxyAgent() {
149
157
  let enabled, proxyUrl;
150
158
  try {
@@ -1387,6 +1395,9 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV3QUiYsp13nD9suD1/ZkEXnuMoSg
1387
1395
  let redisSoftware;
1388
1396
  let redisCluster = false;
1389
1397
 
1398
+ // Consolidated list of Redis health issues, rendered as dashboard warning banners
1399
+ let redisWarnings = [];
1400
+
1390
1401
  try {
1391
1402
  let redisInfo = await module.exports.getRedisStats(redis);
1392
1403
 
@@ -1434,6 +1445,61 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV3QUiYsp13nD9suD1/ZkEXnuMoSg
1434
1445
  softwareDetails = `Amazon MemoryDB`;
1435
1446
  redisSoftware = 'memorydb';
1436
1447
  }
1448
+
1449
+ // Incompatible backend: Amazon ElastiCache
1450
+ if (redisSoftware === 'elasticache') {
1451
+ redisWarnings.push({
1452
+ key: 'elasticache',
1453
+ color: 'danger',
1454
+ title: 'Redis compatibility warning',
1455
+ details: [
1456
+ 'EmailEngine is incompatible with Amazon ElastiCache as the database backend.',
1457
+ 'Please switch to a standard Redis instance. Using ElastiCache with EmailEngine can result in data loss and some features may not work correctly.'
1458
+ ]
1459
+ });
1460
+ }
1461
+
1462
+ // Incompatible backend: Redis Cluster
1463
+ if (redisCluster) {
1464
+ redisWarnings.push({
1465
+ key: 'cluster',
1466
+ color: 'danger',
1467
+ title: 'Redis compatibility warning',
1468
+ details: ['EmailEngine is incompatible with a Redis Cluster setup.', 'Please switch to a standard Redis primary instance.']
1469
+ });
1470
+ }
1471
+
1472
+ // EmailEngine uses Redis as a database, not a cache. Any eviction silently drops sync state,
1473
+ // which makes already-synced messages look new and triggers duplicate webhooks.
1474
+ let maxmemoryPolicy = typeof redisInfo.maxmemory_policy === 'string' ? redisInfo.maxmemory_policy : null;
1475
+ let maxmemory = Number(redisInfo.maxmemory) || 0;
1476
+ let evictedKeys = Number(redisInfo.evicted_keys) || 0;
1477
+
1478
+ if (evictedKeys > 0) {
1479
+ // Definitive: Redis has already evicted keys
1480
+ redisWarnings.push({
1481
+ key: 'evicted-keys',
1482
+ color: 'danger',
1483
+ title: 'Redis eviction detected',
1484
+ details: [
1485
+ `Redis has evicted ${evictedKeys.toLocaleString('en-US')} key${evictedKeys === 1 ? '' : 's'} after reaching its memory limit.`,
1486
+ 'EmailEngine uses Redis as its primary database, not a cache. Evicted keys cause already-synced messages to be reprocessed - generating duplicate webhooks - and can lead to data loss.',
1487
+ 'Set the Redis "maxmemory-policy" to "noeviction" and provision enough memory so Redis never reaches its limit.'
1488
+ ]
1489
+ });
1490
+ } else if (maxmemoryPolicy && maxmemoryPolicy !== 'noeviction') {
1491
+ // Latent risk: an eviction policy is configured but nothing has been evicted yet
1492
+ redisWarnings.push({
1493
+ key: 'maxmemory-policy',
1494
+ color: maxmemory > 0 ? 'danger' : 'warning',
1495
+ title: 'Unsafe Redis eviction policy',
1496
+ details: [
1497
+ `The Redis "maxmemory-policy" is set to "${maxmemoryPolicy}", but EmailEngine requires "noeviction".`,
1498
+ 'With any other policy, Redis can delete EmailEngine sync state under memory pressure, causing already-synced messages to be reprocessed - generating duplicate webhooks - and possible data loss.',
1499
+ 'Set "maxmemory-policy noeviction" and make sure Redis has enough memory for your workload.'
1500
+ ]
1501
+ });
1502
+ }
1437
1503
  } catch (err) {
1438
1504
  logger.error({ msg: 'Failed to get stats', err });
1439
1505
  redisVersion = err.message;
@@ -1477,6 +1543,7 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV3QUiYsp13nD9suD1/ZkEXnuMoSg
1477
1543
  redis: `${redisVersion}${softwareDetails ? ` (${softwareDetails})` : ''}`,
1478
1544
  redisSoftware,
1479
1545
  redisCluster,
1546
+ redisWarnings,
1480
1547
  imapflow: ImapFlow.version || 'please upgrade',
1481
1548
  bullmq: bullmqPackage.version,
1482
1549
  arch: process.arch,
@@ -1571,6 +1638,11 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV3QUiYsp13nD9suD1/ZkEXnuMoSg
1571
1638
 
1572
1639
  mergeObjects(destination, source) {
1573
1640
  for (let propKey of Object.keys(source)) {
1641
+ // Guard against prototype pollution from crafted keys in parsed JSON
1642
+ if (propKey === '__proto__' || propKey === 'constructor' || propKey === 'prototype') {
1643
+ continue;
1644
+ }
1645
+
1574
1646
  let sourceVal = source[propKey];
1575
1647
 
1576
1648
  if (typeof destination[propKey] === 'undefined') {
@@ -1958,6 +2030,7 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV3QUiYsp13nD9suD1/ZkEXnuMoSg
1958
2030
 
1959
2031
  httpAgent,
1960
2032
  reloadHttpProxyAgent,
2033
+ maybeReloadHttpProxyAgent,
1961
2034
  createSocksAgent,
1962
2035
 
1963
2036
  get fetchAgent() {
@@ -1,205 +1,45 @@
1
1
  'use strict';
2
2
 
3
- const Boom = require('@hapi/boom');
3
+ // NB! This file is processed by the gettext parser (npm run gettext) and can not use newer syntax like ?.
4
+
5
+ // Admin UI routes for account management: /admin/accounts (listing), /admin/accounts/new and
6
+ // /accounts/new* (the add-account wizard incl. IMAP autoconfig/test/server steps), and
7
+ // /admin/accounts/{account}* (view, edit, delete, reconnect, sync, logs, browse). Extracted
8
+ // verbatim from lib/routes-ui.js. DISABLE_MESSAGE_BROWSER and the getMailboxListing helper move
9
+ // with the routes (only these account pages use them).
10
+
4
11
  const Joi = require('joi');
5
12
  const crypto = require('crypto');
6
- const util = require('util');
7
- const psl = require('psl');
13
+ const Boom = require('@hapi/boom');
8
14
 
9
15
  const settings = require('../settings');
16
+ const consts = require('../consts');
10
17
  const tokens = require('../tokens');
11
18
  const { redis, documentsQueue } = require('../db');
19
+ const getSecret = require('../get-secret');
20
+ const capa = require('../capa');
21
+ const { Account } = require('../account');
22
+ const { Gateway } = require('../gateway');
23
+ const { autodetectImapSettings } = require('../autodetect-imap-settings');
24
+ const { oauth2Apps, oauth2ProviderData, SERVICE_ACCOUNT_PROVIDERS } = require('../oauth2-apps');
12
25
  const {
13
- failAction,
14
- verifyAccountInfo,
15
- getLogs,
16
- flattenObjectKeys,
17
- getSignedFormData,
18
26
  getServiceHostname,
27
+ getSignedFormData,
19
28
  parseSignedFormData,
29
+ getLogs,
30
+ verifyAccountInfo,
31
+ flattenObjectKeys,
20
32
  getBoolean,
21
- readEnvValue
33
+ readEnvValue,
34
+ failAction
22
35
  } = require('../tools');
23
- const { Account } = require('../account');
24
- const { Gateway } = require('../gateway');
25
- const { oauth2Apps, oauth2ProviderData, SERVICE_ACCOUNT_PROVIDERS } = require('../oauth2-apps');
26
- const { autodetectImapSettings } = require('../autodetect-imap-settings');
27
- const getSecret = require('../get-secret');
28
- const capa = require('../capa');
29
- const consts = require('../consts');
30
36
  const { settingsSchema, accountIdSchema, defaultAccountTypeSchema } = require('../schemas');
31
- const fs = require('fs');
32
- const pathlib = require('path');
37
+ const { formatAccountData, cachedTemplates } = require('./route-helpers');
33
38
 
34
- const { DEFAULT_MAX_LOG_LINES, DEFAULT_PAGE_SIZE, REDIS_PREFIX, MAX_FORM_TTL, NONCE_BYTES } = consts;
39
+ const { REDIS_PREFIX, DEFAULT_PAGE_SIZE, NONCE_BYTES, MAX_FORM_TTL, DEFAULT_MAX_LOG_LINES } = consts;
35
40
 
36
41
  const DISABLE_MESSAGE_BROWSER = getBoolean(readEnvValue('EENGINE_DISABLE_MESSAGE_BROWSER'));
37
42
 
38
- const cachedTemplates = {
39
- testSend: fs.readFileSync(pathlib.join(__dirname, '..', '..', 'views', 'partials', 'test_send.hbs'), 'utf-8')
40
- };
41
-
42
- function formatAccountData(account, gt) {
43
- account.type = {};
44
-
45
- if (account.oauth2 && account.oauth2.app) {
46
- let providerData = oauth2ProviderData(account.oauth2.app.provider);
47
- account.type = providerData;
48
- } else if (account.oauth2 && account.oauth2.provider) {
49
- account.type = oauth2ProviderData(account.oauth2.provider);
50
- } else if (account.imap && !account.imap.disabled) {
51
- account.type.icon = 'fa fa-envelope-square';
52
- account.type.name = 'IMAP';
53
- account.type.comment = psl.get(account.imap.host) || account.imap.host;
54
- } else if (account.smtp) {
55
- account.type.icon = 'fa fa-paper-plane';
56
- account.type.name = 'SMTP';
57
- account.type.comment = psl.get(account.smtp.host) || account.smtp.host;
58
- } else if (account.oauth2 && account.oauth2.auth && account.oauth2.auth.delegatedAccount) {
59
- account.type.icon = 'fa fa-arrow-alt-circle-right';
60
- account.type.name = gt.gettext('Delegated');
61
- account.type.comment = util.format(gt.gettext('Using credentials from "%s"'), account.oauth2.auth.delegatedAccount);
62
- } else {
63
- account.type.name = 'N/A';
64
- }
65
-
66
- switch (account.state) {
67
- case 'init':
68
- account.stateLabel = {
69
- type: 'info',
70
- name: 'Initializing',
71
- spinner: true
72
- };
73
- break;
74
-
75
- case 'connecting':
76
- account.stateLabel = {
77
- type: 'info',
78
- name: 'Connecting'
79
- };
80
- break;
81
-
82
- case 'syncing':
83
- account.stateLabel = {
84
- type: 'info',
85
- name: 'Syncing',
86
- spinner: true
87
- };
88
- break;
89
-
90
- case 'connected':
91
- account.stateLabel = {
92
- type: 'success',
93
- name: 'Connected'
94
- };
95
- break;
96
-
97
- case 'disabled':
98
- account.stateLabel = {
99
- type: 'secondary',
100
- name: 'Disabled',
101
- error: account.disabledReason
102
- };
103
- break;
104
-
105
- case 'authenticationError':
106
- case 'connectError': {
107
- let errorMessage = account.lastErrorState ? account.lastErrorState.response : false;
108
- if (account.lastErrorState) {
109
- switch (account.lastErrorState.serverResponseCode) {
110
- case 'ETIMEDOUT':
111
- errorMessage = gt.gettext('Connection timed out. This usually occurs if you are behind a firewall or connecting to the wrong port.');
112
- break;
113
- case 'ClosedAfterConnectTLS':
114
- errorMessage = gt.gettext('The server unexpectedly closed the connection.');
115
- break;
116
- case 'ClosedAfterConnectText':
117
- errorMessage = gt.gettext(
118
- 'The server unexpectedly closed the connection. This usually happens when attempting to connect to a TLS port without TLS enabled.'
119
- );
120
- break;
121
- case 'ECONNREFUSED':
122
- errorMessage = gt.gettext(
123
- 'The server refused the connection. This typically occurs if the server is not running, is overloaded, or you are connecting to the wrong host or port.'
124
- );
125
- break;
126
- }
127
- }
128
-
129
- account.stateLabel = {
130
- type: 'danger',
131
- name: 'Failed',
132
- error: errorMessage
133
- };
134
- break;
135
- }
136
- case 'unset':
137
- account.stateLabel = {
138
- type: 'light',
139
- name: 'Not syncing'
140
- };
141
- break;
142
- case 'disconnected':
143
- account.stateLabel = {
144
- type: 'warning',
145
- name: 'Disconnected'
146
- };
147
- break;
148
- case 'paused':
149
- account.stateLabel = {
150
- type: 'secondary',
151
- name: 'Paused'
152
- };
153
- break;
154
- default:
155
- account.stateLabel = {
156
- type: 'secondary',
157
- name: 'N/A'
158
- };
159
- break;
160
- }
161
-
162
- // Check if IMAP was disabled due to errors - override state label to show error
163
- if (account.imap && account.imap.disabled && account.lastErrorState) {
164
- account.stateLabel = {
165
- type: 'danger',
166
- name: 'Failed',
167
- error: account.lastErrorState.description || account.lastErrorState.response
168
- };
169
- }
170
-
171
- if (account.oauth2) {
172
- account.oauth2.scopes = []
173
- .concat(account.oauth2.scope || [])
174
- .concat(account.oauth2.scopes || [])
175
- .flatMap(entry => entry.split(/\s+/))
176
- .map(entry => entry.trim())
177
- .filter(entry => entry);
178
-
179
- account.oauth2.expiresStr = account.oauth2.expires ? account.oauth2.expires.toISOString() : false;
180
- account.oauth2.generatedStr = account.oauth2.generated ? account.oauth2.generated.toISOString() : false;
181
-
182
- if (account.outlookSubscription) {
183
- account.outlookSubscription.subscriptionExpiresStr = account.outlookSubscription.expirationDateTime
184
- ? account.outlookSubscription.expirationDateTime.toISOString()
185
- : false;
186
-
187
- let state = account.outlookSubscription.state || {};
188
-
189
- account.outlookSubscription.isValid =
190
- state.state !== 'error' && account.outlookSubscription.expirationDateTime && account.outlookSubscription.expirationDateTime > new Date();
191
-
192
- account.outlookSubscription.stateLabel = (state.state || '').replace(/^./, c => c.toUpperCase());
193
-
194
- if ((state.state === 'created' && !account.outlookSubscription.expirationDateTime) || account.outlookSubscription.expirationDateTime < new Date()) {
195
- account.outlookSubscription.stateLabel = 'Expired';
196
- }
197
- }
198
- }
199
-
200
- return account;
201
- }
202
-
203
43
  async function getMailboxListing(accountObject) {
204
44
  let mailboxes = [
205
45
  {
@@ -229,7 +69,7 @@ async function getMailboxListing(accountObject) {
229
69
  return a.path.toLowerCase().localeCompare(b.path.toLowerCase());
230
70
  });
231
71
  } catch (err) {
232
- // ignore
72
+ // failed to get mailbox list
233
73
  }
234
74
 
235
75
  return mailboxes;
@@ -238,7 +78,6 @@ async function getMailboxListing(accountObject) {
238
78
  function init(args) {
239
79
  const { server, call } = args;
240
80
 
241
- // Account listing route
242
81
  server.route({
243
82
  method: 'GET',
244
83
  path: '/admin/accounts',
@@ -256,14 +95,21 @@ function init(args) {
256
95
  }
257
96
 
258
97
  for (let account of accounts.accounts) {
259
- let accountObj = new Account({ redis, account: account.account });
260
- account.data = await accountObj.loadAccountData(null, null, runIndex);
98
+ let accountObject = new Account({ redis, account: account.account });
99
+ try {
100
+ account.data = await accountObject.loadAccountData(null, null, runIndex);
261
101
 
262
- if (account.data && account.data.oauth2 && account.data.oauth2.provider) {
263
- let oauth2App = await oauth2Apps.get(account.data.oauth2.provider);
264
- if (oauth2App) {
265
- account.data.oauth2.app = oauth2App;
102
+ if (account.data && account.data.oauth2 && account.data.oauth2.provider) {
103
+ let oauth2App = await oauth2Apps.get(account.data.oauth2.provider);
104
+ if (oauth2App) {
105
+ account.data.oauth2.app = oauth2App;
106
+ }
266
107
  }
108
+ } catch (err) {
109
+ // Account has invalid config (e.g., broken delegation)
110
+ account.data = {
111
+ delegationError: err.message
112
+ };
267
113
  }
268
114
  }
269
115
 
@@ -365,7 +211,7 @@ function init(args) {
365
211
  selectedState: stateOptions.find(entry => entry.state && entry.state === request.query.state),
366
212
 
367
213
  searchTarget: '/admin/accounts',
368
- searchPlaceholder: 'Search for accounts...',
214
+ searchPlaceholder: 'Search for accounts',
369
215
 
370
216
  showPaging: accounts.pages > 1,
371
217
  nextPage,
@@ -415,7 +261,6 @@ function init(args) {
415
261
  }
416
262
  });
417
263
 
418
- // New account POST handler
419
264
  server.route({
420
265
  method: 'POST',
421
266
  path: '/admin/accounts/new',
@@ -566,7 +411,6 @@ function init(args) {
566
411
  );
567
412
  }
568
413
 
569
- // Public GET new account form
570
414
  server.route({
571
415
  method: 'GET',
572
416
  path: '/accounts/new',
@@ -628,7 +472,6 @@ function init(args) {
628
472
  }
629
473
  });
630
474
 
631
- // Public POST new account form
632
475
  server.route({
633
476
  method: 'POST',
634
477
  path: '/accounts/new',
@@ -669,7 +512,6 @@ function init(args) {
669
512
  }
670
513
  });
671
514
 
672
- // IMAP account setup form
673
515
  server.route({
674
516
  method: 'POST',
675
517
  path: '/accounts/new/imap',
@@ -772,7 +614,6 @@ function init(args) {
772
614
  }
773
615
  });
774
616
 
775
- // Test IMAP settings
776
617
  server.route({
777
618
  method: 'POST',
778
619
  path: '/accounts/new/imap/test',
@@ -924,7 +765,6 @@ function init(args) {
924
765
  }
925
766
  });
926
767
 
927
- // Submit IMAP server settings
928
768
  server.route({
929
769
  method: 'POST',
930
770
  path: '/accounts/new/imap/server',
@@ -1097,7 +937,6 @@ function init(args) {
1097
937
  }
1098
938
  });
1099
939
 
1100
- // View account details
1101
940
  server.route({
1102
941
  method: 'GET',
1103
942
  path: '/admin/accounts/{account}',
@@ -1327,7 +1166,6 @@ function init(args) {
1327
1166
  }
1328
1167
  });
1329
1168
 
1330
- // Delete account
1331
1169
  server.route({
1332
1170
  method: 'POST',
1333
1171
  path: '/admin/accounts/{account}/delete',
@@ -1369,7 +1207,6 @@ function init(args) {
1369
1207
  }
1370
1208
  });
1371
1209
 
1372
- // Reconnect account
1373
1210
  server.route({
1374
1211
  method: 'POST',
1375
1212
  path: '/admin/accounts/{account}/reconnect',
@@ -1408,7 +1245,6 @@ function init(args) {
1408
1245
  }
1409
1246
  });
1410
1247
 
1411
- // Sync account
1412
1248
  server.route({
1413
1249
  method: 'POST',
1414
1250
  path: '/admin/accounts/{account}/sync',
@@ -1447,7 +1283,6 @@ function init(args) {
1447
1283
  }
1448
1284
  });
1449
1285
 
1450
- // Toggle account logs
1451
1286
  server.route({
1452
1287
  method: 'POST',
1453
1288
  path: '/admin/accounts/{account}/logs',
@@ -1495,7 +1330,6 @@ function init(args) {
1495
1330
  }
1496
1331
  });
1497
1332
 
1498
- // Flush account logs
1499
1333
  server.route({
1500
1334
  method: 'POST',
1501
1335
  path: '/admin/accounts/{account}/logs-flush',
@@ -1532,7 +1366,6 @@ function init(args) {
1532
1366
  }
1533
1367
  });
1534
1368
 
1535
- // Get account logs as text
1536
1369
  server.route({
1537
1370
  method: 'GET',
1538
1371
  path: '/admin/accounts/{account}/logs.txt',
@@ -1556,7 +1389,6 @@ function init(args) {
1556
1389
  }
1557
1390
  });
1558
1391
 
1559
- // Browse account messages
1560
1392
  server.route({
1561
1393
  method: 'GET',
1562
1394
  path: '/admin/accounts/{account}/browse',
@@ -1573,7 +1405,7 @@ function init(args) {
1573
1405
  if (request.cookieAuth) {
1574
1406
  request.cookieAuth.clear();
1575
1407
  }
1576
- await request.flash({ type: 'info', message: `Sign in again to continue` });
1408
+ await request.flash({ type: 'info', message: `Sign in again to continue.` });
1577
1409
  return h.redirect('/admin/login?next=' + encodeURIComponent('/admin/accounts/{account}/browse'));
1578
1410
  }
1579
1411
 
@@ -1635,7 +1467,6 @@ function init(args) {
1635
1467
  }
1636
1468
  });
1637
1469
 
1638
- // Edit account (GET)
1639
1470
  server.route({
1640
1471
  method: 'GET',
1641
1472
  path: '/admin/accounts/{account}/edit',
@@ -1715,7 +1546,6 @@ function init(args) {
1715
1546
  }
1716
1547
  });
1717
1548
 
1718
- // Edit account (POST)
1719
1549
  server.route({
1720
1550
  method: 'POST',
1721
1551
  path: '/admin/accounts/{account}/edit',