emailengine-app 2.61.4 → 2.62.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 (62) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account.js +20 -7
  4. package/lib/api-routes/account-routes.js +28 -5
  5. package/lib/api-routes/chat-routes.js +1 -1
  6. package/lib/api-routes/export-routes.js +316 -0
  7. package/lib/api-routes/message-routes.js +28 -23
  8. package/lib/api-routes/template-routes.js +28 -7
  9. package/lib/arf-detect.js +1 -1
  10. package/lib/consts.js +16 -0
  11. package/lib/db.js +3 -0
  12. package/lib/email-client/base-client.js +6 -4
  13. package/lib/email-client/gmail-client.js +204 -33
  14. package/lib/email-client/imap/mailbox.js +99 -8
  15. package/lib/email-client/imap/subconnection.js +5 -5
  16. package/lib/email-client/imap-client.js +76 -16
  17. package/lib/email-client/message-builder.js +3 -1
  18. package/lib/email-client/notification-handler.js +12 -9
  19. package/lib/email-client/outlook-client.js +362 -69
  20. package/lib/email-client/smtp-pool-manager.js +1 -1
  21. package/lib/export.js +528 -0
  22. package/lib/oauth/gmail.js +21 -13
  23. package/lib/oauth/mail-ru.js +23 -10
  24. package/lib/oauth/outlook.js +26 -16
  25. package/lib/oauth/pubsub/google.js +5 -0
  26. package/lib/routes-ui.js +236 -2
  27. package/lib/schemas.js +260 -80
  28. package/lib/stream-encrypt.js +263 -0
  29. package/lib/tools.js +30 -4
  30. package/lib/ui-routes/account-routes.js +24 -1
  31. package/lib/ui-routes/admin-config-routes.js +11 -4
  32. package/lib/ui-routes/admin-entities-routes.js +18 -0
  33. package/lib/webhooks.js +16 -20
  34. package/package.json +17 -17
  35. package/sbom.json +1 -1
  36. package/server.js +41 -5
  37. package/static/js/ace/ace.js +1 -1
  38. package/static/js/ace/ext-language_tools.js +1 -1
  39. package/static/licenses.html +47 -127
  40. package/translations/de.mo +0 -0
  41. package/translations/de.po +63 -36
  42. package/translations/en.mo +0 -0
  43. package/translations/en.po +64 -37
  44. package/translations/et.mo +0 -0
  45. package/translations/et.po +63 -36
  46. package/translations/fr.mo +0 -0
  47. package/translations/fr.po +63 -36
  48. package/translations/ja.mo +0 -0
  49. package/translations/ja.po +63 -36
  50. package/translations/messages.pot +88 -55
  51. package/translations/nl.mo +0 -0
  52. package/translations/nl.po +63 -36
  53. package/translations/pl.mo +0 -0
  54. package/translations/pl.po +63 -36
  55. package/views/accounts/account.hbs +375 -2
  56. package/views/config/service.hbs +35 -0
  57. package/workers/api.js +124 -45
  58. package/workers/documents.js +1 -0
  59. package/workers/export.js +926 -0
  60. package/workers/imap.js +29 -0
  61. package/workers/submit.js +25 -5
  62. package/workers/webhooks.js +11 -2
@@ -30,7 +30,8 @@ const {
30
30
  OUTLOOK_RETRY_MAX_DELAY,
31
31
  MESSAGE_UPDATED_NOTIFY,
32
32
  MESSAGE_DELETED_NOTIFY,
33
- MESSAGE_MISSING_NOTIFY
33
+ MESSAGE_MISSING_NOTIFY,
34
+ DEFAULT_OUTLOOK_EXPORT_BATCH_SIZE
34
35
  } = require('../consts');
35
36
 
36
37
  // Maximum number of operations in a single batch request to Microsoft Graph API
@@ -340,51 +341,66 @@ class OutlookClient extends BaseClient {
340
341
  }
341
342
 
342
343
  // Format mailbox data for API response
