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
@@ -32,6 +32,14 @@
32
32
  data-toggle="tooltip" data-placement="top">
33
33
  <i class="fas fa-redo fa-fw" id="sync-icon"></i> Run sync
34
34
  </button>
35
+
36
+ {{#if canReadMail}}
37
+ <button type="button" class="btn btn-light" id="request-export" title="Export messages to file"
38
+ data-toggle="modal" data-target="#exportModal">
39
+ <i class="fas fa-file-export fa-fw"></i> Export
40
+ <span id="export-status-indicator" class="badge badge-pill ml-1 d-none"></span>
41
+ </button>
42
+ {{/if}}
35
43
  </div>
36
44
 
37
45
  <div class="btn-group mr-2 mb-1" role="group" aria-label="Third group">
@@ -64,7 +72,7 @@
64
72
  {{#if canUseSmtp}}
65
73
  <a class="dropdown-item" data-toggle="modal" data-target="#testSendModal" data-keyboard="false"
66
74
  data-backdrop="static" data-account="{{account.account}}" href="#">Account's
67
- SMTP server</a>
75
+ Mail server</a>
68
76
  {{#if gateways}}
69
77
  <div class="dropdown-divider"></div>
70
78
  {{/if}}
@@ -935,6 +943,85 @@
935
943
  </div>
936
944
  </div>
937
945
 
946
+ <div class="modal fade" id="exportModal" tabindex="-1" aria-labelledby="exportModalLabel" aria-hidden="true">
947
+ <div class="modal-dialog">
948
+ <div class="modal-content">
949
+ <div class="modal-header">
950
+ <h5 class="modal-title" id="exportModalLabel">Export Messages</h5>
951
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
952
+ <span aria-hidden="true">&times;</span>
953
+ </button>
954
+ </div>
955
+ <div class="modal-body">
956
+ <div class="alert alert-info mb-3">
957
+ <i class="fas fa-flask fa-fw"></i>
958
+ <strong>Beta Feature:</strong> The export feature is currently in beta. If you encounter any issues,
959
+ please contact <a href="mailto:support@postalsys.com">support@postalsys.com</a>.
960
+ </div>
961
+ <div id="export-form-view">
962
+ <div class="form-group">
963
+ <label for="export-start-date">Start Date</label>
964
+ <input type="date" class="form-control" id="export-start-date">
965
+ </div>
966
+ <div class="form-group">
967
+ <label for="export-end-date">End Date</label>
968
+ <input type="date" class="form-control" id="export-end-date">
969
+ </div>
970
+ <div class="form-group">
971
+ <div class="custom-control custom-checkbox">
972
+ <input type="checkbox" class="custom-control-input" id="export-include-attachments">
973
+ <label class="custom-control-label" for="export-include-attachments">Include
974
+ attachments</label>
975
+ </div>
976
+ <small class="form-text text-muted">Download and include attachment file contents in the export.
977
+ This increases export size and time.</small>
978
+ </div>
979
+ <small class="text-muted">Gmail accounts export from All Mail. Other accounts export all folders
980
+ (excluding Junk and Trash).</small>
981
+ </div>
982
+
983
+ <div id="export-status-view" class="d-none">
984
+ <div class="mb-3">
985
+ <strong>Status:</strong>
986
+ <span id="export-status-badge" class="badge badge-info">Queued</span>
987
+ <span id="export-phase-badge" class="badge badge-secondary ml-1 d-none"></span>
988
+ </div>
989
+ <div class="mb-3">
990
+ <strong>Expires:</strong>
991
+ <span id="export-expires-at" class="text-muted"></span>
992
+ </div>
993
+ <div id="export-progress-section">
994
+ <div class="progress mb-2">
995
+ <div id="export-progress-bar"
996
+ class="progress-bar progress-bar-striped progress-bar-animated bg-info"
997
+ role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
998
+ style="width: 0%"></div>
999
+ </div>
1000
+ <small id="export-progress-text" class="text-muted"></small>
1001
+ </div>
1002
+ <div id="export-error-section" class="d-none">
1003
+ <div class="alert alert-danger" id="export-error-message"></div>
1004
+ </div>
1005
+ <div id="export-download-section" class="d-none mt-3">
1006
+ <a href="#" id="export-download-link" class="btn btn-success btn-block">
1007
+ <i class="fas fa-download fa-fw"></i> Download Export
1008
+ </a>
1009
+ </div>
1010
+ </div>
1011
+ </div>
1012
+ <div class="modal-footer">
1013
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
1014
+ <button type="button" class="btn btn-danger d-none" id="export-reset-btn">
1015
+ <i class="fas fa-trash fa-fw"></i> Reset
1016
+ </button>
1017
+ <button type="button" class="btn btn-primary" id="export-start-btn">
1018
+ <i class="fas fa-file-export fa-fw" id="export-start-icon"></i> Start Export
1019
+ </button>
1020
+ </div>
1021
+ </div>
1022
+ </div>
1023
+ </div>
1024
+
938
1025
  <input type="hidden" id="account-id" value="{{account.account}}">
939
1026
  <input type="hidden" id="crumb" value="{{crumb}}">
940
1027
 
@@ -1107,7 +1194,7 @@
1107
1194
  flushingLogs = false;
1108
1195
  showToast(data.error ? data.error : (data.success ? 'Stored logs were flushed' : 'Failed to flush logs'), data.success ? 'check-circle' : 'alert-triangle');
1109
1196
  }).catch(err => {
1110
- togglinflushingLogsgLogs = false;
1197
+ flushingLogs = false;
1111
1198
  showToast('Request failed\n' + err.message, 'alert-triangle');
1112
1199
  })
1113
1200
  });
@@ -1121,6 +1208,292 @@
1121
1208
 
1122
1209
  $('#renew-grant-btn').tooltip();
1123
1210
  $('#renew-grant-btn').click(() => $('#renew-grant-btn').tooltip('hide'));
1211
+
1212
+ // Export status indicator on page load
1213
+ async function updateExportIndicator() {
1214
+ const account = document.getElementById('account-id').value.trim();
1215
+ const indicator = document.getElementById('export-status-indicator');
1216
+ if (!indicator) return;
1217
+
1218
+ try {
1219
+ const res = await fetch(`/admin/accounts/${account}/exports?pageSize=1`);
1220
+ if (!res.ok) {
1221
+ indicator.classList.add('d-none');
1222
+ return;
1223
+ }
1224
+ const data = await res.json();
1225
+
1226
+ if (data.exports && data.exports.length > 0) {
1227
+ const latest = data.exports[0];
1228
+ if (latest.status === 'queued' || latest.status === 'processing') {
1229
+ indicator.textContent = latest.status === 'queued' ? 'Queued' : 'Exporting';
1230
+ indicator.className = 'badge badge-pill badge-info ml-1';
1231
+ } else if (latest.status === 'completed') {
1232
+ indicator.textContent = 'Ready';
1233
+ indicator.className = 'badge badge-pill badge-success ml-1';
1234
+ } else {
1235
+ indicator.classList.add('d-none');
1236
+ return;
1237
+ }
1238
+ indicator.classList.remove('d-none');
1239
+ } else {
1240
+ indicator.classList.add('d-none');
1241
+ }
1242
+ } catch (err) {
1243
+ console.error('Failed to check export status', err);
1244
+ indicator.classList.add('d-none');
1245
+ }
1246
+ }
1247
+
1248
+ // Check export status on page load
1249
+ updateExportIndicator();
1250
+
1251
+ // Export modal functionality
1252
+ let currentExportId = null;
1253
+ let exportPollingInterval = null;
1254
+
1255
+ function showFormView() {
1256
+ document.getElementById('export-form-view').classList.remove('d-none');
1257
+ document.getElementById('export-status-view').classList.add('d-none');
1258
+ document.getElementById('export-start-btn').classList.remove('d-none');
1259
+ document.getElementById('export-reset-btn').classList.add('d-none');
1260
+
1261
+ // Set default dates (last 1 year)
1262
+ const today = new Date();
1263
+ const lastYear = new Date(today);
1264
+ lastYear.setFullYear(lastYear.getFullYear() - 1);
1265
+ document.getElementById('export-end-date').value = today.toISOString().split('T')[0];
1266
+ document.getElementById('export-start-date').value = lastYear.toISOString().split('T')[0];
1267
+ }
1268
+
1269
+ function showStatusView() {
1270
+ document.getElementById('export-form-view').classList.add('d-none');
1271
+ document.getElementById('export-status-view').classList.remove('d-none');
1272
+ document.getElementById('export-start-btn').classList.add('d-none');
1273
+ document.getElementById('export-reset-btn').classList.remove('d-none');
1274
+ }
1275
+
1276
+ function getStatusBadgeClass(status) {
1277
+ switch (status) {
1278
+ case 'completed': return 'success';
1279
+ case 'failed': return 'danger';
1280
+ case 'processing': return 'info';
1281
+ default: return 'secondary';
1282
+ }
1283
+ }
1284
+
1285
+ function startPolling() {
1286
+ if (exportPollingInterval) return;
1287
+ exportPollingInterval = setInterval(updateExportStatus, 3000);
1288
+ }
1289
+
1290
+ function stopPolling() {
1291
+ if (exportPollingInterval) {
1292
+ clearInterval(exportPollingInterval);
1293
+ exportPollingInterval = null;
1294
+ }
1295
+ }
1296
+
1297
+ async function updateExportStatus() {
1298
+ if (!currentExportId) return;
1299
+
1300
+ const account = document.getElementById('account-id').value.trim();
1301
+ try {
1302
+ const res = await fetch(`/admin/accounts/${account}/export/${currentExportId}`);
1303
+ if (!res.ok) {
1304
+ throw new Error(`HTTP error! status: ${res.status}`);
1305
+ }
1306
+ const data = await res.json();
1307
+
1308
+ // Update status badge
1309
+ const badge = document.getElementById('export-status-badge');
1310
+ badge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
1311
+ badge.className = 'badge badge-' + getStatusBadgeClass(data.status);
1312
+
1313
+ // Update phase badge
1314
+ const phaseBadge = document.getElementById('export-phase-badge');
1315
+ if (data.phase && data.status === 'processing') {
1316
+ phaseBadge.textContent = data.phase.charAt(0).toUpperCase() + data.phase.slice(1);
1317
+ phaseBadge.classList.remove('d-none');
1318
+ } else {
1319
+ phaseBadge.classList.add('d-none');
1320
+ }
1321
+
1322
+ // Update expiration date
1323
+ const expiresAtEl = document.getElementById('export-expires-at');
1324
+ if (data.expiresAt) {
1325
+ const expiresDate = new Date(data.expiresAt);
1326
+ expiresAtEl.textContent = expiresDate.toLocaleString();
1327
+ } else {
1328
+ expiresAtEl.textContent = 'Unknown';
1329
+ }
1330
+
1331
+ // Update progress bar
1332
+ const progressBar = document.getElementById('export-progress-bar');
1333
+ const progressText = document.getElementById('export-progress-text');
1334
+
1335
+ if (data.phase === 'indexing' && data.progress) {
1336
+ // During indexing, show folders scanned progress
1337
+ const pct = data.progress.foldersTotal > 0
1338
+ ? Math.round((data.progress.foldersScanned / data.progress.foldersTotal) * 100)
1339
+ : 0;
1340
+ progressBar.style.width = pct + '%';
1341
+ progressBar.setAttribute('aria-valuenow', pct);
1342
+ progressText.textContent = `Scanning folders: ${data.progress.foldersScanned || 0} / ${data.progress.foldersTotal || '?'}`;
1343
+ } else if (data.progress) {
1344
+ // During exporting, show messages exported progress
1345
+ const pct = data.progress.messagesQueued > 0
1346
+ ? Math.round((data.progress.messagesExported / data.progress.messagesQueued) * 100)
1347
+ : 0;
1348
+ progressBar.style.width = pct + '%';
1349
+ progressBar.setAttribute('aria-valuenow', pct);
1350
+ progressText.textContent =
1351
+ `${data.progress.messagesExported} / ${data.progress.messagesQueued} messages exported`;
1352
+ }
1353
+
1354
+ // Handle completion
1355
+ if (data.status === 'completed') {
1356
+ stopPolling();
1357
+ document.getElementById('export-download-section').classList.remove('d-none');
1358
+ document.getElementById('export-download-link').href =
1359
+ `/admin/accounts/${account}/export/${currentExportId}/download`;
1360
+ progressBar.style.width = '100%';
1361
+ progressBar.setAttribute('aria-valuenow', 100);
1362
+ progressBar.classList.remove('bg-info', 'progress-bar-striped', 'progress-bar-animated');
1363
+ progressBar.classList.add('bg-success');
1364
+ }
1365
+
1366
+ // Handle failure
1367
+ if (data.status === 'failed') {
1368
+ stopPolling();
1369
+ document.getElementById('export-error-section').classList.remove('d-none');
1370
+ document.getElementById('export-error-message').textContent = data.error || 'Export failed';
1371
+ document.getElementById('export-progress-section').classList.add('d-none');
1372
+ }
1373
+ } catch (err) {
1374
+ console.error('Failed to update export status', err);
1375
+ }
1376
+ }
1377
+
1378
+ async function checkExistingExport() {
1379
+ const account = document.getElementById('account-id').value.trim();
1380
+ try {
1381
+ const res = await fetch(`/admin/accounts/${account}/exports?pageSize=1`);
1382
+ if (!res.ok) {
1383
+ showFormView();
1384
+ return;
1385
+ }
1386
+ const data = await res.json();
1387
+
1388
+ if (data.exports && data.exports.length > 0) {
1389
+ const latest = data.exports[0];
1390
+ if (latest.status === 'queued' || latest.status === 'processing' || latest.status === 'completed') {
1391
+ currentExportId = latest.exportId;
1392
+ showStatusView();
1393
+ // Reset progress display state
1394
+ document.getElementById('export-progress-section').classList.remove('d-none');
1395
+ document.getElementById('export-error-section').classList.add('d-none');
1396
+ document.getElementById('export-download-section').classList.add('d-none');
1397
+ document.getElementById('export-phase-badge').classList.add('d-none');
1398
+ const progressBar = document.getElementById('export-progress-bar');
1399
+ progressBar.classList.remove('bg-success');
1400
+ progressBar.classList.add('bg-info', 'progress-bar-striped', 'progress-bar-animated');
1401
+ await updateExportStatus();
1402
+ if (latest.status !== 'completed' && latest.status !== 'failed') {
1403
+ startPolling();
1404
+ }
1405
+ return;
1406
+ }
1407
+ }
1408
+ showFormView();
1409
+ } catch (err) {
1410
+ console.error('Failed to check existing export', err);
1411
+ showFormView();
1412
+ }
1413
+ }
1414
+
1415
+ $('#exportModal').on('show.bs.modal', async function () {
1416
+ await checkExistingExport();
1417
+ });
1418
+
1419
+ $('#exportModal').on('hidden.bs.modal', function () {
1420
+ stopPolling();
1421
+ updateExportIndicator();
1422
+ });
1423
+
1424
+ document.getElementById('export-start-btn').addEventListener('click', async function () {
1425
+ const account = document.getElementById('account-id').value.trim();
1426
+ const startDate = document.getElementById('export-start-date').value;
1427
+ const endDate = document.getElementById('export-end-date').value;
1428
+ const includeAttachments = document.getElementById('export-include-attachments').checked;
1429
+
1430
+ if (!startDate || !endDate) {
1431
+ showToast('Please select date range', 'alert-triangle');
1432
+ return;
1433
+ }
1434
+
1435
+ document.getElementById('export-start-icon').classList.add('fa-spin');
1436
+
1437
+ try {
1438
+ const res = await fetch(`/admin/accounts/${account}/export`, {
1439
+ method: 'POST',
1440
+ headers: { 'Content-Type': 'application/json' },
1441
+ body: JSON.stringify({
1442
+ crumb: document.getElementById('crumb').value,
1443
+ startDate: startDate + 'T00:00:00Z',
1444
+ endDate: endDate + 'T23:59:59Z',
1445
+ includeAttachments
1446
+ })
1447
+ });
1448
+
1449
+ const data = await res.json();
1450
+ if (data.exportId) {
1451
+ currentExportId = data.exportId;
1452
+ showStatusView();
1453
+ // Reset progress display state
1454
+ document.getElementById('export-progress-section').classList.remove('d-none');
1455
+ document.getElementById('export-error-section').classList.add('d-none');
1456
+ document.getElementById('export-download-section').classList.add('d-none');
1457
+ document.getElementById('export-phase-badge').classList.add('d-none');
1458
+ const progressBar = document.getElementById('export-progress-bar');
1459
+ progressBar.classList.remove('bg-success');
1460
+ progressBar.classList.add('bg-info', 'progress-bar-striped', 'progress-bar-animated');
1461
+ progressBar.style.width = '0%';
1462
+ progressBar.setAttribute('aria-valuenow', 0);
1463
+ document.getElementById('export-progress-text').textContent = '';
1464
+ await updateExportStatus();
1465
+ startPolling();
1466
+ showToast('Export started', 'check-circle');
1467
+ } else {
1468
+ showToast(data.error || data.message || 'Failed to start export', 'alert-triangle');
1469
+ }
1470
+ } catch (err) {
1471
+ showToast('Export failed: ' + err.message, 'alert-triangle');
1472
+ } finally {
1473
+ document.getElementById('export-start-icon').classList.remove('fa-spin');
1474
+ }
1475
+ });
1476
+
1477
+ document.getElementById('export-reset-btn').addEventListener('click', async function () {
1478
+ if (!currentExportId) return;
1479
+
1480
+ const account = document.getElementById('account-id').value.trim();
1481
+
1482
+ try {
1483
+ await fetch(`/admin/accounts/${account}/export/${currentExportId}`, {
1484
+ method: 'DELETE',
1485
+ headers: { 'Content-Type': 'application/json' },
1486
+ body: JSON.stringify({ crumb: document.getElementById('crumb').value })
1487
+ });
1488
+
1489
+ stopPolling();
1490
+ currentExportId = null;
1491
+ showFormView();
1492
+ showToast('Export cleared', 'check-circle');
1493
+ } catch (err) {
1494
+ showToast('Failed to clear export: ' + err.message, 'alert-triangle');
1495
+ }
1496
+ });
1124
1497
  });
