emailengine-app 2.61.5 → 2.62.1

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 (65) hide show
  1. package/CHANGELOG.md +88 -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/autodetect-imap-settings.js +5 -5
  11. package/lib/consts.js +16 -0
  12. package/lib/db.js +3 -0
  13. package/lib/email-client/base-client.js +6 -4
  14. package/lib/email-client/gmail-client.js +205 -35
  15. package/lib/email-client/imap/mailbox.js +99 -8
  16. package/lib/email-client/imap/subconnection.js +5 -5
  17. package/lib/email-client/imap-client.js +76 -19
  18. package/lib/email-client/message-builder.js +3 -1
  19. package/lib/email-client/notification-handler.js +12 -9
  20. package/lib/email-client/outlook-client.js +364 -73
  21. package/lib/email-client/smtp-pool-manager.js +1 -1
  22. package/lib/export.js +528 -0
  23. package/lib/oauth/gmail.js +24 -16
  24. package/lib/oauth/mail-ru.js +26 -13
  25. package/lib/oauth/outlook.js +29 -19
  26. package/lib/oauth/pubsub/google.js +5 -0
  27. package/lib/routes-ui.js +268 -9
  28. package/lib/schemas.js +274 -81
  29. package/lib/stream-encrypt.js +263 -0
  30. package/lib/sub-script.js +2 -2
  31. package/lib/tools.js +194 -12
  32. package/lib/ui-routes/account-routes.js +23 -0
  33. package/lib/ui-routes/admin-config-routes.js +13 -6
  34. package/lib/ui-routes/admin-entities-routes.js +18 -0
  35. package/lib/webhooks.js +16 -20
  36. package/package.json +20 -20
  37. package/sbom.json +1 -1
  38. package/server.js +66 -7
  39. package/static/js/ace/ace.js +1 -1
  40. package/static/js/ace/ext-language_tools.js +1 -1
  41. package/static/licenses.html +118 -149
  42. package/translations/de.mo +0 -0
  43. package/translations/de.po +63 -36
  44. package/translations/en.mo +0 -0
  45. package/translations/en.po +64 -37
  46. package/translations/et.mo +0 -0
  47. package/translations/et.po +63 -36
  48. package/translations/fr.mo +0 -0
  49. package/translations/fr.po +63 -36
  50. package/translations/ja.mo +0 -0
  51. package/translations/ja.po +63 -36
  52. package/translations/messages.pot +84 -51
  53. package/translations/nl.mo +0 -0
  54. package/translations/nl.po +63 -36
  55. package/translations/pl.mo +0 -0
  56. package/translations/pl.po +63 -36
  57. package/views/accounts/account.hbs +375 -2
  58. package/views/config/network.hbs +45 -0
  59. package/views/config/service.hbs +35 -0
  60. package/workers/api.js +130 -47
  61. package/workers/documents.js +3 -2
  62. package/workers/export.js +933 -0
  63. package/workers/imap.js +34 -1
  64. package/workers/submit.js +33 -6
  65. package/workers/webhooks.js +20 -4
@@ -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, httpAgent, 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
 