343
- let mailboxes = mailboxListing
344
- .map(entry => {
345
- let folderData = {
346
- id: entry.id,
347
- path: entry.pathName,
348
- delimiter: '/',
349
- parentPath: entry.parentPath,
350
- name: entry.displayName,
351
- listed: true,
352
- subscribed: true
344
+ let mailboxes = mailboxListing.map(entry => {
345
+ let folderData = {
346
+ id: entry.id,
347
+ path: entry.pathName,
348
+ delimiter: '/',
349
+ parentPath: entry.parentPath,
350
+ name: entry.displayName,
351
+ listed: true,
352
+ subscribed: true
353
+ };
354
+
355
+ if (entry.specialUse) {
356
+ folderData.specialUse = entry.specialUse;
357
+ folderData.specialUseSource = 'extension';
358
+ }
359
+
360
+ // Include message counts if requested
361
+ if (options?.statusQuery?.messages) {
362
+ folderData.status = {
363
+ messages: entry.totalItemCount,
364
+ unseen: entry.unreadItemCount
353
365
  };
366
+ }
354
367
 
355
- if (entry.specialUse) {
356
- folderData.specialUse = entry.specialUse;
357
- folderData.specialUseSource = 'extension';
358
- }
368
+ return folderData;
369
+ });
359
370
 
360
- // Include message counts if requested
361
- if (options?.statusQuery?.messages) {
362
- folderData.status = {
363
- messages: entry.totalItemCount,
364
- unseen: entry.unreadItemCount
365
- };
366
- }
371
+ // Add virtual "All Mail" folder for Outlook API
372
+ // This allows exporting all messages without scanning individual folders
373
+ mailboxes.unshift({
374
+ id: 'virtual_all',
375
+ path: '\\All',
376
+ delimiter: '/',
377
+ parentPath: '',
378
+ name: 'All Mail',
379
+ listed: true,
380
+ subscribed: false,
381
+ noSelect: true,
382
+ specialUse: '\\All',
383
+ specialUseSource: 'extension'
384
+ });
367
385
 
368
- return folderData;
369
- })
370
- .sort((a, b) => {
371
- // INBOX always comes first
372
- if (a.path === 'INBOX' || a.specialUse === '\\Inbox') {
373
- return -1;
374
- } else if (b.path === 'INBOX' || b.specialUse === '\\Inbox') {
375
- return 1;
376
- }
386
+ mailboxes.sort((a, b) => {
387
+ // INBOX always comes first
388
+ if (a.path === 'INBOX' || a.specialUse === '\\Inbox') {
389
+ return -1;
390
+ } else if (b.path === 'INBOX' || b.specialUse === '\\Inbox') {
391
+ return 1;
392
+ }
377
393
 
378
- // Special use folders come before regular folders
379
- if (a.specialUse && !b.specialUse) {
380
- return -1;
381
- } else if (!a.specialUse && b.specialUse) {
382
- return 1;
383
- }
394
+ // Special use folders come before regular folders
395
+ if (a.specialUse && !b.specialUse) {
396
+ return -1;
397
+ } else if (!a.specialUse && b.specialUse) {
398
+ return 1;
399
+ }
384
400
 
385
- // Alphabetical sorting for the rest
386
- return a.path.toLowerCase().localeCompare(b.path.toLowerCase());
387
- });
401
+ // Alphabetical sorting for the rest
402
+ return a.path.toLowerCase().localeCompare(b.path.toLowerCase());
403
+ });
388
404
 
389
405
  return mailboxes;
390
406
  }
