emailengine-app 2.61.5 → 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 +78 -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 +235 -1
  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 +23 -0
  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 +16 -16
  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 +52 -62
  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 +80 -47
  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 +123 -44
  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
@@ -2,7 +2,7 @@
2
2
 
3
3
  const packageData = require('../../package.json');
4
4
  const { fetch: fetchCmd } = require('undici');
5
- const { formatPartialSecretKey, structuredClone, retryAgent } = require('../tools');
5
+ const { formatPartialSecretKey, structuredClone, fetchAgent, retryAgent, formatTokenError } = require('../tools');
6
6
 
7
7
  const MAIL_RU_SCOPES = ['userinfo', 'mail.imap'];
8
8
 
@@ -18,11 +18,16 @@ const checkForFlags = err => {
18
18
  };
19
19
 
20
20
  const formatFetchBody = (searchParams, logRaw) => {
21
- let data = Object.fromEntries(searchParams);
21
+ let entries = typeof searchParams === 'string' ? new URLSearchParams(searchParams) : searchParams;
22
+ let data = Object.fromEntries(entries);
23
+
24
+ if (logRaw) {
25
+ return data;
26
+ }
22
27
 
23
28
  for (let key of ['refresh_token', 'client_secret']) {
24
29
  if (data[key]) {
25
- data[key] = logRaw ? data[key] : formatPartialSecretKey(data[key]);
30
+ data[key] = formatPartialSecretKey(data[key]);
26
31
  }
27
32
  }
28
33
 
@@ -92,7 +97,7 @@ class MailRuOauth {
92
97
 
93
98
  return {
94
99
  url: url.origin + url.pathname,
95
- body: url.searchParams
100
+ body: url.searchParams.toString()
96
101
  };
97
102
  }
98
103
 
@@ -154,7 +159,7 @@ class MailRuOauth {
154
159
  code
155
160
  };
156
161
  try {
157
- err.tokenRequest.response = responseJson;
162
+ err.tokenRequest.response = responseJson || { error: 'Failed to parse response' };
158
163
 
159
164
  let flag = checkForFlags(err.tokenRequest.response);
160
165
  if (flag) {
@@ -164,6 +169,7 @@ class MailRuOauth {
164
169
  } catch (e) {
165
170
  // ignore
166
171
  }
172
+ err.message = formatTokenError(this.provider, err.tokenRequest);
167
173
  throw err;
168
174
  }
169
175
 
@@ -188,13 +194,14 @@ class MailRuOauth {
188
194
  let requestUrl = url.origin + url.pathname;
189
195
  let method = 'post';
190
196
 
197
+ const bodyString = url.searchParams.toString();
191
198
  let res = await fetchCmd(requestUrl, {
192
- method: 'post',
199
+ method,
193
200
  headers: {
194
201
  'Content-Type': 'application/x-www-form-urlencoded',
195
202
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
196
203
  },
197
- body: url.searchParams,
204
+ body: bodyString,
198
205
  dispatcher: retryAgent
199
206
  });
200
207
 
@@ -236,7 +243,7 @@ class MailRuOauth {
236
243
  scopes: this.scopes
237
244
  };
238
245
  try {
239
- err.tokenRequest.response = responseJson;
246
+ err.tokenRequest.response = responseJson || { error: 'Failed to parse response' };
240
247
 
241
248
  let flag = checkForFlags(err.tokenRequest.response);
242
249
 
@@ -247,6 +254,7 @@ class MailRuOauth {
247
254
  } catch (e) {
248
255
  // ignore
249
256
  }
257
+ err.message = formatTokenError(this.provider, err.tokenRequest);
250
258
  throw err;
251
259
  }
252
260
 
@@ -270,11 +278,16 @@ class MailRuOauth {
270
278
  if (!Buffer.isBuffer(payload)) {
271
279
  reqData.headers.Accept = 'application/json';
272
280
  reqData.headers['Content-Type'] = options?.contentType || 'application/json';
273
- payload = Buffer.from(JSON.stringify(payload));
281
+ // Use string body instead of Buffer to avoid ArrayBuffer detachment on retry
282
+ reqData.body = JSON.stringify(payload);
274
283
  } else {
275
284
  reqData.headers['Content-Type'] = options?.contentType || 'application/x-www-form-urlencoded';
285
+ reqData.body = payload;
286
+ if (payload.length > 0) {
287
+ // Non-empty buffers use non-retry dispatcher to prevent ArrayBuffer detachment
288
+ reqData.dispatcher = fetchAgent;
289
+ }
276
290
  }
277
- reqData.body = payload;
278
291
  }
279
292
 
280
293
  const requestUrl = new URL(url);
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const packageData = require('../../package.json');
4
- const { formatPartialSecretKey, structuredClone, retryAgent } = require('../tools');
4
+ const { formatPartialSecretKey, structuredClone, fetchAgent, retryAgent, formatTokenError } = require('../tools');
5
5
 
6
6
  const { fetch: fetchCmd } = require('undici');
7
7
 
@@ -127,11 +127,16 @@ const checkForUserFlags = err => {
127
127
  };
128
128
 
129
129
  const formatFetchBody = (searchParams, logRaw) => {
130
- let data = Object.fromEntries(searchParams);
130
+ let entries = typeof searchParams === 'string' ? new URLSearchParams(searchParams) : searchParams;
131
+ let data = Object.fromEntries(entries);
132
+
133
+ if (logRaw) {
134
+ return data;
135
+ }
131
136
 
132
137
  for (let key of ['refresh_token', 'client_secret']) {
133
138
  if (data[key]) {
134
- data[key] = logRaw ? data[key] : formatPartialSecretKey(data[key]);
139
+ data[key] = formatPartialSecretKey(data[key]);
135
140
  }
136
141
  }
137
142
 
@@ -242,7 +247,7 @@ class OutlookOauth {
242
247
 
243
248
  return {
244
249
  url: url.origin + url.pathname,
245
- body: url.searchParams
250
+ body: url.searchParams.toString()
246
251
  };
247
252
  }
248
253
 
@@ -304,9 +309,9 @@ class OutlookOauth {
304
309
  code
305
310
  };
306
311
  try {
307
- err.tokenRequest.response = responseJson;
312
+ err.tokenRequest.response = responseJson || { error: 'Failed to parse response' };
308
313
 
309
- if (EXPOSE_PARTIAL_SECRET_KEY_REGEX.test(err.tokenRequest.response.error_description)) {
314
+ if (EXPOSE_PARTIAL_SECRET_KEY_REGEX.test(err.tokenRequest.response?.error_description)) {
310
315
  // key might have been invalidated or renewed
311
316
  err.tokenRequest.clientSecret = formatPartialSecretKey(this.clientSecret);
312
317
  }
@@ -319,6 +324,7 @@ class OutlookOauth {
319
324
  } catch (e) {
320
325
  // ignore
321
326
  }
327
+ err.message = formatTokenError(this.provider, err.tokenRequest);
322
328
  throw err;
323
329
  }
324
330
 
@@ -349,13 +355,14 @@ class OutlookOauth {
349
355
  let requestUrl = url.origin + url.pathname;
350
356
  let method = 'post';
351
357
 
358
+ const bodyString = url.searchParams.toString();
352
359
  let res = await fetchCmd(requestUrl, {
353
- method: 'post',
360
+ method,
354
361
  headers: {
355
362
  'Content-Type': 'application/x-www-form-urlencoded',
356
363
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
357
364
  },
358
- body: url.searchParams,
365
+ body: bodyString,
359
366
  dispatcher: retryAgent
360
367
  });
361
368
 
@@ -372,7 +379,7 @@ class OutlookOauth {
372
379
  this.logger.info({
373
380
  msg: 'OAuth2 authentication request',
374
381
  action: 'oauth2Fetch',
375
- fn: 'getToken',
382
+ fn: 'refreshToken',
376
383
  method,
377
384
  url: requestUrl,
378
385
  success: !!res.ok,
@@ -398,9 +405,9 @@ class OutlookOauth {
398
405
  scopes: this.scopes
399
406
  };
400
407
  try {
401
- err.tokenRequest.response = responseJson;
408
+ err.tokenRequest.response = responseJson || { error: 'Failed to parse response' };
402
409
 
403
- if (EXPOSE_PARTIAL_SECRET_KEY_REGEX.test(err.tokenRequest.response.error_description)) {
410
+ if (EXPOSE_PARTIAL_SECRET_KEY_REGEX.test(err.tokenRequest.response?.error_description)) {
404
411
  // key might have been invalidated or renewed
405
412
  err.tokenRequest.clientSecret = formatPartialSecretKey(this.clientSecret);
406
413
  }
@@ -418,6 +425,7 @@ class OutlookOauth {
418
425
  } catch (e) {
419
426
  // ignore
420
427
  }
428
+ err.message = formatTokenError(this.provider, err.tokenRequest);
421
429
  throw err;
422
430
  }
423
431
 
@@ -449,11 +457,16 @@ class OutlookOauth {
449
457
  if (!Buffer.isBuffer(payload)) {
450
458
  reqData.headers.Accept = 'application/json';
451
459
  reqData.headers['Content-Type'] = options?.contentType || 'application/json';
452
- payload = Buffer.from(JSON.stringify(payload));
460
+ // Use string body instead of Buffer to avoid ArrayBuffer detachment on retry
461
+ reqData.body = JSON.stringify(payload);
453
462
  } else {
454
463
  reqData.headers['Content-Type'] = options?.contentType || 'application/x-www-form-urlencoded';
464
+ reqData.body = payload;
465
+ if (payload.length > 0) {
466
+ // Non-empty buffers use non-retry dispatcher to prevent ArrayBuffer detachment
467
+ reqData.dispatcher = fetchAgent;
468
+ }
455
469
  }
456
- reqData.body = payload;
457
470
  } else if (payload && method === 'get') {
458
471
  let parsedUrl = new URL(url);
459
472
  for (let key of Object.keys(payload)) {
@@ -465,8 +478,6 @@ class OutlookOauth {
465
478
  url = parsedUrl.href;
466
479
  }
467
480
 
468
- let retryCount = 0;
469
-
470
481
  let startTime = Date.now();
471
482
  let res = await fetchCmd(url, reqData);
472
483
  let reqTime = Date.now() - startTime;
@@ -481,7 +492,6 @@ class OutlookOauth {
481
492
  status: res.status,
482
493
  clientId: this.clientId,
483
494
  scopes: this.scopes,
484
- retryCount,
485
495
  reqTime
486
496
  };
487
497
 
@@ -202,6 +202,11 @@ class PubSubInstance {
202
202
 
203
203
  await oauth2Apps.setMeta(this.app, { pubSubFlag: null });
204
204
  } catch (err) {
205
+ // Transient network errors are expected for long-polling connections
206
+ if (['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT'].includes(err.code)) {
207
+ logger.warn({ msg: 'Transient error pulling subscription messages', app: this.app, code: err.code });
208
+ return;
209
+ }
205
210
  logger.error({ msg: 'Failed to pull subscription messages', app: this.app, err });
206
211
  throw err;
207
212
  }
package/lib/routes-ui.js CHANGED
@@ -46,7 +46,8 @@ const {
46
46
  accountIdSchema,
47
47
  defaultAccountTypeSchema,
48
48
  googleProjectIdSchema,
49
- googleWorkspaceAccountsSchema
49
+ googleWorkspaceAccountsSchema,
50
+ exportIdSchema
50
51
  } = require('./schemas');
51
52
  const fs = require('fs');
52
53
  const pathlib = require('path');
@@ -67,6 +68,7 @@ const { simpleParser } = require('mailparser');
67
68
  const libmime = require('libmime');
68
69
 
69
70
  const adminEntitiesRoutes = require('./ui-routes/admin-entities-routes');
71
+ const { Export } = require('./export');
70
72
 
71
73
  const {
72
74
  DEFAULT_MAX_LOG_LINES,
@@ -869,6 +871,215 @@ function applyRoutes(server, call) {
869
871
  // Initialize admin entity routes (webhooks, templates, gateways, tokens)
870
872
  adminEntitiesRoutes({ server, call });
871
873
 
874
+ // Export routes for session-authenticated UI
875
+
876
+ function throwAsBoom(err) {
877
+ if (Boom.isBoom(err)) {
878
+ throw err;
879
+ }
880
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
881
+ if (err.code) {
882
+ error.output.payload.code = err.code;
883
+ }
884
+ throw error;
885
+ }
886
+
887
+ // List exports for account
888
+ server.route({
889
+ method: 'GET',
890
+ path: '/admin/accounts/{account}/exports',
891
+ async handler(request) {
892
+ try {
893
+ return await Export.list(request.params.account, {
894
+ page: request.query.page,
895
+ pageSize: request.query.pageSize
896
+ });
897
+ } catch (err) {
898
+ request.logger.error({ msg: 'Failed to list exports', err, account: request.params.account });
899
+ throwAsBoom(err);
900
+ }
901
+ },
902
+ options: {
903
+ validate: {
904
+ options: {
905
+ stripUnknown: true,
906
+ abortEarly: false,
907
+ convert: true
908
+ },
909
+ failAction,
910
+ params: Joi.object({
911
+ account: Joi.string().max(256).required()
912
+ }),
913
+ query: Joi.object({
914
+ page: Joi.number().integer().min(0).default(0),
915
+ pageSize: Joi.number().integer().min(1).max(100).default(20)
916
+ })
917
+ }
918
+ }
919
+ });
920
+
921
+ // Get export status
922
+ server.route({
923
+ method: 'GET',
924
+ path: '/admin/accounts/{account}/export/{exportId}',
925
+ async handler(request) {
926
+ try {
927
+ const result = await Export.get(request.params.account, request.params.exportId);
928
+ if (!result) {
929
+ throw Boom.notFound('Export not found');
930
+ }
931
+ return result;
932
+ } catch (err) {
933
+ request.logger.error({ msg: 'Failed to get export', err, account: request.params.account, exportId: request.params.exportId });
934
+ throwAsBoom(err);
935
+ }
936
+ },
937
+ options: {
938
+ validate: {
939
+ options: {
940
+ stripUnknown: true,
941
+ abortEarly: false,
942
+ convert: true
943
+ },
944
+ failAction,
945
+ params: Joi.object({
946
+ account: Joi.string().max(256).required(),
947
+ exportId: exportIdSchema
948
+ })
949
+ }
950
+ }
951
+ });
952
+
953
+ // Create export
954
+ server.route({
955
+ method: 'POST',
956
+ path: '/admin/accounts/{account}/export',
957
+ async handler(request) {
958
+ try {
959
+ return await Export.create(request.params.account, {
960
+ startDate: request.payload.startDate,
961
+ endDate: request.payload.endDate,
962
+ includeAttachments: request.payload.includeAttachments,
963
+ folders: []
964
+ });
965
+ } catch (err) {
966
+ request.logger.error({ msg: 'Failed to create export', err, account: request.params.account });
967
+ throwAsBoom(err);
968
+ }
969
+ },
970
+ options: {
971
+ validate: {
972
+ options: {
973
+ stripUnknown: true,
974
+ abortEarly: false,
975
+ convert: true
976
+ },
977
+ failAction,
978
+ params: Joi.object({
979
+ account: Joi.string().max(256).required()
980
+ }),
981
+ payload: Joi.object({
982
+ startDate: Joi.date().iso().required(),
983
+ endDate: Joi.date().iso().required(),
984
+ includeAttachments: Joi.boolean().default(false)
985
+ })
986
+ }
987
+ }
988
+ });
989
+
990
+ // Delete export
991
+ server.route({
992
+ method: 'DELETE',
993
+ path: '/admin/accounts/{account}/export/{exportId}',
994
+ async handler(request) {
995
+ try {
996
+ const deleted = await Export.delete(request.params.account, request.params.exportId);
997
+ if (!deleted) {
998
+ throw Boom.notFound('Export not found');
999
+ }
1000
+ return { deleted: true };
1001
+ } catch (err) {
1002
+ request.logger.error({ msg: 'Failed to delete export', err, account: request.params.account, exportId: request.params.exportId });
1003
+ throwAsBoom(err);
1004
+ }
1005
+ },
1006
+ options: {
1007
+ validate: {
1008
+ options: {
1009
+ stripUnknown: true,
1010
+ abortEarly: false,
1011
+ convert: true
1012
+ },
1013
+ failAction,
1014
+ params: Joi.object({
1015
+ account: Joi.string().max(256).required(),
1016
+ exportId: exportIdSchema
1017
+ })
1018
+ }
1019
+ }
1020
+ });
1021
+
1022
+ // Download export file
1023
+ server.route({
1024
+ method: 'GET',
1025
+ path: '/admin/accounts/{account}/export/{exportId}/download',
1026
+ async handler(request, h) {
1027
+ try {
1028
+ const { account, exportId } = request.params;
1029
+ const fileInfo = await Export.getFile(account, exportId);
1030
+ if (!fileInfo) {
1031
+ throw Boom.notFound('Export not found');
1032
+ }
1033
+
1034
+ const fileReadStream = fs.createReadStream(fileInfo.filePath);
1035
+ let stream = fileReadStream;
1036
+
1037
+ stream.on('error', err => {
1038
+ request.logger.error({ msg: 'Export download stream error', exportId, err });
1039
+ });
1040
+
1041
+ // Decrypt file if encrypted
1042
+ if (fileInfo.isEncrypted) {
1043
+ const secret = await getSecret();
1044
+ if (!secret) {
1045
+ fileReadStream.destroy();
1046
+ throw Boom.serverUnavailable('Encryption secret not available for decryption');
1047
+ }
1048
+ const { createDecryptStream } = require('./stream-encrypt');
1049
+ const decryptStream = await createDecryptStream(secret);
1050
+ decryptStream.on('error', err => {
1051
+ request.logger.error({ msg: 'Export decryption error', exportId, err });
1052
+ fileReadStream.destroy();
1053
+ });
1054
+ stream = fileReadStream.pipe(decryptStream);
1055
+ }
1056
+
1057
+ return h
1058
+ .response(stream)
1059
+ .type('application/gzip')
1060
+ .header('Content-Disposition', `attachment; filename="${fileInfo.filename}"`)
1061
+ .header('Content-Encoding', 'identity');
1062
+ } catch (err) {
1063
+ request.logger.error({ msg: 'Failed to download export', err, account: request.params.account, exportId: request.params.exportId });
1064
+ throwAsBoom(err);
1065
+ }
1066
+ },
1067
+ options: {
1068
+ validate: {
1069
+ options: {
1070
+ stripUnknown: true,
1071
+ abortEarly: false,
1072
+ convert: true
1073
+ },
1074
+ failAction,
1075
+ params: Joi.object({
1076
+ account: Joi.string().max(256).required(),
1077
+ exportId: exportIdSchema
1078
+ })
1079
+ }
1080
+ }
1081
+ });
1082
+
872
1083
  const getDefaultPrompt = async () =>
873
1084
  await call({
874
1085
  cmd: 'openAiDefaultPrompt'
@@ -4435,6 +4646,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4435
4646
 
4436
4647
  const nonce = data.n || crypto.randomBytes(NONCE_BYTES).toString('base64url');
4437
4648
 
4649
+ // Validate nonce format (base64url, 21-22 chars; also accept base64 for backward compatibility)
4650
+ if (!/^[A-Za-z0-9_\-+/]{21,22}={0,2}$/.test(nonce)) {
4651
+ throw Boom.badRequest('Invalid nonce format. Please generate a new authentication URL.');
4652
+ }
4653
+
4438
4654
  // store account data with atomic SET + EX
4439
4655
  await redis.set(`${REDIS_PREFIX}account:add:${nonce}`, JSON.stringify(accountData), 'EX', Math.floor(MAX_FORM_TTL / 1000));
4440
4656
 
@@ -4725,11 +4941,29 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4725
4941
  case 'EAUTH':
4726
4942
  verifyResult.smtp.error = request.app.gt.gettext('Invalid username or password');
4727
4943
  break;
4944
+ case 'ENOAUTH':
4945
+ verifyResult.smtp.error = request.app.gt.gettext('Authentication credentials were not provided');
4946
+ break;
4947
+ case 'EOAUTH2':
4948
+ verifyResult.smtp.error = request.app.gt.gettext('OAuth2 authentication failed');
4949
+ break;
4950
+ case 'ETLS':
4951
+ verifyResult.smtp.error = request.app.gt.gettext('TLS protocol error');
4952
+ break;
4728
4953
  case 'ESOCKET':
4729
4954
  if (/openssl/.test(verifyResult.smtp.error)) {
4730
4955
  verifyResult.smtp.error = request.app.gt.gettext('TLS protocol error');
4731
4956
  }
4732
4957
  break;
4958
+ case 'ETIMEDOUT':
4959
+ verifyResult.smtp.error = request.app.gt.gettext('Connection timed out');
4960
+ break;
4961
+ case 'ECONNECTION':
4962
+ verifyResult.smtp.error = request.app.gt.gettext('Could not connect to server');
4963
+ break;
4964
+ case 'EPROTOCOL':
4965
+ verifyResult.smtp.error = request.app.gt.gettext('Unexpected server response');
4966
+ break;
4733
4967
  }
4734
4968
  }
4735
4969
  }