@@ -112,7 +117,7 @@ class MailRuOauth {
112
117
  Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`
113
118
  },
114
119
  body: tokenRequest.body,
115
- dispatcher: retryAgent
120
+ dispatcher: httpAgent.retry
116
121
  });
117
122
 
118
123
  let responseJson;
@@ -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,14 +194,15 @@ 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,
198
- dispatcher: retryAgent
204
+ body: bodyString,
205
+ dispatcher: httpAgent.retry
199
206
  });
200
207
 
201
208
  let responseJson;
@@ -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
 
@@ -263,18 +271,23 @@ class MailRuOauth {
263
271
  headers: {
264
272
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
265
273
  },
266
- dispatcher: retryAgent
274
+ dispatcher: httpAgent.retry
267
275
  };
268
276
 
269
277
  if (payload) {
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 = httpAgent.fetch;
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, httpAgent, 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
 
@@ -261,7 +266,7 @@ class OutlookOauth {
261
266
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
262
267
  },
263
268
  body: tokenRequest.body,
264
- dispatcher: retryAgent
269
+ dispatcher: httpAgent.retry
265
270
  });
266
271
 
267
272
  let responseJson;
@@ -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,14 +355,15 @@ 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,
359
- dispatcher: retryAgent
365
+ body: bodyString,
366
+ dispatcher: httpAgent.retry
360
367
  });
361
368
 
362
369
  let responseJson;
@@ -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
 
@@ -438,7 +446,7 @@ class OutlookOauth {
438
446
  Authorization: `Bearer ${accessToken}`,
439
447
  'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
440
448
  },
441
- dispatcher: retryAgent
449
+ dispatcher: httpAgent.retry
442
450
  };
443
451
 
444
452
  if (options.headers) {
@@ -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 = httpAgent.fetch;
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
@@ -24,8 +24,10 @@ const {
24
24
  getDuration,
25
25
  parseSignedFormData,
26
26
  hasEnvValue,
27
- retryAgent
27
+ httpAgent,
28
+ reloadHttpProxyAgent
28
29
  } = require('./tools');
30
+ const { parentPort } = require('worker_threads');
29
31
  const { updatePublicInterfaces } = require('./utils/network');
30
32
  const packageData = require('../package.json');
31
33
  const he = require('he');
@@ -46,7 +48,8 @@ const {
46
48
  accountIdSchema,
47
49
  defaultAccountTypeSchema,
48
50
  googleProjectIdSchema,
49
- googleWorkspaceAccountsSchema
51
+ googleWorkspaceAccountsSchema,
52
+ exportIdSchema
50
53
  } = require('./schemas');
51
54
  const fs = require('fs');
52
55
  const pathlib = require('path');
@@ -67,6 +70,7 @@ const { simpleParser } = require('mailparser');
67
70
  const libmime = require('libmime');
68
71
 
69
72
  const adminEntitiesRoutes = require('./ui-routes/admin-entities-routes');
73
+ const { Export } = require('./export');
70
74
 
71
75
  const {
72
76
  DEFAULT_MAX_LOG_LINES,
@@ -869,6 +873,215 @@ function applyRoutes(server, call) {
869
873
  // Initialize admin entity routes (webhooks, templates, gateways, tokens)
870
874
  adminEntitiesRoutes({ server, call });
871
875
 
876
+ // Export routes for session-authenticated UI
877
+
878
+ function throwAsBoom(err) {
879
+ if (Boom.isBoom(err)) {
880
+ throw err;
881
+ }
882
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
883
+ if (err.code) {
884
+ error.output.payload.code = err.code;
885
+ }
886
+ throw error;
887
+ }
888
+
889
+ // List exports for account
890
+ server.route({
891
+ method: 'GET',
892
+ path: '/admin/accounts/{account}/exports',
893
+ async handler(request) {
894
+ try {
895
+ return await Export.list(request.params.account, {
896
+ page: request.query.page,
897
+ pageSize: request.query.pageSize
898
+ });
899
+ } catch (err) {
900
+ request.logger.error({ msg: 'Failed to list exports', err, account: request.params.account });
901
+ throwAsBoom(err);
902
+ }
903
+ },
904
+ options: {
905
+ validate: {
906
+ options: {
907
+ stripUnknown: true,
908
+ abortEarly: false,
909
+ convert: true
910
+ },
911
+ failAction,
912
+ params: Joi.object({
913
+ account: Joi.string().max(256).required()
914
+ }),
915
+ query: Joi.object({
916
+ page: Joi.number().integer().min(0).default(0),
917
+ pageSize: Joi.number().integer().min(1).max(100).default(20)
918
+ })
919
+ }
920
+ }
921
+ });
922
+
923
+ // Get export status
924
+ server.route({
925
+ method: 'GET',
926
+ path: '/admin/accounts/{account}/export/{exportId}',
927
+ async handler(request) {
928
+ try {
929
+ const result = await Export.get(request.params.account, request.params.exportId);
930
+ if (!result) {
931
+ throw Boom.notFound('Export not found');
932
+ }
933
+ return result;
934
+ } catch (err) {
935
+ request.logger.error({ msg: 'Failed to get export', err, account: request.params.account, exportId: request.params.exportId });
936
+ throwAsBoom(err);
937
+ }
938
+ },
939
+ options: {
940
+ validate: {
941
+ options: {
942
+ stripUnknown: true,
943
+ abortEarly: false,
944
+ convert: true
945
+ },
946
+ failAction,
947
+ params: Joi.object({
948
+ account: Joi.string().max(256).required(),
949
+ exportId: exportIdSchema
950
+ })
951
+ }
952
+ }
953
+ });
954
+
955
+ // Create export
956
+ server.route({
957
+ method: 'POST',
958
+ path: '/admin/accounts/{account}/export',
959
+ async handler(request) {
960
+ try {
961
+ return await Export.create(request.params.account, {
962
+ startDate: request.payload.startDate,
963
+ endDate: request.payload.endDate,
964
+ includeAttachments: request.payload.includeAttachments,
965
+ folders: []
966
+ });
967
+ } catch (err) {
968
+ request.logger.error({ msg: 'Failed to create export', err, account: request.params.account });
969
+ throwAsBoom(err);
970
+ }
971
+ },
972
+ options: {
973
+ validate: {
974
+ options: {
975
+ stripUnknown: true,
976
+ abortEarly: false,
977
+ convert: true
978
+ },
979
+ failAction,
980
+ params: Joi.object({
981
+ account: Joi.string().max(256).required()
982
+ }),
983
+ payload: Joi.object({
984
+ startDate: Joi.date().iso().required(),
985
+ endDate: Joi.date().iso().required(),
986
+ includeAttachments: Joi.boolean().default(false)
987
+ })
988
+ }
989
+ }
990
+ });
991
+
992
+ // Delete export
993
+ server.route({
994
+ method: 'DELETE',
995
+ path: '/admin/accounts/{account}/export/{exportId}',
996
+ async handler(request) {
997
+ try {
998
+ const deleted = await Export.delete(request.params.account, request.params.exportId);
999
+ if (!deleted) {
1000
+ throw Boom.notFound('Export not found');
1001
+ }
1002
+ return { deleted: true };
1003
+ } catch (err) {
1004
+ request.logger.error({ msg: 'Failed to delete export', err, account: request.params.account, exportId: request.params.exportId });
1005
+ throwAsBoom(err);
1006
+ }
1007
+ },
1008
+ options: {
1009
+ validate: {
1010
+ options: {
1011
+ stripUnknown: true,
1012
+ abortEarly: false,
1013
+ convert: true
1014
+ },
1015
+ failAction,
1016
+ params: Joi.object({
1017
+ account: Joi.string().max(256).required(),
1018
+ exportId: exportIdSchema
1019
+ })
1020
+ }
1021
+ }
1022
+ });
1023
+
1024
+ // Download export file
1025
+ server.route({
1026
+ method: 'GET',
1027
+ path: '/admin/accounts/{account}/export/{exportId}/download',
1028
+ async handler(request, h) {
1029
+ try {
1030
+ const { account, exportId } = request.params;
1031
+ const fileInfo = await Export.getFile(account, exportId);
1032
+ if (!fileInfo) {
1033
+ throw Boom.notFound('Export not found');
1034
+ }
1035
+
1036
+ const fileReadStream = fs.createReadStream(fileInfo.filePath);
1037
+ let stream = fileReadStream;
1038
+
1039
+ stream.on('error', err => {
1040
+ request.logger.error({ msg: 'Export download stream error', exportId, err });
1041
+ });
1042
+
1043
+ // Decrypt file if encrypted
1044
+ if (fileInfo.isEncrypted) {
1045
+ const secret = await getSecret();
1046
+ if (!secret) {
1047
+ fileReadStream.destroy();
1048
+ throw Boom.serverUnavailable('Encryption secret not available for decryption');
1049
+ }
1050
+ const { createDecryptStream } = require('./stream-encrypt');
1051
+ const decryptStream = await createDecryptStream(secret);
1052
+ decryptStream.on('error', err => {
1053
+ request.logger.error({ msg: 'Export decryption error', exportId, err });
1054
+ fileReadStream.destroy();
1055
+ });
1056
+ stream = fileReadStream.pipe(decryptStream);
1057
+ }
1058
+
1059
+ return h
1060
+ .response(stream)
1061
+ .type('application/gzip')
1062
+ .header('Content-Disposition', `attachment; filename="${fileInfo.filename}"`)
1063
+ .header('Content-Encoding', 'identity');
1064
+ } catch (err) {
1065
+ request.logger.error({ msg: 'Failed to download export', err, account: request.params.account, exportId: request.params.exportId });
1066
+ throwAsBoom(err);
1067
+ }
1068
+ },
1069
+ options: {
1070
+ validate: {
1071
+ options: {
1072
+ stripUnknown: true,
1073
+ abortEarly: false,
1074
+ convert: true
1075
+ },
1076
+ failAction,
1077
+ params: Joi.object({
1078
+ account: Joi.string().max(256).required(),
1079
+ exportId: exportIdSchema
1080
+ })
1081
+ }
1082
+ }
1083
+ });
1084
+
872
1085
  const getDefaultPrompt = async () =>
873
1086
  await call({
874
1087
  cmd: 'openAiDefaultPrompt'
@@ -2230,7 +2443,7 @@ return true;`
2230
2443
  }
