emailengine-app 2.69.0 → 2.71.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 (97) hide show
  1. package/.github/workflows/deploy.yml +6 -3
  2. package/.github/workflows/release.yaml +2 -0
  3. package/.github/workflows/test.yml +73 -12
  4. package/.ncurc.js +3 -3
  5. package/CHANGELOG.md +37 -0
  6. package/Gruntfile.js +21 -23
  7. package/bin/emailengine.js +8 -1
  8. package/config/default.toml +5 -0
  9. package/config/test.toml +5 -0
  10. package/data/google-crawlers.json +1 -1
  11. package/getswagger.sh +44 -4
  12. package/gettext-extract.js +163 -0
  13. package/lib/account.js +104 -72
  14. package/lib/api-routes/account-routes.js +231 -71
  15. package/lib/api-routes/blocklist-routes.js +25 -18
  16. package/lib/api-routes/chat-routes.js +32 -14
  17. package/lib/api-routes/delivery-test-routes.js +30 -5
  18. package/lib/api-routes/export-routes.js +27 -2
  19. package/lib/api-routes/gateway-routes.js +63 -12
  20. package/lib/api-routes/license-routes.js +18 -4
  21. package/lib/api-routes/mailbox-routes.js +33 -7
  22. package/lib/api-routes/message-routes.js +291 -145
  23. package/lib/api-routes/oauth2-app-routes.js +90 -24
  24. package/lib/api-routes/outbox-routes.js +16 -4
  25. package/lib/api-routes/pubsub-routes.js +8 -4
  26. package/lib/api-routes/route-helpers.js +14 -1
  27. package/lib/api-routes/settings-routes.js +51 -25
  28. package/lib/api-routes/stats-routes.js +37 -3
  29. package/lib/api-routes/submit-routes.js +31 -42
  30. package/lib/api-routes/template-routes.js +54 -21
  31. package/lib/api-routes/token-routes.js +67 -67
  32. package/lib/api-routes/webhook-route-routes.js +37 -8
  33. package/lib/autodetect-imap-settings.js +0 -2
  34. package/lib/consts.js +5 -0
  35. package/lib/document-store.js +22 -1
  36. package/lib/email-client/base-client.js +31 -8
  37. package/lib/email-client/gmail-client.js +119 -112
  38. package/lib/email-client/imap/mailbox.js +2 -2
  39. package/lib/email-client/imap/subconnection.js +0 -1
  40. package/lib/email-client/imap/sync-operations.js +1 -1
  41. package/lib/email-client/imap-client.js +36 -17
  42. package/lib/email-client/notification-handler.js +3 -6
  43. package/lib/email-client/outlook-client.js +49 -62
  44. package/lib/export.js +49 -1
  45. package/lib/feature-flags.js +8 -2
  46. package/lib/gateway.js +4 -9
  47. package/lib/get-raw-email.js +5 -5
  48. package/lib/imapproxy/imap-core/lib/imap-connection.js +0 -1
  49. package/lib/license-beacon.js +367 -0
  50. package/lib/logger.js +35 -22
  51. package/lib/metrics-collector.js +0 -2
  52. package/lib/oauth2-apps.js +13 -4
  53. package/lib/outbox.js +24 -40
  54. package/lib/redis-operations.js +1 -1
  55. package/lib/routes-ui.js +2 -1
  56. package/lib/schemas.js +403 -83
  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 +28 -6
  63. package/lib/ui-routes/account-routes.js +7 -4
  64. package/lib/ui-routes/admin-config-routes.js +20 -6
  65. package/lib/ui-routes/document-store-routes.js +7 -1
  66. package/lib/ui-routes/oauth-config-routes.js +0 -2
  67. package/lib/ui-routes/route-helpers.js +0 -2
  68. package/lib/ui-routes/unsubscribe-routes.js +0 -2
  69. package/lib/webhooks.js +8 -4
  70. package/package.json +23 -19
  71. package/sbom.json +1 -1
  72. package/server.js +38 -31
  73. package/static/licenses.html +171 -391
  74. package/translations/de.mo +0 -0
  75. package/translations/de.po +154 -142
  76. package/translations/et.mo +0 -0
  77. package/translations/et.po +129 -131
  78. package/translations/fr.mo +0 -0
  79. package/translations/fr.po +133 -136
  80. package/translations/ja.mo +0 -0
  81. package/translations/ja.po +126 -129
  82. package/translations/messages.pot +107 -107
  83. package/translations/nl.mo +0 -0
  84. package/translations/nl.po +128 -130
  85. package/translations/pl.mo +0 -0
  86. package/translations/pl.po +125 -128
  87. package/update-info.sh +19 -1
  88. package/views/config/logging.hbs +48 -0
  89. package/views/dashboard.hbs +22 -0
  90. package/workers/api.js +33 -37
  91. package/workers/documents.js +2 -22
  92. package/workers/export.js +73 -92
  93. package/workers/imap-proxy.js +3 -23
  94. package/workers/imap.js +2 -22
  95. package/workers/smtp.js +2 -22
  96. package/workers/submit.js +6 -24
  97. package/workers/webhooks.js +2 -22