@@ -395,6 +411,8 @@ class OutlookClient extends BaseClient {
395
411
  */
396
412
  async listMessages(query, options) {
397
413
  options = options || {};
414
+ // Support metadataOnly passed via query (from export worker)
415
+ options.metadataOnly = options.metadataOnly || query.metadataOnly;
398
416
 
399
417
  await this.prepare();
400
418
 
@@ -447,6 +465,53 @@ class OutlookClient extends BaseClient {
447
465
  }
448
466
  }
449
467
 
468
+ // Build select fields based on detail level
469
+ let selectFields;
470
+ let expandFields;
471
+
472
+ if (options.minimalFields) {
473
+ selectFields = ['id', 'conversationId', 'receivedDateTime'];
474
+ } else if (options.metadataOnly) {
475
+ selectFields = [
476
+ 'id',
477
+ 'conversationId',
478
+ 'receivedDateTime',
479
+ 'isRead',
480
+ 'isDraft',
481
+ 'flag',
482
+ 'body',
483
+ 'subject',
484
+ 'from',
485
+ 'replyTo',
486
+ 'sender',
487
+ 'internetMessageId'
488
+ ];
489
+ } else {
490
+ selectFields = [
491
+ 'id',
492
+ 'conversationId',
493
+ 'receivedDateTime',
494
+ 'isRead',
495
+ 'isDraft',
496
+ 'flag',
497
+ 'body',
498
+ 'subject',
499
+ 'from',
500
+ 'replyTo',
501
+ 'sender',
502
+ 'toRecipients',
503
+ 'ccRecipients',
504
+ 'bccRecipients',
505
+ 'internetMessageId',
506
+ 'bodyPreview'
507
+ ];
508
+ expandFields = 'attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)';
509
+ }
510
+
511
+ if (!folder) {
512
+ selectFields.push('parentFolderId');
513
+ }
514
+
450
515
  // Build Graph API query parameters
451
516
  let requestQuery = {
452
517
  $count: true,
@@ -454,30 +519,8 @@ class OutlookClient extends BaseClient {
454
519
  $skip: page * pageSize,
455
520
  $skiptoken,
456
521
  $orderBy: 'receivedDateTime desc',
457
- $select: (options.metadataOnly
458
- ? ['id', 'conversationId', 'receivedDateTime', 'isRead', 'isDraft', 'flag', 'body', 'subject', 'from', 'replyTo', 'sender', 'internetMessageId']
459
- : [
460
- 'id',
461
- 'conversationId',
462
- 'receivedDateTime',
463
- 'isRead',
464
- 'isDraft',
465
- 'flag',
466
- 'body',
467
- 'subject',
468
- 'from',
469
- 'replyTo',
470
- 'sender',
471
- 'toRecipients',
472
- 'ccRecipients',
473
- 'bccRecipients',
474
- 'internetMessageId',
475
- 'bodyPreview'
476
- ]
477
- )
478
- .concat(!folder ? 'parentFolderId' : [])
479
- .join(','),
480
- $expand: options.metadataOnly ? undefined : 'attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)'
522
+ $select: selectFields.join(','),
523
+ $expand: expandFields
481
524
  };
482
525
 
483
526
  let useOutlookSearch = false;
@@ -523,7 +566,11 @@ class OutlookClient extends BaseClient {
523
566
 
524
567
  messages =
525
568
  listing?.value?.map(messageData =>
526
- this.formatMessage(messageData, { path: quickResolveFolder(messageData.parentFolderId)?.pathName, showPath: !folder })
569
+ this.formatMessage(messageData, {
570
+ path: quickResolveFolder(messageData.parentFolderId)?.pathName,
571
+ showPath: !folder,
572
+ minimalFields: options.minimalFields
573
+ })
527
574
  ) || [];
528
575
  } catch (err) {
529
576
  this.logger.error({
@@ -1571,6 +1618,229 @@ class OutlookClient extends BaseClient {
1571
1618
  return formattedMessage;
1572
1619
  }
1573
1620
 
1621
+ /**
1622
+ * Fetches multiple messages using MS Graph batch API for efficient export operations
1623
+ * Uses /$batch endpoint to send up to 20 message requests in a single HTTP call
1624
+ * @param {string[]} emailIds - Array of message IDs
1625
+ * @param {Object} options - Fetch options
1626
+ * @returns {Object[]} Array of results with messageId, data, and error fields
1627
+ */
1628
+ async getMessages(emailIds, options) {
1629
+ options = options || {};
1630
+ await this.prepare();
1631
+
1632
+ // Pre-fetch folder listing once for all messages to avoid repeated lookups
1633
+ const mailboxListing = await this.getMailboxListing();
1634
+ const folderMap = new Map(mailboxListing.map(f => [f.id, f]));
1635
+
1636
+ const results = [];
1637
+ let idGen = 0;
1638
+
1639
+ // Build select fields for batch request
1640
+ const selectFields = [
1641
+ 'id',
1642
+ 'conversationId',
1643
+ 'receivedDateTime',
1644
+ 'isRead',
1645
+ 'isDraft',
1646
+ 'flag',
1647
+ 'body',
1648
+ 'subject',
1649
+ 'from',
1650
+ 'replyTo',
1651
+ 'sender',
1652
+ 'toRecipients',
1653
+ 'ccRecipients',
1654
+ 'bccRecipients',
1655
+ 'internetMessageId',
1656
+ 'bodyPreview',
1657
+ 'internetMessageHeaders',
1658
+ 'parentFolderId',
1659
+ 'categories'
1660
+ ].join(',');
1661
+
1662
+ const expandFields = 'attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)';
1663
+
1664
+ // Build Prefer header for body content type
1665
+ // Note: IdType="ImmutableId" is added by requestWithRetry automatically
1666
+ let preferHeader = '';
1667
+ if (options.textType === 'plain') {
1668
+ preferHeader = 'outlook.body-content-type="text"';
1669
+ } else if (options.textType === 'html') {
1670
+ preferHeader = 'outlook.body-content-type="html"';
1671
+ }
1672
+
1673
+ // Get configured batch size, capped at MS Graph API limit
1674
+ const settingsBatchSize = await settings.get('outlookExportBatchSize');
1675
+ const batchSize = Math.min(settingsBatchSize || DEFAULT_OUTLOOK_EXPORT_BATCH_SIZE, MAX_BATCH_SIZE);
1676
+
1677
+ for (let i = 0; i < emailIds.length; i += batchSize) {
1678
+ const batch = emailIds.slice(i, i + batchSize);
1679
+ const messageMap = new Map();
1680
+
1681
+ // Build batch request with up to MAX_BATCH_SIZE individual requests
1682
+ const requests = batch.map(emailId => {
1683
+ const reqId = `msg_${++idGen}`;
1684
+ messageMap.set(reqId, emailId);
1685
+
1686
+ const request = {
1687
+ id: reqId,
1688
+ method: 'GET',
1689
+ url: `/${this.oauth2UserPath}/messages/${emailId}?$select=${selectFields}&$expand=${expandFields}`
1690
+ };
1691
+
1692
+ // Add Prefer header for body content type if specified
1693
+ if (preferHeader) {
1694
+ request.headers = { Prefer: preferHeader };
1695
+ }
1696
+
1697
+ return request;
1698
+ });
1699
+
1700
+ // Submit batch request - single HTTP call for up to 20 messages
1701
+ // Retry on transient errors (429 rate limit, 5xx server errors)
1702
+ const BATCH_MAX_RETRIES = 3;
1703
+ const BATCH_RETRY_BASE_DELAY = 5000;
1704
+
1705
+ let responseData;
1706
+ let batchSuccess = false;
1707
+
1708
+ for (let attempt = 0; attempt <= BATCH_MAX_RETRIES; attempt++) {
1709
+ try {
1710
+ responseData = await this.request('/$batch', 'post', { requests });
1711
+ batchSuccess = true;
1712
+ break;
1713
+ } catch (err) {
1714
+ const isRetryable = err.oauthRequest?.status === 429 || (err.oauthRequest?.status >= 500 && err.oauthRequest?.status < 600);
1715
+
1716
+ if (!isRetryable || attempt === BATCH_MAX_RETRIES) {
1717
+ // Non-retryable error or max retries reached - return errors for all messages in this batch
1718
+ for (const emailId of batch) {
1719
+ results.push({
1720
+ messageId: emailId,
1721
+ data: null,
1722
+ error: { message: err.message, code: err.code, statusCode: err.oauthRequest?.status }
1723
+ });
1724
+ }
1725
+ break;
1726
+ }
1727
+
1728
+ // Wait before retrying with exponential backoff + jitter
1729
+ const delay = BATCH_RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000;
1730
+ this.logger.warn({
1731
+ msg: 'Batch request failed, retrying',
1732
+ account: this.account,
1733
+ attempt: attempt + 1,
1734
+ maxAttempts: BATCH_MAX_RETRIES + 1,
1735
+ statusCode: err.oauthRequest?.status,
1736
+ delayMs: Math.round(delay)
1737
+ });
1738
+ await new Promise(resolve => setTimeout(resolve, delay));
1739
+ }
1740
+ }
1741
+
1742
+ if (!batchSuccess) {
1743
+ continue;
1744
+ }
1745
+
1746
+ // Process batch responses - match by request ID and track which were received
1747
+ const respondedIds = new Set();
1748
+ for (const response of responseData?.responses || []) {
1749
+ const emailId = messageMap.get(response.id);
1750
+ if (!emailId) {
1751
+ continue;
1752
+ }
1753
+
1754
+ respondedIds.add(response.id);
1755
+
1756
+ if (response.status >= 200 && response.status < 300 && response.body) {
1757
+ try {
1758
+ const messageData = response.body;
1759
+
1760
+ // Resolve folder from pre-fetched map (O(1) lookup)
1761
+ let path, specialUse;
1762
+ if (messageData.parentFolderId) {
1763
+ const folder = folderMap.get(messageData.parentFolderId);
1764
+ if (folder) {
1765
+ path = folder.pathName;
1766
+ specialUse = folder.specialUse;
1767
+ }
1768
+ }
1769
+
1770
+ // Process email headers
1771
+ if (messageData.internetMessageHeaders) {
1772
+ let headers = {};
1773
+ for (let header of messageData.internetMessageHeaders || []) {
1774
+ let { name, value } = header;
1775
+ name = (name || '').toString().trim().toLowerCase();
1776
+ value = (value || '').toString().trim();
1777
+ if (!(name in headers)) {
1778
+ headers[name] = [];
1779
+ }
1780
+ if (!Array.isArray(headers[name])) {
1781
+ continue;
1782
+ }
1783
+ headers[name].push(value);
1784
+ if (name === 'in-reply-to') {
1785
+ messageData.inReplyTo = value;
1786
+ }
1787
+ }
1788
+ messageData.headers = headers;
1789
+ }
1790
+
1791
+ const formattedMessage = this.formatMessage(messageData, {
1792
+ extended: true,
1793
+ path,
1794
+ textType: options.textType,
1795
+ showPath: options.showPath
1796
+ });
1797
+
1798
+ if (specialUse) {
1799
+ formattedMessage.messageSpecialUse = specialUse;
1800
+ }
1801
+
1802
+ results.push({
1803
+ messageId: emailId,
1804
+ data: formattedMessage,
1805
+ error: null
1806
+ });
1807
+ } catch (err) {
1808
+ results.push({
1809
+ messageId: emailId,
1810
+ data: null,
1811
+ error: { message: err.message, code: err.code }
1812
+ });
1813
+ }
1814
+ } else {
1815
+ // Individual message error within the batch
1816
+ const graphError = response.body?.error;
1817
+ results.push({
1818
+ messageId: emailId,
1819
+ data: null,
1820
+ error: {
1821
+ message: graphError?.message || 'Request failed',
1822
+ code: graphError?.code,
1823
+ statusCode: response.status
1824
+ }
1825
+ });
1826
+ }
1827
+ }
1828
+
1829
+ // Reconcile: detect messages that got no response from the batch API
1830
+ for (const [reqId, emailId] of messageMap) {
1831
+ if (!respondedIds.has(reqId)) {
1832
+ results.push({
1833
+ messageId: emailId,
1834
+ data: null,
1835
+ error: { message: 'No response received from batch API', code: 'EMISSING_RESPONSE' }
1836
+ });
1837
+ }
1838
+ }
1839
+ }
1840
+
1841
+ return results;
1842
+ }
1843
+
1574
1844
  /**
1575
1845
  * Get message text content only
1576
1846
  * More efficient than getMessage when only text is needed
@@ -1765,7 +2035,7 @@ class OutlookClient extends BaseClient {
1765
2035
  case 'attachments': {
1766
2036
  messageUploadObj.attachments = [];
1767
2037
  let attachmentCounter = 0;
1768
- for (let attachment of emailObject.attachments) {
2038
+ for (let attachment of emailObject.attachments || []) {
1769
2039
  let attachmentEntry = {
1770
2040
  '@odata.type': '#microsoft.graph.fileAttachment'
1771
2041
  };
@@ -2088,7 +2358,8 @@ class OutlookClient extends BaseClient {
2088
2358
  await this.redis
2089
2359
  .multi()
2090
2360
  .hset(data.feedbackKey, 'success', 'true')
2091
- .expire(1 * 60 * 60);
2361
+ .expire(data.feedbackKey, 1 * 60 * 60)
2362
+ .exec();
2092
2363
  }
2093
2364
 
2094
2365
  return {
@@ -2963,7 +3234,7 @@ class OutlookClient extends BaseClient {
2963
3234
  try {
2964
3235
  outlookSubscription = JSON.parse(outlookSubscription);
2965
3236
  } catch (err) {
2966
- // ignore, I guess?
3237
+ outlookSubscription = {};
2967
3238
  }
2968
3239
  }
2969
3240
 
@@ -3092,13 +3363,35 @@ class OutlookClient extends BaseClient {
3092
3363
  * Handles all message properties and attachments
3093
3364
  */
3094
3365
  formatMessage(messageData, options) {
3095
- let { extended, path, textType, showPath } = options || {};
3366
+ let { extended, path, textType, showPath, minimalFields } = options || {};
3096
3367
 
3097
3368
  let date = messageData.receivedDateTime ? new Date(messageData.receivedDateTime) : undefined;
3098
3369
  if (date?.toString() === 'Invalid Date') {
3099
3370
  date = undefined;
3100
3371
  }
3101
3372
 
3373
+ // For minimalFields mode, return only essential fields for export indexing
3374
+ if (minimalFields) {
3375
+ // Derive a numeric uid from message ID for score tiebreaker
3376
+ // Graph API doesn't have IMAP-style UIDs, so we hash the ID
3377
+ let uid = 0;
3378
+ if (messageData.id) {
3379
+ for (let i = 0; i < messageData.id.length; i++) {
3380
+ uid = ((uid << 5) - uid + messageData.id.charCodeAt(i)) | 0;
3381
+ }
3382
+ uid = Math.abs(uid);
3383
+ }
3384
+
3385
+ return {
3386
+ id: messageData.id,
3387
+ emailId: messageData.id,
3388
+ threadId: messageData.conversationId || undefined,
3389
+ date: date ? date.toISOString() : undefined,
3390
+ uid,
3391
+ size: 0 // Graph API doesn't provide size in minimal listing
3392
+ };
3393
+ }
3394
+
3102
3395
  // Map Graph API flags to IMAP flags
3103
3396
  const flags = [];
3104
3397
  if (messageData.isRead) {
@@ -54,7 +54,7 @@ function setupTransporterHandlers(transporter, poolKey) {
54
54
  // Handle transport errors by removing from pool
55
55
  transporter.once('error', err => {
56
56
  // Not sure what happened, but do not re-use this transporter object anymore
57
- logger.error({ msg: 'Transporter failed', err });
57
+ logger.warn({ msg: 'Transporter failed', err });
58
58
  SMTP_POOLS.delete(poolKey);
59
59
  SMTP_POOL_LAST_USED.delete(poolKey);
60
60
  try {