2231
2444
  }),
2232
2445
  headers,
2233
- dispatcher: retryAgent
2446
+ dispatcher: httpAgent.retry
2234
2447
  });
2235
2448
  duration = Date.now() - start;
2236
2449
  } catch (err) {
@@ -3293,7 +3506,7 @@ return true;`
3293
3506
  url: (await settings.get('serviceUrl')) || ''
3294
3507
  }),
3295
3508
  headers,
3296
- dispatcher: retryAgent
3509
+ dispatcher: httpAgent.retry
3297
3510
  });
3298
3511
 
3299
3512
  if (!res.ok) {
@@ -4435,6 +4648,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4435
4648
 
4436
4649
  const nonce = data.n || crypto.randomBytes(NONCE_BYTES).toString('base64url');
4437
4650
 
4651
+ // Validate nonce format (base64url, 21-22 chars; also accept base64 for backward compatibility)
4652
+ if (!/^[A-Za-z0-9_\-+/]{21,22}={0,2}$/.test(nonce)) {
4653
+ throw Boom.badRequest('Invalid nonce format. Please generate a new authentication URL.');
4654
+ }
4655
+
4438
4656
  // store account data with atomic SET + EX
4439
4657
  await redis.set(`${REDIS_PREFIX}account:add:${nonce}`, JSON.stringify(accountData), 'EX', Math.floor(MAX_FORM_TTL / 1000));
4440
4658
 
@@ -4725,11 +4943,29 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4725
4943
  case 'EAUTH':
4726
4944
  verifyResult.smtp.error = request.app.gt.gettext('Invalid username or password');
4727
4945
  break;
4946
+ case 'ENOAUTH':
4947
+ verifyResult.smtp.error = request.app.gt.gettext('Authentication credentials were not provided');
4948
+ break;
4949
+ case 'EOAUTH2':
4950
+ verifyResult.smtp.error = request.app.gt.gettext('OAuth2 authentication failed');
4951
+ break;
4952
+ case 'ETLS':
4953
+ verifyResult.smtp.error = request.app.gt.gettext('TLS protocol error');
4954
+ break;
4728
4955
  case 'ESOCKET':
4729
4956
  if (/openssl/.test(verifyResult.smtp.error)) {
4730
4957
  verifyResult.smtp.error = request.app.gt.gettext('TLS protocol error');
4731
4958
  }
4732
4959
  break;
4960
+ case 'ETIMEDOUT':
4961
+ verifyResult.smtp.error = request.app.gt.gettext('Connection timed out');
4962
+ break;
4963
+ case 'ECONNECTION':
4964
+ verifyResult.smtp.error = request.app.gt.gettext('Could not connect to server');
4965
+ break;
4966
+ case 'EPROTOCOL':
4967
+ verifyResult.smtp.error = request.app.gt.gettext('Unexpected server response');
4968
+ break;
4733
4969
  }
4734
4970
  }
4735
4971
  }
@@ -6547,6 +6783,8 @@ return payload;`
6547
6783
  let proxyEnabled = await settings.get('proxyEnabled');
6548
6784
  let proxyUrl = await settings.get('proxyUrl');
6549
6785
  let smtpEhloName = await settings.get('smtpEhloName');
6786
+ let httpProxyEnabled = await settings.get('httpProxyEnabled');
6787
+ let httpProxyUrl = await settings.get('httpProxyUrl');
6550
6788
 
6551
6789
  let localAddresses = [].concat((await settings.get('localAddresses')) || []);
6552
6790
 
@@ -6566,7 +6804,9 @@ return payload;`
6566
6804
  values: {
6567
6805
  proxyEnabled,
6568
6806
  proxyUrl,
6569
- smtpEhloName
6807
+ smtpEhloName,
6808
+ httpProxyEnabled,
6809
+ httpProxyUrl
6570
6810
  },
6571
6811
 
6572
6812
  addresses: await listPublicInterfaces(localAddresses),
@@ -6619,10 +6859,26 @@ return payload;`
6619
6859
  path: '/admin/config/network',
6620
6860
  async handler(request, h) {
6621
6861
  try {
6622
- for (let key of ['smtpStrategy', 'imapStrategy', 'localAddresses', 'proxyUrl', 'smtpEhloName', 'proxyEnabled']) {
6862
+ for (let key of [
6863
+ 'smtpStrategy',
6864
+ 'imapStrategy',
6865
+ 'localAddresses',
6866
+ 'proxyUrl',
6867
+ 'smtpEhloName',
6868
+ 'proxyEnabled',
6869
+ 'httpProxyEnabled',
6870
+ 'httpProxyUrl'
6871
+ ]) {
6623
6872
  await settings.set(key, request.payload[key]);
6624
6873
  }
6625
6874
 
6875
+ await reloadHttpProxyAgent();
6876
+
6877
+ // Notify other workers about settings change
6878
+ if (parentPort) {
6879
+ parentPort.postMessage({ cmd: 'settings', data: request.payload });
6880
+ }
6881
+
6626
6882
  await request.flash({ type: 'info', message: `Configuration updated` });
6627
6883
 
6628
6884
  return h.redirect('/admin/config/network');
@@ -6706,7 +6962,10 @@ return payload;`
6706
6962
 
6707
6963
  proxyUrl: settingsSchema.proxyUrl,
6708
6964
  smtpEhloName: settingsSchema.smtpEhloName,
6709
- proxyEnabled: settingsSchema.proxyEnabled
6965
+ proxyEnabled: settingsSchema.proxyEnabled,
6966
+
6967
+ httpProxyEnabled: settingsSchema.httpProxyEnabled,
6968
+ httpProxyUrl: settingsSchema.httpProxyUrl
6710
6969
  })
6711
6970
  }
6712
6971
  }
@@ -7241,7 +7500,7 @@ Token: ${JSON.stringify(request.params.token)}`
7241
7500
  requestor: '@postalsys/emailengine-app'
7242
7501
  }),
7243
7502
  headers,
7244
- dispatcher: retryAgent
7503
+ dispatcher: httpAgent.retry
7245
7504
  });
7246
7505
 
7247
7506
  if (!res.ok) {
@@ -7352,7 +7611,7 @@ ${now}`,
7352
7611
  let res = await fetchCmd(`${SMTP_TEST_HOST}/test-address/${user}`, {
7353
7612
  method: 'get',
7354
7613
  headers,
7355
- dispatcher: retryAgent
7614
+ dispatcher: httpAgent.retry
7356
7615
  });
7357
7616
 
7358
7617
  if (!res.ok) {