1125
1498
  </script>
1126
1499
 
@@ -70,6 +70,51 @@
70
70
  </div>
71
71
  </div>
72
72
 
73
+ <div class="card mb-4 mt-4">
74
+ <div class="card-header py-3">
75
+ <h6 class="m-0 font-weight-bold text-primary">HTTP Proxy Settings</h6>
76
+ </div>
77
+ <div class="card-body">
78
+ <p>Route all outbound HTTP/HTTPS requests through a proxy. This applies to webhook delivery, OAuth token
79
+ exchange, and API calls to Gmail/Outlook. This is separate from the IMAP/SMTP proxy above.</p>
80
+
81
+ <div class="form-group form-check">
82
+
83
+ <div class="text-muted float-right code-link">[<a href="/admin/swagger#/Settings/postV1Settings"
84
+ target="_blank" rel="noopener noreferrer">httpProxyEnabled</a>]</div>
85
+
86
+ <input type="checkbox"
87
+ class="form-check-input http-proxy-status {{#if errors.httpProxyEnabled}}is-invalid{{/if}}"
88
+ id="http_proxy_enabled" name="httpProxyEnabled" {{#if
89
+ values.httpProxyEnabled}}checked{{/if}} />
90
+ <label class="form-check-label" for="http_proxy_enabled">Enable HTTP Proxy</label>
91
+ {{#if errors.httpProxyEnabled}}
92
+ <span class="invalid-feedback">{{errors.httpProxyEnabled}}</span>
93
+ {{/if}}
94
+ <small class="form-text text-muted">Routes all outbound HTTP/HTTPS requests through the proxy
95
+ below.</small>
96
+
97
+ </div>
98
+
99
+ <div class="form-group">
100
+
101
+ <div class="text-muted float-right code-link">[<a href="/admin/swagger#/Settings/postV1Settings"
102
+ target="_blank" rel="noopener noreferrer">httpProxyUrl</a>]</div>
103
+
104
+ <label for="httpProxyUrl">HTTP Proxy URL</label>
105
+ <input type="text" class="form-control or-else-all {{#if errors.httpProxyUrl}}is-invalid{{/if}}"
106
+ id="httpProxyUrl" placeholder="http://proxy:8080" value="{{values.httpProxyUrl}}"
107
+ name="httpProxyUrl" data-target="http-proxy-status">
108
+ {{#if errors.httpProxyUrl}}
109
+ <span class="invalid-feedback">{{errors.httpProxyUrl}}</span>
110
+ {{/if}}
111
+ <small id="httpProxyUrlBlock" class="form-text text-muted">Format: <code>http://</code>,
112
+ <code>https://</code>, <code>socks4://</code>, or <code>socks5://</code>
113
+ followed by host:port</small>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
73
118
  <div class="card mb-4 mt-4">
74
119
  <div class="card-header py-3">
75
120
  <h6 class="m-0 font-weight-bold text-primary">IP Address Strategy</h6>
@@ -341,6 +341,41 @@
341
341
  </div>
342
342
  </div>
343
343
 
344
+ <div id="export_settings" class="card mb-4">
345
+ <div class="card-header py-3">
346
+ <h6 class="m-0 font-weight-bold text-primary">Export Settings</h6>
347
+ </div>
348
+ <div class="card-body">
349
+ <p>Configure batch sizes for bulk message export operations.</p>
350
+
351
+ <div class="form-group">
352
+ <div class="text-muted float-right code-link">[<a href="/admin/swagger#/Settings/postV1Settings"
353
+ target="_blank" rel="noopener noreferrer">gmailExportBatchSize</a>]</div>
354
+ <label for="settingsGmailExportBatchSize">Gmail Export Batch Size</label>
355
+ <input type="number" min="1" max="50" class="form-control {{#if errors.gmailExportBatchSize}}is-invalid{{/if}}"
356
+ id="settingsGmailExportBatchSize" name="gmailExportBatchSize" value="{{values.gmailExportBatchSize}}"
357
+ data-lpignore="true" autocomplete="off" />
358
+ {{#if errors.gmailExportBatchSize}}
359
+ <span class="invalid-feedback">{{errors.gmailExportBatchSize}}</span>
360
+ {{/if}}
361
+ <small class="form-text text-muted">Number of parallel requests when fetching Gmail messages for export (1-50, default: 10).</small>
362
+ </div>
363
+
364
+ <div class="form-group">
365
+ <div class="text-muted float-right code-link">[<a href="/admin/swagger#/Settings/postV1Settings"
366
+ target="_blank" rel="noopener noreferrer">outlookExportBatchSize</a>]</div>
367
+ <label for="settingsOutlookExportBatchSize">Outlook Export Batch Size</label>
368
+ <input type="number" min="1" max="20" class="form-control {{#if errors.outlookExportBatchSize}}is-invalid{{/if}}"
369
+ id="settingsOutlookExportBatchSize" name="outlookExportBatchSize" value="{{values.outlookExportBatchSize}}"
370
+ data-lpignore="true" autocomplete="off" />
371
+ {{#if errors.outlookExportBatchSize}}
372
+ <span class="invalid-feedback">{{errors.outlookExportBatchSize}}</span>
373
+ {{/if}}
374
+ <small class="form-text text-muted">Messages per batch request for Outlook exports (1-20, default: 20). Limited by Microsoft Graph API.</small>
375
+ </div>
376
+ </div>
377
+ </div>
378
+
344
379
  <div id="templates_settings" class="card mb-4">
345
380
  <div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
346
381
  <h6 class="m-0 font-weight-bold text-primary">Public Page Customization</h6>