@@ -910,23 +910,35 @@ class OutlookClient extends BaseClient {
910
910
 
911
911
  let folder;
912
912
  if (!force) {
913
- try {
914
- folder = await this.resolveFolder('\\Trash');
915
-
916
- // If we're already in trash, force delete
917
- if (!force && folder?.specialUse === '\\Trash') {
918
- force = true;
919
- }
913
+ // Fetch the listing once for both folder resolutions
914
+ let mailboxListing = (await this.getCachedMailboxListing()) || (await this.getMailboxListing());
920
915
 
921
- if (!force && !folder) {
922
- throw new Error('Trash folder was not found');
923
- }
916
+ // If the source folder is already the Trash folder, delete permanently
917
+ let sourceFolder;
918
+ try {
919
+ sourceFolder = await this.resolveFolder(path, { mailboxListing });
924
920
  } catch (err) {
925
921
  this.logger.error({
926
- msg: 'Failed to resolve folder for Trash',
922
+ msg: 'Failed to resolve source folder',
923
+ path,
927
924
  err
928
925
  });
929
926
  }
927
+
928
+ if (sourceFolder?.specialUse === '\\Trash') {
929
+ force = true;
930
+ } else {
931
+ folder = await this.resolveFolder('\\Trash', { mailboxListing });
932
+ if (!folder) {
933
+ let error = new Error('Trash folder was not found');
934
+ error.info = {
935
+ response: 'Failed to resolve the Trash folder for the account'
936
+ };
937
+ error.code = 'TrashNotFound';
938
+ error.statusCode = 404;
939
+ throw error;
940
+ }
941
+ }
930
942
  }
931
943
 
932
944
  // Step 1. Resolve matching messages
@@ -1049,27 +1061,15 @@ class OutlookClient extends BaseClient {
1049
1061
  }
1050
1062
 
1051
1063
  /**
1052
- * Update message flags (Graph API only supports \Seen and \Flagged)
1064
+ * Converts an IMAP-style flag update request into Graph API message properties.
1065
+ * Only \Seen and \Flagged can be represented in the Graph API. Set precedence is
1066
+ * handled by BaseClient#normalizeFlagUpdates.
1067
+ * @param {Object} [flags] - Flag update request ({ add, delete, set })
1068
+ * @returns {Object} Graph API PATCH properties ({ isRead, flag })
1053
1069
  */
1054
- async updateMessage(emailId, updates) {
1055
- await this.prepare();
1056
- updates = updates || {};
1057
-
1058
- let addFlags = updates.flags?.add || [];
1059
- let deleteFlags = updates.flags?.delete || [];
1060
-
1061
- // Handle flag set operations
1062
- if (updates.flags?.set) {
1063
- for (let flag of ['\\Seen', '\\Flagged']) {
1064
- if (updates.flags.set.includes(flag)) {
1065
- addFlags.push(flag);
1066
- } else {
1067
- deleteFlags.push(flag);
1068
- }
1069
- }
1070
- }
1070
+ buildFlagUpdates(flags) {
1071
+ let { add: addFlags, delete: deleteFlags } = this.normalizeFlagUpdates(flags, ['\\Seen', '\\Flagged']);
1071
1072
 
1072
- // Map IMAP flags to Graph API properties
1073
1073
  let flagUpdates = {};
1074
1074
 
1075
1075
  if (addFlags.includes('\\Seen')) {
@@ -1086,6 +1086,19 @@ class OutlookClient extends BaseClient {
1086
1086
  flagUpdates.flag = { flagStatus: 'notFlagged' };
1087
1087
  }
1088
1088
 
1089
+ return flagUpdates;
1090
+ }
1091
+
1092
+ /**
1093
+ * Update message flags (Graph API only supports \Seen and \Flagged)
1094
+ */
1095
+ async updateMessage(emailId, updates) {
1096
+ await this.prepare();
1097
+ updates = updates || {};
1098
+
1099
+ // Map IMAP flags to Graph API properties
1100
+ let flagUpdates = this.buildFlagUpdates(updates.flags);
1101
+
1089
1102
  // Handle label (category) operations
1090
1103
  if (updates.labels) {
1091
1104
  let categories;
@@ -1178,36 +1191,8 @@ class OutlookClient extends BaseClient {
1178
1191
 
1179
1192
  updates = updates || {};
1180
1193
 
1181
- let addFlags = updates.flags?.add || [];
1182
- let deleteFlags = updates.flags?.delete || [];
1183
-
1184
- // Handle flag set operations
1185
- if (updates.flags?.set) {
1186
- for (let flag of ['\\Seen', '\\Flagged']) {
1187
- if (updates.flags.set.includes(flag)) {
1188
- addFlags.push(flag);
1189
- } else {
1190
- deleteFlags.push(flag);
1191
- }
1192
- }
1193
- }
1194
-
1195
1194
  // Map IMAP flags to Graph API properties
1196
- let flagUpdates = {};
1197
-
1198
- if (addFlags.includes('\\Seen')) {
1199
- flagUpdates.isRead = true;
1200
- }
1201
- if (deleteFlags.includes('\\Seen')) {
1202
- flagUpdates.isRead = false;
1203
- }
1204
-
1205
- if (addFlags.includes('\\Flagged')) {
1206
- flagUpdates.flag = { flagStatus: 'flagged' };
1207
- }
1208
- if (deleteFlags.includes('\\Flagged')) {
1209
- flagUpdates.flag = { flagStatus: 'notFlagged' };
1210
- }
1195
+ let flagUpdates = this.buildFlagUpdates(updates.flags);
1211
1196
 
1212
1197
  // Step 1. Resolve matching messages
1213
1198
  let emailIds = search.emailIds || (await this.searchEmailIds(path, search));
@@ -1579,7 +1564,7 @@ class OutlookClient extends BaseClient {
1579
1564
 
1580
1565
  const contentResponse = {
1581
1566
  headers: {
1582
- 'content-type': attachmentData.mimeType || 'application/octet-stream',
1567
+ 'content-type': attachmentData.contentType || 'application/octet-stream',
1583
1568
  'content-disposition': 'attachment' + filenameParam
1584
1569
  },
1585
1570
  contentType: attachmentData.contentType,
@@ -3178,8 +3163,9 @@ class OutlookClient extends BaseClient {
3178
3163
 
3179
3164
  path = [].concat(path || []).join('/');
3180
3165
 
3181
- let cachedListing = await this.getCachedMailboxListing();
3182
- let mailboxListing = cachedListing || (await this.getMailboxListing());
3166
+ // Callers that resolve multiple folders can pass a pre-fetched listing to avoid
3167
+ // repeated cache reads or Graph API folder crawls
3168
+ let mailboxListing = options.mailboxListing || (await this.getCachedMailboxListing()) || (await this.getMailboxListing());
3183
3169
 
3184
3170
  if (options.byId) {
3185
3171
  return mailboxListing.find(entry => entry.id === path);
@@ -3749,6 +3735,7 @@ class OutlookClient extends BaseClient {
3749
3735
  bcc: extended && messageData.bccRecipients?.length ? messageData.bccRecipients.map(entry => entry.emailAddress).filter(entry => entry) : undefined,
3750
3736
 
3751
3737
  messageId: messageData.internetMessageId,
3738
+ inReplyTo: messageData.inReplyTo || undefined,
3752
3739
 
3753
3740
  headers: (extended && messageData.headers) || undefined,
3754
3741
 
package/lib/export.js CHANGED
@@ -66,6 +66,50 @@ function createError(message, code, statusCode) {
66
66
  return err;
67
67
  }
68
68
 
69
+ // Error classifiers used by the export worker to decide between retrying, skipping a single
70
+ // message, or failing the whole export job.
71
+
72
+ function isTransientError(err) {
73
+ if (['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH'].includes(err.code)) {
74
+ return true;
75
+ }
76
+ if (err.statusCode >= 500 && err.statusCode < 600) {
77
+ return true;
78
+ }
79
+ if (err.code === 'Timeout' || err.message?.includes('timeout')) {
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ // Account.assertMessageFound reports a missing message as a Boom 404 that carries the
86
+ // machine-readable code and status only in err.output, while client/RPC errors set plain
87
+ // err.code/err.statusCode - check both shapes
88
+ function isSkippableError(err) {
89
+ let errCode = err.output?.payload?.code || err.code;
90
+ let statusCode = err.statusCode || err.output?.statusCode;
91
+ return errCode === 'MessageNotFound' || statusCode === 404 || err.message?.includes('Failed to generate message ID');
92
+ }
93
+
94
+ // Account.listMessages reports unknown folders as FolderNotFound (Boom error with the code in
95
+ // the payload), the Gmail and Outlook backends throw NotFound directly. Outlook bulk delete can
96
+ // also throw TrashNotFound, but the export worker never deletes messages, so it is not matched.
97
+ function isFolderMissingError(err) {
98
+ let errCode = err.output?.payload?.code || err.code;
99
+ return ['FolderNotFound', 'NotFound'].includes(errCode);
100
+ }
101
+
102
+ // Per-message errors worth retrying during a batch export: rate limits, a dropped batch response
103
+ // (Outlook reports these as EMISSING_RESPONSE), and the same transient network/5xx/timeout errors
104
+ // isTransientError already covers. Used by the export worker's API-account batch fetch path so a
105
+ // single transient blip does not fail an entire multi-message export.
106
+ function isRetryableError(err) {
107
+ if (err.statusCode === 429 || ['rateLimitExceeded', 'userRateLimitExceeded', 'EMISSING_RESPONSE'].includes(err.code)) {
108
+ return true;
109
+ }
110
+ return isTransientError(err);
111
+ }
112
+
69
113
  async function tryAddToActiveSet(account, exportId) {
70
114
  const maxConcurrent = (await settings.get('exportMaxConcurrent')) || DEFAULT_EXPORT_MAX_CONCURRENT;
71
115
  const maxGlobalConcurrent = (await settings.get('exportMaxGlobalConcurrent')) || DEFAULT_EXPORT_MAX_GLOBAL_CONCURRENT;
@@ -493,7 +537,7 @@ class Export {
493
537
  continue;
494
538
  }
495
539
 
496
- if (data && (data.status === 'processing' || data.status === 'queued' || data.status === 'indexing' || data.status === 'cancelled')) {
540
+ if (data && (data.status === 'processing' || data.status === 'queued' || data.status === 'cancelled')) {
497
541
  const job = await exportQueue.getJob(exportId).catch(() => null);
498
542
  if (job) {
499
543
  await job.remove().catch(() => {});
@@ -582,5 +626,9 @@ module.exports = {
582
626
  getExportQueueKey,
583
627
  getExportPath,
584
628
  getExportMaxAge,
629
+ isTransientError,
630
+ isSkippableError,
631
+ isFolderMissingError,
632
+ isRetryableError,
585
633
  DEFAULT_EXPORT_MAX_MESSAGE_SIZE
586
634
  };
@@ -21,11 +21,11 @@ for (let key of Object.keys(process.env)) {
21
21
  if (/^EENGINE_FEATURE_/i.test(key)) {
22
22
  let feature = formatFeatureKey(key.substring('EENGINE_FEATURE_'.length));
23
23
  if (feature) {
24
- let value = /^y|1|t/i.test((process.env[key] || '').toString().trim());
24
+ let value = /^(y|yes|true|t|1)$/i.test((process.env[key] || '').toString().trim());
25
25
  if (value) {
26
26
  FEATURES.add(feature);
27
27
  } else {
28
- FEATURES.remove(feature);
28
+ FEATURES.delete(feature);
29
29
  }
30
30
  }
31
31
  }
@@ -34,6 +34,12 @@ for (let key of Object.keys(process.env)) {
34
34
  module.exports = {
35
35
  enabled(key) {
36
36
  return FEATURES.has(formatFeatureKey(key));
37
+ },
38
+
39
+ // Sorted list of the currently enabled feature flag keys. Used by the license beacon
40
+ // to report which EENGINE_FEATURE_* flags are active on this instance.
41
+ listEnabled() {
42
+ return Array.from(FEATURES).sort();
37
43
  }
38
44
  };
39
45
 
package/lib/gateway.js CHANGED
@@ -179,13 +179,8 @@ class Gateway {
179
179
  throw error;
180
180
  }
181
181
 
182
- let state = false;
183
- if (result[0][1] && result[0][1].gateway) {
184
- // existing user
185
- state = 'existing';
186
- } else {
187
- state = 'new';
188
- }
182
+ // HGET returns the previous value if the gateway entry already existed
183
+ let state = result[0][1] ? 'existing' : 'new';
189
184
 
190
185
  return { gateway: this.gateway, state };
191
186
  }
@@ -243,7 +238,7 @@ class Gateway {
243
238
  let result = await this.redis.multi().del(this.getGatewayKey()).srem(`${REDIS_PREFIX}gateways`, this.gateway).exec();
244
239
  if (!result) {
245
240
  return {
246
- account: this.account,
241
+ gateway: this.gateway,
247
242
  deleted: false
248
243
  };
249
244
  }
@@ -256,7 +251,7 @@ class Gateway {
256
251
 
257
252
  if (!result[0] || !result[0][1]) {
258
253
  return {
259
- account: this.account,
254
+ gateway: this.gateway,
260
255
  deleted: false
261
256
  };
262
257
  }
@@ -229,15 +229,15 @@ async function processMessage(data, licenseInfo) {
229
229
  }
230
230
 
231
231
  if (data.headers) {
232
- for (let key of Object.keys(headers)) {
232
+ for (let key of Object.keys(data.headers)) {
233
233
  let casedKey = key.replace(/^.|-./g, c => c.toUpperCase());
234
- switch (key) {
234
+ switch (key.toLowerCase()) {
235
235
  case 'in-reply-to':
236
236
  case 'references':
237
- headers.update(casedKey, headers[key]);
237
+ headers.update(casedKey, data.headers[key]);
238
238
  break;
239
239
  default:
240
- headers.add(casedKey, headers[key]);
240
+ headers.add(casedKey, data.headers[key]);
241
241
  break;
242
242
  }
243
243
  }
@@ -266,7 +266,7 @@ async function processMessage(data, licenseInfo) {
266
266
  });
267
267
 
268
268
  let trackClicks = typeof data.trackClicks === 'boolean' ? data.trackClicks : trackingEnabled;
269
- let trackOpens = typeof data.trackOpens === 'boolean' ? data.trackClicks : trackingEnabled;
269
+ let trackOpens = typeof data.trackOpens === 'boolean' ? data.trackOpens : trackingEnabled;
270
270
 
271
271
  return {
272
272
  raw: message,
@@ -860,7 +860,6 @@ class IMAPConnection extends EventEmitter {
860
860
  if (existsResponse && !changed) {
861
861
  // send cached EXISTS response
862
862
  this.writeStream.write(existsResponse);
863
- existsResponse = false;
864
863
  }
865
864
 
866
865
  if (changed) {