emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
package/lib/account.js CHANGED
@@ -945,7 +945,7 @@ class Account {
945
945
  throw error;
946
946
  }
947
947
 
948
- let state = false;
948
+ let state;
949
949
  if (result[0][1] && result[0][1].account) {
950
950
  // existing user
951
951
  state = 'existing';
@@ -1084,22 +1084,45 @@ class Account {
1084
1084
  };
1085
1085
  }
1086
1086
 
1087
+ // Creates the consumer side of a cross-thread download stream, with a guard listener:
1088
+ // the producer can post {error} before Hapi attaches its own 'error' listeners (or
1089
+ // while the setup call is still pending). Without a listener that emission is an
1090
+ // uncaught exception that kills the API worker.
1091
+ createDownloadStream(failureLogMessage) {
1092
+ const { port1, port2 } = new MessageChannel();
1093
+ const stream = new MessagePortReadable(port1);
1094
+
1095
+ stream.on('error', err => {
1096
+ this.logger.error({ msg: failureLogMessage, account: this.account, err });
1097
+ });
1098
+
1099
+ return { stream, port2 };
1100
+ }
1101
+
1087
1102
  async getRawMessage(message) {
1088
1103
  await this.loadAccountData(this.account, true);
1089
1104
 
1090
- const { port1, port2 } = new MessageChannel();
1091
- const stream = new MessagePortReadable(port1);
1105
+ const { stream, port2 } = this.createDownloadStream('Message source stream failed');
1092
1106
 
1093
- let streamCreated = await this.call(
1094
- {
1095
- cmd: 'getRawMessage',
1096
- account: this.account,
1097
- message,
1098
- timeout: this.timeout,
1099
- port: port2
1100
- },
1101
- [port2]
1102
- );
1107
+ let streamCreated;
1108
+ try {
1109
+ streamCreated = await this.call(
1110
+ {
1111
+ cmd: 'getRawMessage',
1112
+ account: this.account,
1113
+ message,
1114
+ timeout: this.timeout,
1115
+ port: port2
1116
+ },
1117
+ [port2]
1118
+ );
1119
+ } catch (err) {
1120
+ // The setup call failed (timeout, worker gone, 404). Destroy the reader so
1121
+ // its MessagePort + listener are released instead of leaking, and so any
1122
+ // late producer data is not buffered forever.
1123
+ stream.destroy();
1124
+ throw err;
1125
+ }
1103
1126
 
1104
1127
  if (streamCreated && streamCreated.headers) {
1105
1128
  stream.headers = streamCreated.headers;
@@ -1111,19 +1134,27 @@ class Account {
1111
1134
  async getAttachment(attachment) {
1112
1135
  await this.loadAccountData(this.account, true);
1113
1136
 
1114
- const { port1, port2 } = new MessageChannel();
1115
- const stream = new MessagePortReadable(port1);
1137
+ const { stream, port2 } = this.createDownloadStream('Attachment stream failed');
1116
1138
 
1117
- let streamCreated = await this.call(
1118
- {
1119
- cmd: 'getAttachment',
1120
- account: this.account,
1121
- attachment,
1122
- timeout: this.timeout,
1123
- port: port2
1124
- },
1125
- [port2]
1126
- );
1139
+ let streamCreated;
1140
+ try {
1141
+ streamCreated = await this.call(
1142
+ {
1143
+ cmd: 'getAttachment',
1144
+ account: this.account,
1145
+ attachment,
1146
+ timeout: this.timeout,
1147
+ port: port2
1148
+ },
1149
+ [port2]
1150
+ );
1151
+ } catch (err) {
1152
+ // The setup call failed (timeout, worker gone, 404). Destroy the reader so
1153
+ // its MessagePort + listener are released instead of leaking, and so any
1154
+ // late producer data is not buffered forever.
1155
+ stream.destroy();
1156
+ throw err;
1157
+ }
1127
1158
 
1128
1159
  if (streamCreated && streamCreated.headers) {
1129
1160
  stream.headers = streamCreated.headers;
@@ -1236,16 +1267,7 @@ class Account {
1236
1267
  } catch (err) {
1237
1268
  // should not happen
1238
1269
  if (logger.notifyError) {
1239
- logger.notifyError(err, event => {
1240
- if (this.account) {
1241
- event.setUser(this.account);
1242
- }
1243
-
1244
- event.addMetadata('ee', {
1245
- path,
1246
- mailboxListing: typeof mailboxListing
1247
- });
1248
- });
1270
+ logger.notifyError(err, { user: this.account, meta: { path, mailboxListing: typeof mailboxListing } });
1249
1271
  }
1250
1272
 
1251
1273
  let message = 'Failed to process stored mailbox listing';
@@ -1259,22 +1281,44 @@ class Account {
1259
1281
  return mailboxes;
1260
1282
  }
1261
1283
 
1284
+ // Worker backends return false for entities they can not find (e.g. an unknown mailbox path
1285
+ // or message ID on IMAP). Convert such results into a 404 error for the API.
1286
+ assertFound(result, message, code) {
1287
+ if (!result) {
1288
+ let error = Boom.boomify(new Error(message), { statusCode: 404 });
1289
+ error.output.payload.code = code;
1290
+ throw error;
1291
+ }
1292
+ return result;
1293
+ }
1294
+
1295
+ assertMessageFound(result) {
1296
+ return this.assertFound(result, 'Requested message was not found', 'MessageNotFound');
1297
+ }
1298
+
1299
+ assertFolderFound(result) {
1300
+ return this.assertFound(result, 'Requested mailbox folder was not found', 'FolderNotFound');
1301
+ }
1302
+
1262
1303
  async updateMessage(message, updates) {
1263
1304
  await this.loadAccountData(this.account, true);
1264
1305
 
1265
- return await this.call({
1306
+ let result = await this.call({
1266
1307
  cmd: 'updateMessage',
1267
1308
  account: this.account,
1268
1309
  message,
1269
1310
  updates,
1270
1311
  timeout: this.timeout
1271
1312
  });
1313
+
1314
+ // IMAP backend returns false for unknown messages and folders
1315
+ return this.assertMessageFound(result);
1272
1316
  }
1273
1317
 
1274
1318
  async updateMessages(path, search, updates) {
1275
1319
  await this.loadAccountData(this.account, true);
1276
1320
 
1277
- return await this.call({
1321
+ let result = await this.call({
1278
1322
  cmd: 'updateMessages',
1279
1323
  account: this.account,
1280
1324
  path,
@@ -1282,6 +1326,9 @@ class Account {
1282
1326
  updates,
1283
1327
  timeout: this.timeout
1284
1328
  });
1329
+
1330
+ // IMAP backend returns false for unknown folders
1331
+ return this.assertFolderFound(result);
1285
1332
  }
1286
1333
 
1287
1334
  async listMailboxes(query) {
@@ -1304,7 +1351,8 @@ class Account {
1304
1351
 
1305
1352
  async moveMessage(message, target, options) {
1306
1353
  await this.loadAccountData(this.account, true);
1307
- return await this.call({
1354
+
1355
+ let result = await this.call({
1308
1356
  cmd: 'moveMessage',
1309
1357
  account: this.account,
1310
1358
  message,
@@ -1312,11 +1360,15 @@ class Account {
1312
1360
  options,
1313
1361
  timeout: this.timeout
1314
1362
  });
1363
+
1364
+ // IMAP backend returns false for unknown messages and folders
1365
+ return this.assertMessageFound(result);
1315
1366
  }
1316
1367
 
1317
1368
  async moveMessages(source, search, target) {
1318
1369
  await this.loadAccountData(this.account, true);
1319
- return await this.call({
1370
+
1371
+ let result = await this.call({
1320
1372
  cmd: 'moveMessages',
1321
1373
  account: this.account,
1322
1374
  source,
@@ -1324,24 +1376,30 @@ class Account {
1324
1376
  target,
1325
1377
  timeout: this.timeout
1326
1378
  });
1379
+
1380
+ // IMAP backend returns false for unknown folders
1381
+ return this.assertFolderFound(result);
1327
1382
  }
1328
1383
 
1329
1384
  async deleteMessage(message, force) {
1330
1385
  await this.loadAccountData(this.account, true);
1331
1386
 
1332
- return await this.call({
1387
+ let result = await this.call({
1333
1388
  cmd: 'deleteMessage',
1334
1389
  account: this.account,
1335
1390
  message,
1336
1391
  force,
1337
1392
  timeout: this.timeout
1338
1393
  });
1394
+
1395
+ // IMAP backend returns false for unknown messages and folders
1396
+ return this.assertMessageFound(result);
1339
1397
  }
1340
1398
 
1341
1399
  async deleteMessages(path, search, force) {
1342
1400
  await this.loadAccountData(this.account, true);
1343
1401
 
1344
- return await this.call({
1402
+ let result = await this.call({
1345
1403
  cmd: 'deleteMessages',
1346
1404
  account: this.account,
1347
1405
  path,
@@ -1349,6 +1407,9 @@ class Account {
1349
1407
  force,
1350
1408
  timeout: this.timeout
1351
1409
  });
1410
+
1411
+ // IMAP backend returns false for unknown folders
1412
+ return this.assertFolderFound(result);
1352
1413
  }
1353
1414
 
1354
1415
  async getQuota() {
@@ -1416,13 +1477,7 @@ class Account {
1416
1477
  results: getResult && getResult._source ? 1 : 0
1417
1478
  });
1418
1479
 
1419
- if (!getResult || !getResult._source) {
1420
- let message = 'Requested message was not found';
1421
- let error = Boom.boomify(new Error(message), { statusCode: 404 });
1422
- throw error;
1423
- }
1424
-
1425
- let messageData = getResult._source;
1480
+ let messageData = this.assertMessageFound(getResult && getResult._source);
1426
1481
  let response = {};
1427
1482
 
1428
1483
  response.hasMore = false;
@@ -1442,13 +1497,16 @@ class Account {
1442
1497
 
1443
1498
  await this.loadAccountData(this.account, true);
1444
1499
 
1445
- return await this.call({
1500
+ let textData = await this.call({
1446
1501
  cmd: 'getText',
1447
1502
  account: this.account,
1448
1503
  text,
1449
1504
  options,
1450
1505
  timeout: this.timeout
1451
1506
  });
1507
+
1508
+ // IMAP backend returns false for unknown messages and folders
1509
+ return this.assertMessageFound(textData);
1452
1510
  }
1453
1511
 
1454
1512
  async getMessage(message, options) {
@@ -1490,13 +1548,7 @@ class Account {
1490
1548
  results: getResult && getResult._source ? 1 : 0
1491
1549
  });
1492
1550
 
1493
- if (!getResult || !getResult._source) {
1494
- let message = 'Requested message was not found';
1495
- let error = Boom.boomify(new Error(message), { statusCode: 404 });
1496
- throw error;
1497
- }
1498
-
1499
- let messageData = getResult._source;
1551
+ let messageData = this.assertMessageFound(getResult && getResult._source);
1500
1552
 
1501
1553
  // restore headers and text object as per the API response
1502
1554
  let headersObj = {};
@@ -1547,14 +1599,20 @@ class Account {
1547
1599
 
1548
1600
  // download large inline attachments not stored in ES
1549
1601
  for (let attachment of attachmentsToDownload) {
1602
+ let downloadStream;
1550
1603
  try {
1551
- let downloadStream = await this.getAttachment(attachment.id);
1604
+ downloadStream = await this.getAttachment(attachment.id);
1552
1605
  if (downloadStream) {
1553
1606
  let content = await download(downloadStream);
1554
1607
  this.logger.trace({ msg: 'Fetched attachment content', account: this.account, attachment, size: content.length });
1555
1608
  attachment.content = content.toString('base64');
1556
1609
  }
1557
1610
  } catch (err) {
1611
+ // Release the reader if download() failed mid-stream so its
1612
+ // MessagePort is not left open (destroy() is idempotent).
1613
+ if (downloadStream) {
1614
+ downloadStream.destroy();
1615
+ }
1558
1616
  this.logger.error({ msg: 'Failed to fetch attachment content', account: this.account, attachment, err });
1559
1617
  }
1560
1618
  }
@@ -1595,6 +1653,9 @@ class Account {
1595
1653
 
1596
1654
  if (options.markAsSeen && (!messageData.flags || !messageData.flags.includes('\\Seen'))) {
1597
1655
  // mark message as seen
1656
+ if (!messageData.flags) {
1657
+ messageData.flags = [];
1658
+ }
1598
1659
  messageData.flags.push('\\Seen');
1599
1660
  // do not wait until the update is completed, return immediately
1600
1661
  this.updateMessage(message, { flags: { add: ['\\Seen'] } }).catch(err => {
@@ -1624,13 +1685,7 @@ class Account {
1624
1685
  timeout: this.timeout
1625
1686
  });
1626
1687
 
1627
- if (!messageData) {
1628
- let message = 'Requested message was not found';
1629
- let error = Boom.boomify(new Error(message), { statusCode: 404 });
1630
- throw error;
1631
- }
1632
-
1633
- return messageData;
1688
+ return this.assertMessageFound(messageData);
1634
1689
  }
1635
1690
 
1636
1691
  async getMessages(messageIds, options) {
@@ -1764,7 +1819,7 @@ class Account {
1764
1819
 
1765
1820
  await this.loadAccountData(this.account, true);
1766
1821
 
1767
- return await this.call(
1822
+ let listing = await this.call(
1768
1823
  Object.assign(
1769
1824
  {
1770
1825
  cmd: 'listMessages',
@@ -1774,6 +1829,9 @@ class Account {
1774
1829
  { timeout: this.timeout }
1775
1830
  )
1776
1831
  );
1832
+
1833
+ // IMAP and Gmail backends return false for unknown folders
1834
+ return this.assertFolderFound(listing);
1777
1835
  }
1778
1836
 
1779
1837
  async searchMessages(query, searchOpts) {
@@ -2049,11 +2107,11 @@ class Account {
2049
2107
  let sizeMatch = {};
2050
2108
 
2051
2109
  if (query.search.larger) {
2052
- dateMatch.gte = query.search.larger;
2110
+ sizeMatch.gte = query.search.larger;
2053
2111
  }
2054
2112
 
2055
2113
  if (query.search.smaller) {
2056
- dateMatch.lte = query.search.smaller;
2114
+ sizeMatch.lte = query.search.smaller;
2057
2115
  }
2058
2116
 
2059
2117
  if (Object.keys(sizeMatch).length) {
@@ -2161,7 +2219,7 @@ class Account {
2161
2219
 
2162
2220
  await this.loadAccountData(this.account, true);
2163
2221
 
2164
- return await this.call(
2222
+ let listing = await this.call(
2165
2223
  Object.assign(
2166
2224
  {
2167
2225
  cmd: 'listMessages',
@@ -2171,6 +2229,9 @@ class Account {
2171
2229
  { timeout: this.timeout }
2172
2230
  )
2173
2231
  );
2232
+
2233
+ // IMAP and Gmail backends return false for unknown folders
2234
+ return this.assertFolderFound(listing);
2174
2235
  }
2175
2236
 
2176
2237
  async uploadMessage(data) {
@@ -2285,7 +2346,7 @@ class Account {
2285
2346
  }
2286
2347
  } catch (err) {
2287
2348
  this.logger.error({ msg: 'Failed to get lock', lockKey, err });
2288
- if (Boom.isBoom) {
2349
+ if (Boom.isBoom(err)) {
2289
2350
  throw err;
2290
2351
  }
2291
2352
  let error = Boom.boomify(new Error('Failed to get flush lock, try again later'), { statusCode: 500 });
@@ -2344,7 +2405,9 @@ class Account {
2344
2405
  // Flush ElasticSearch index for this account
2345
2406
  const { index, client } = this.esClient;
2346
2407
  if (!client) {
2347
- return;
2408
+ // Account data in Redis was already flushed, only the index cleanup was skipped
2409
+ this.logger.error({ msg: 'Document store is enabled but the ElasticSearch client is not available', action: 'flush' });
2410
+ return true;
2348
2411
  }
2349
2412
 
2350
2413
  let deleteResult = {};
@@ -2555,7 +2618,7 @@ class Account {
2555
2618
  account: accountData.account,
2556
2619
  user: authData.user,
2557
2620
  accessToken: authData.accessToken,
2558
- provider: accountData.oauth2.auth.provider,
2621
+ provider: accountData.oauth2.provider,
2559
2622
  registeredScopes: accountData.oauth2.scope,
2560
2623
  cached: false
2561
2624
  };
@@ -2589,7 +2652,7 @@ class Account {
2589
2652
  account: accountData.account,
2590
2653
  user: accountData.oauth2.auth.user,
2591
2654
  accessToken,
2592
- provider: accountData.oauth2.auth.provider,
2655
+ provider: accountData.oauth2.provider,
2593
2656
  registeredScopes: accountData.oauth2.scope,
2594
2657
  expires:
2595
2658
  accountData.oauth2.expires && typeof accountData.oauth2.expires.toISOString === 'function'