neoagent 2.4.4-beta.0 → 2.4.4-beta.11

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 (66) hide show
  1. package/.env.example +17 -0
  2. package/README.md +9 -3
  3. package/docs/capabilities.md +16 -7
  4. package/docs/configuration.md +1 -0
  5. package/docs/getting-started.md +6 -0
  6. package/docs/index.md +1 -0
  7. package/docs/security-boundaries.md +122 -0
  8. package/docs/supermemory-memory-review.md +852 -0
  9. package/flutter_app/lib/features/memory/views/retrieval_inspector_view.dart +128 -0
  10. package/flutter_app/lib/main.dart +3 -0
  11. package/flutter_app/lib/main_account_settings.dart +79 -15
  12. package/flutter_app/lib/main_app_shell.dart +22 -0
  13. package/flutter_app/lib/main_chat.dart +155 -8
  14. package/flutter_app/lib/main_controller.dart +74 -5
  15. package/flutter_app/lib/main_devices.dart +9 -3
  16. package/flutter_app/lib/main_models.dart +32 -0
  17. package/flutter_app/lib/main_operations.dart +13 -0
  18. package/flutter_app/lib/main_security.dart +967 -0
  19. package/flutter_app/lib/main_settings.dart +56 -0
  20. package/flutter_app/lib/src/backend_client.dart +60 -3
  21. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
  22. package/flutter_app/pubspec.lock +32 -0
  23. package/flutter_app/pubspec.yaml +1 -0
  24. package/lib/install_helpers.js +1 -0
  25. package/lib/manager.js +63 -1
  26. package/lib/schema_migrations.js +262 -0
  27. package/package.json +4 -2
  28. package/server/admin/admin.js +151 -0
  29. package/server/admin/index.html +55 -3
  30. package/server/db/database.js +3 -0
  31. package/server/http/routes.js +2 -1
  32. package/server/public/.last_build_id +1 -1
  33. package/server/public/assets/NOTICES +86 -0
  34. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  35. package/server/public/flutter_bootstrap.js +1 -1
  36. package/server/public/main.dart.js +82243 -80308
  37. package/server/routes/account.js +2 -23
  38. package/server/routes/admin.js +18 -2
  39. package/server/routes/agents.js +5 -1
  40. package/server/routes/memory.js +41 -4
  41. package/server/routes/security.js +112 -0
  42. package/server/services/account/service_email_settings.js +167 -0
  43. package/server/services/ai/engine.js +269 -27
  44. package/server/services/ai/rate_limits.js +150 -0
  45. package/server/services/ai/systemPrompt.js +26 -3
  46. package/server/services/ai/tools.js +11 -8
  47. package/server/services/cli/shell_worker.js +135 -0
  48. package/server/services/cli/shell_worker_pool.js +125 -0
  49. package/server/services/integrations/google/gmail.js +7 -7
  50. package/server/services/manager.js +20 -1
  51. package/server/services/memory/consolidation.js +111 -0
  52. package/server/services/memory/embedding_index.js +175 -0
  53. package/server/services/memory/embeddings.js +22 -2
  54. package/server/services/memory/evaluation.js +187 -0
  55. package/server/services/memory/ingestion_chunking.js +191 -0
  56. package/server/services/memory/ingestion_documents.js +96 -26
  57. package/server/services/memory/intelligence.js +3 -1
  58. package/server/services/memory/manager.js +855 -43
  59. package/server/services/memory/policy.js +0 -40
  60. package/server/services/memory/retrieval_reasoning.js +191 -0
  61. package/server/services/runtime/manager.js +7 -0
  62. package/server/services/security/approval_gate_service.js +93 -0
  63. package/server/services/security/tool_categories.js +105 -0
  64. package/server/services/security/tool_policy_service.js +92 -0
  65. package/server/services/security/tool_security_hook.js +77 -0
  66. package/server/services/websocket.js +5 -1
@@ -38,6 +38,7 @@ const {
38
38
  approveChallenge,
39
39
  resolveChallengeForApproval,
40
40
  } = require('../services/account/qr_login');
41
+ const { getRateLimitSnapshot } = require('../services/ai/rate_limits');
41
42
 
42
43
  const accountLimiter = rateLimit({
43
44
  windowMs: 15 * 60 * 1000,
@@ -112,29 +113,7 @@ router.get('/', (req, res) => {
112
113
 
113
114
  router.get('/usage', (req, res) => {
114
115
  try {
115
- const userId = req.session.userId;
116
- const userLimits = db.prepare('SELECT rate_limit_4h, rate_limit_weekly FROM users WHERE id = ?').get(userId);
117
- const h4Tokens = db.prepare("SELECT COALESCE(SUM(total_tokens), 0) as t FROM agent_runs WHERE user_id = ? AND created_at > datetime('now', '-4 hours')").get(userId).t;
118
- const weeklyTokens = db.prepare("SELECT COALESCE(SUM(total_tokens), 0) as t FROM agent_runs WHERE user_id = ? AND created_at > datetime('now', '-7 days')").get(userId).t;
119
-
120
- const globalLimit4h = process.env.NEOAGENT_RATE_LIMIT_4H ? parseInt(process.env.NEOAGENT_RATE_LIMIT_4H, 10) : null;
121
- const globalLimitWeekly = process.env.NEOAGENT_RATE_LIMIT_WEEKLY ? parseInt(process.env.NEOAGENT_RATE_LIMIT_WEEKLY, 10) : null;
122
-
123
- const custom4h = userLimits?.rate_limit_4h ?? null;
124
- const customWeekly = userLimits?.rate_limit_weekly ?? null;
125
-
126
- res.json({
127
- limits: {
128
- fourHour: custom4h ?? globalLimit4h,
129
- weekly: customWeekly ?? globalLimitWeekly,
130
- fourHourIsCustom: custom4h !== null,
131
- weeklyIsCustom: customWeekly !== null,
132
- },
133
- usage: {
134
- fourHour: h4Tokens,
135
- weekly: weeklyTokens,
136
- }
137
- });
116
+ res.json(getRateLimitSnapshot(req.session.userId));
138
117
  } catch (err) {
139
118
  sendRouteError(res, err);
140
119
  }
@@ -18,6 +18,7 @@ const {
18
18
  const { APP_DIR, ENV_FILE, upsertEnvValue } = require('../../runtime/paths');
19
19
  const { isManagedDeployment } = require('../utils/deployment');
20
20
  const rateLimit = require('express-rate-limit');
21
+ const { configuredDefaultLimits } = require('../services/ai/rate_limits');
21
22
 
22
23
  const router = express.Router();
23
24
  const ADMIN_DIR = path.join(__dirname, '..', 'admin');
@@ -324,6 +325,20 @@ const sqlLimiter = rateLimit({
324
325
  message: { error: 'Too many SQL queries, slow down' },
325
326
  });
326
327
 
328
+ router.get('/api/config/email', requireAdminAuth, (req, res) => {
329
+ const { getAdminEmailSettings } = require('../services/account/service_email_settings');
330
+ res.json(getAdminEmailSettings());
331
+ });
332
+
333
+ router.put('/api/config/email', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
334
+ try {
335
+ const { updateAdminEmailSettings } = require('../services/account/service_email_settings');
336
+ res.json({ ok: true, ...updateAdminEmailSettings(req.body) });
337
+ } catch (err) {
338
+ res.status(err.statusCode || 500).json({ error: err.message });
339
+ }
340
+ });
341
+
327
342
  // --- Access settings (signup toggle + API key) ---
328
343
 
329
344
  router.get('/api/settings', requireAdminAuth, (req, res) => {
@@ -670,9 +685,10 @@ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
670
685
  // --- Global default rate limits ---
671
686
 
672
687
  router.get('/api/config/rate-limits', requireAdminAuth, (req, res) => {
688
+ const defaults = configuredDefaultLimits();
673
689
  res.json({
674
- rate_limit_4h: process.env.NEOAGENT_RATE_LIMIT_4H ? parseInt(process.env.NEOAGENT_RATE_LIMIT_4H, 10) : null,
675
- rate_limit_weekly: process.env.NEOAGENT_RATE_LIMIT_WEEKLY ? parseInt(process.env.NEOAGENT_RATE_LIMIT_WEEKLY, 10) : null,
690
+ rate_limit_4h: defaults.fourHour,
691
+ rate_limit_weekly: defaults.weekly,
676
692
  });
677
693
  });
678
694
 
@@ -130,7 +130,11 @@ router.post('/', async (req, res) => {
130
130
 
131
131
  res.json(result);
132
132
  } catch (err) {
133
- res.status(500).json({ error: sanitizeError(err) });
133
+ res.status(err?.statusCode || err?.status || 500).json({
134
+ error: sanitizeError(err),
135
+ code: err?.code,
136
+ rateLimit: err?.rateLimit,
137
+ });
134
138
  }
135
139
  });
136
140
 
@@ -28,6 +28,14 @@ const transferImportLimiter = rateLimit({
28
28
  legacyHeaders: false,
29
29
  });
30
30
 
31
+ const memorySearchLimiter = rateLimit({
32
+ windowMs: 1 * 60 * 1000,
33
+ max: 60,
34
+ message: { error: 'Too many memory search requests, try again later' },
35
+ standardHeaders: true,
36
+ legacyHeaders: false,
37
+ });
38
+
31
39
  const MAX_TRANSFER_TEXT_BYTES = 60 * 1024;
32
40
 
33
41
  function normalizeMemoryIds(value) {
@@ -378,20 +386,49 @@ router.post('/memories/bulk-archive', (req, res) => {
378
386
  });
379
387
 
380
388
  // Semantic recall (search)
381
- router.post('/memories/recall', async (req, res) => {
389
+ router.post('/memories/recall', memorySearchLimiter, async (req, res) => {
382
390
  const mm = req.app.locals.memoryManager;
383
391
  const userId = req.session.userId;
384
392
  const agentId = resolveAgentId(userId, getAgentIdFromRequest(req));
385
393
  const { query, limit = 8 } = req.body;
386
394
  if (!query) return res.status(400).json({ error: 'query is required' });
395
+
396
+ let parsedLimit = Number.parseInt(limit, 10);
397
+ if (!Number.isFinite(parsedLimit) || Number.isNaN(parsedLimit)) {
398
+ return res.status(400).json({ error: 'limit must be an integer between 1 and 100' });
399
+ }
400
+ parsedLimit = Math.max(1, Math.min(100, parsedLimit));
401
+
387
402
  try {
388
- const results = await mm.recallMemory(userId, query, parseInt(limit), { agentId });
403
+ const results = await mm.recallMemory(userId, query, parsedLimit, { agentId });
389
404
  res.json(results);
390
405
  } catch (err) {
391
406
  res.status(500).json({ error: sanitizeError(err) });
392
407
  }
393
408
  });
394
409
 
410
+ // Semantic recall inspection (UI debug)
411
+ router.post('/memories/inspect', memorySearchLimiter, async (req, res) => {
412
+ const mm = req.app.locals.memoryManager;
413
+ const userId = req.session.userId;
414
+ const agentId = resolveAgentId(userId, getAgentIdFromRequest(req));
415
+ const { query, limit = 20 } = req.body;
416
+ if (!query) return res.status(400).json({ error: 'query is required' });
417
+
418
+ let parsedLimit = Number.parseInt(limit, 10);
419
+ if (!Number.isFinite(parsedLimit) || Number.isNaN(parsedLimit)) {
420
+ return res.status(400).json({ error: 'limit must be an integer between 1 and 100' });
421
+ }
422
+ parsedLimit = Math.max(1, Math.min(100, parsedLimit));
423
+
424
+ try {
425
+ const results = await mm.recallMemory(userId, query, parsedLimit, { agentId });
426
+ res.json({ results });
427
+ } catch (err) {
428
+ res.status(500).json({ error: sanitizeError(err) });
429
+ }
430
+ });
431
+
395
432
  // ─────────────────────────────────────────────────────────────────────────────
396
433
  // Core Memory
397
434
  // ─────────────────────────────────────────────────────────────────────────────
@@ -457,7 +494,7 @@ router.post('/transfer-import', transferImportLimiter, async (req, res) => {
457
494
  if (applyCoreMemory && parsed.coreEntries) {
458
495
  for (const [key, value] of Object.entries(parsed.coreEntries)) {
459
496
  if (!key || key === 'active_context') continue;
460
- mm.updateCore(userId, key, value, { agentId, confirmed: true });
497
+ mm.updateCore(userId, key, value, { agentId });
461
498
  coreUpdatedCount += 1;
462
499
  }
463
500
  }
@@ -489,7 +526,7 @@ router.put('/core/:key', (req, res) => {
489
526
  if (req.params.key === 'active_context') {
490
527
  return res.status(400).json({ error: 'active_context is no longer a supported core memory key' });
491
528
  }
492
- mm.updateCore(userId, req.params.key, value, { agentId, confirmed: true });
529
+ mm.updateCore(userId, req.params.key, value, { agentId });
493
530
  res.json({ success: true });
494
531
  });
495
532
 
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const router = express.Router();
5
+ const { requireAuth } = require('../middleware/auth');
6
+ const db = require('../db/database');
7
+ const { TOOL_CATEGORIES, getCategoryForTool } = require('../services/security/tool_categories');
8
+
9
+ router.use(requireAuth);
10
+
11
+ // ── Security mode ─────────────────────────────────────────────────────────────
12
+
13
+ // GET /api/security/mode — current global security mode
14
+ router.get('/mode', (req, res) => {
15
+ const mode = req.app.locals.toolPolicyService.getSecurityMode(req.session.userId);
16
+ res.json({ mode });
17
+ });
18
+
19
+ // PUT /api/security/mode — update global security mode
20
+ router.put('/mode', (req, res) => {
21
+ const { mode } = req.body;
22
+ if (!mode) return res.status(400).json({ error: 'mode is required' });
23
+ try {
24
+ req.app.locals.toolPolicyService.setSecurityMode(req.session.userId, mode);
25
+ res.json({ ok: true, mode });
26
+ } catch (err) {
27
+ res.status(400).json({ error: err.message });
28
+ }
29
+ });
30
+
31
+ // ── Per-category policies ─────────────────────────────────────────────────────
32
+
33
+ // GET /api/security/policies — all category policies + current mode
34
+ router.get('/policies', (req, res) => {
35
+ const { toolPolicyService } = req.app.locals;
36
+ const policies = toolPolicyService.getPolicies(req.session.userId);
37
+ const mode = toolPolicyService.getSecurityMode(req.session.userId);
38
+ res.json({ policies, mode, categories: Object.keys(TOOL_CATEGORIES) });
39
+ });
40
+
41
+ // PUT /api/security/policies — update a single category policy
42
+ router.put('/policies', (req, res) => {
43
+ const { category, policy } = req.body;
44
+ if (!category || !policy) {
45
+ return res.status(400).json({ error: 'category and policy are required' });
46
+ }
47
+ try {
48
+ req.app.locals.toolPolicyService.setPolicy(req.session.userId, category, policy);
49
+ res.json({ ok: true, category, policy });
50
+ } catch (err) {
51
+ res.status(400).json({ error: err.message });
52
+ }
53
+ });
54
+
55
+ // ── Approval decisions ────────────────────────────────────────────────────────
56
+
57
+ // POST /api/security/approvals/:approvalId — resolve a pending approval
58
+ router.post('/approvals/:approvalId', (req, res) => {
59
+ const { approvalId } = req.params;
60
+ const { decision, scope, runId, toolName, toolArgs } = req.body;
61
+
62
+ if (!decision || !['approved', 'denied'].includes(decision)) {
63
+ return res.status(400).json({ error: 'decision must be approved or denied' });
64
+ }
65
+ const normalizedScope = ['once', 'session', 'always'].includes(scope) ? scope : 'once';
66
+
67
+ // 'always' scope: also persist the policy so this category is allowed going forward
68
+ if (decision === 'approved' && normalizedScope === 'always' && toolName) {
69
+ try {
70
+ const category = getCategoryForTool(toolName, toolArgs ?? {});
71
+ if (category) {
72
+ req.app.locals.toolPolicyService.setPolicy(req.session.userId, category, 'allow');
73
+ }
74
+ } catch {}
75
+ }
76
+
77
+ const { approvalGateService } = req.app.locals;
78
+ const resolved = approvalGateService.resolve(
79
+ approvalId,
80
+ req.session.userId,
81
+ runId || null,
82
+ toolName || null,
83
+ toolArgs || {},
84
+ decision,
85
+ normalizedScope,
86
+ );
87
+ if (!resolved) {
88
+ return res.status(404).json({ error: 'Approval not found or already resolved' });
89
+ }
90
+ res.json({ ok: true, approvalId, decision, scope: normalizedScope });
91
+ });
92
+
93
+ // ── Audit log ─────────────────────────────────────────────────────────────────
94
+
95
+ // GET /api/security/approval-log — paginated audit log
96
+ router.get('/approval-log', (req, res) => {
97
+ const limit = Math.min(Number(req.query.limit) || 50, 200);
98
+ const offset = Number(req.query.offset) || 0;
99
+ const rows = db.prepare(`
100
+ SELECT id, run_id, tool_name, tool_args_json, decision, scope, decided_at
101
+ FROM tool_approval_log
102
+ WHERE user_id = ?
103
+ ORDER BY decided_at DESC
104
+ LIMIT ? OFFSET ?
105
+ `).all(req.session.userId, limit, offset);
106
+ const total = db.prepare(
107
+ 'SELECT COUNT(*) as count FROM tool_approval_log WHERE user_id = ?'
108
+ ).get(req.session.userId)?.count ?? 0;
109
+ res.json({ log: rows, total, limit, offset });
110
+ });
111
+
112
+ module.exports = router;
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ const { ENV_FILE, upsertEnvValue } = require('../../../runtime/paths');
4
+
5
+ const STRING_FIELDS = Object.freeze({
6
+ from: 'NEOAGENT_EMAIL_FROM',
7
+ smtpHost: 'NEOAGENT_EMAIL_SMTP_HOST',
8
+ smtpUser: 'NEOAGENT_EMAIL_SMTP_USER',
9
+ replyTo: 'NEOAGENT_EMAIL_REPLY_TO',
10
+ brandName: 'NEOAGENT_EMAIL_BRAND_NAME',
11
+ supportUrl: 'NEOAGENT_EMAIL_SUPPORT_URL',
12
+ publicUrl: 'NEOAGENT_EMAIL_PUBLIC_URL',
13
+ });
14
+
15
+ const BOOLEAN_FIELDS = Object.freeze({
16
+ smtpSecure: ['NEOAGENT_EMAIL_SMTP_SECURE', false],
17
+ smtpRequireTls: ['NEOAGENT_EMAIL_SMTP_REQUIRE_TLS', true],
18
+ smtpRejectUnauthorized: ['NEOAGENT_EMAIL_SMTP_REJECT_UNAUTHORIZED', true],
19
+ requireSignupConfirmation: ['NEOAGENT_EMAIL_REQUIRE_SIGNUP_CONFIRMATION', true],
20
+ requireEmailChangeConfirmation: ['NEOAGENT_EMAIL_REQUIRE_EMAIL_CHANGE_CONFIRMATION', true],
21
+ notifyUnusualLogin: ['NEOAGENT_EMAIL_NOTIFY_UNUSUAL_LOGIN', true],
22
+ notifyAccountChanges: ['NEOAGENT_EMAIL_NOTIFY_ACCOUNT_CHANGES', true],
23
+ });
24
+
25
+ function envString(env, key) {
26
+ return String(env[key] || '').trim();
27
+ }
28
+
29
+ function envBoolean(env, key, defaultValue) {
30
+ const value = envString(env, key).toLowerCase();
31
+ if (!value) return defaultValue;
32
+ return ['1', 'true', 'yes', 'on'].includes(value);
33
+ }
34
+
35
+ function envPositiveInteger(env, key, defaultValue) {
36
+ const value = Number(envString(env, key));
37
+ return Number.isInteger(value) && value > 0 ? value : defaultValue;
38
+ }
39
+
40
+ function cleanSingleLine(value, label) {
41
+ const cleaned = String(value ?? '').trim();
42
+ if (/[\r\n]/.test(cleaned)) {
43
+ const error = new Error(`${label} must be a single line.`);
44
+ error.statusCode = 400;
45
+ throw error;
46
+ }
47
+ return cleaned;
48
+ }
49
+
50
+ function requireBoolean(value, label) {
51
+ if (typeof value !== 'boolean') {
52
+ const error = new Error(`${label} must be true or false.`);
53
+ error.statusCode = 400;
54
+ throw error;
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function requireInteger(value, label, { min, max }) {
60
+ const number = Number(value);
61
+ if (!Number.isInteger(number) || number < min || number > max) {
62
+ const error = new Error(`${label} must be an integer from ${min} to ${max}.`);
63
+ error.statusCode = 400;
64
+ throw error;
65
+ }
66
+ return number;
67
+ }
68
+
69
+ function requireHttpUrl(value, label) {
70
+ const cleaned = cleanSingleLine(value, label);
71
+ if (!cleaned) return '';
72
+ try {
73
+ const url = new URL(cleaned);
74
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('invalid protocol');
75
+ } catch {
76
+ const error = new Error(`${label} must be an HTTP or HTTPS URL.`);
77
+ error.statusCode = 400;
78
+ throw error;
79
+ }
80
+ return cleaned.replace(/\/$/, '');
81
+ }
82
+
83
+ function encodeEnvValue(value) {
84
+ return JSON.stringify(String(value));
85
+ }
86
+
87
+ function persistValue(envFile, env, key, value) {
88
+ upsertEnvValue(envFile, key, encodeEnvValue(value));
89
+ if (value === '') {
90
+ delete env[key];
91
+ } else {
92
+ env[key] = String(value);
93
+ }
94
+ }
95
+
96
+ function getAdminEmailSettings({ env = process.env } = {}) {
97
+ const smtpPort = envPositiveInteger(env, 'NEOAGENT_EMAIL_SMTP_PORT', 587);
98
+ const settings = {
99
+ smtpPort,
100
+ tokenTtlHours: envPositiveInteger(env, 'NEOAGENT_EMAIL_TOKEN_TTL_HOURS', 24),
101
+ smtpPasswordConfigured: Boolean(envString(env, 'NEOAGENT_EMAIL_SMTP_PASS')),
102
+ };
103
+
104
+ for (const [name, key] of Object.entries(STRING_FIELDS)) {
105
+ settings[name] = envString(env, key);
106
+ }
107
+ for (const [name, [key, defaultValue]] of Object.entries(BOOLEAN_FIELDS)) {
108
+ let effectiveDefault = defaultValue;
109
+ if (name === 'smtpSecure') effectiveDefault = smtpPort === 465;
110
+ if (name === 'smtpRequireTls') effectiveDefault = !settings.smtpSecure;
111
+ settings[name] = envBoolean(env, key, effectiveDefault);
112
+ }
113
+
114
+ const missing = [];
115
+ if (!settings.from) missing.push('Sender address');
116
+ if (!settings.smtpHost) missing.push('SMTP host');
117
+
118
+ return {
119
+ configured: missing.length === 0,
120
+ missing,
121
+ settings,
122
+ };
123
+ }
124
+
125
+ function updateAdminEmailSettings(body, { env = process.env, envFile = ENV_FILE } = {}) {
126
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
127
+ const error = new Error('Email settings must be an object.');
128
+ error.statusCode = 400;
129
+ throw error;
130
+ }
131
+
132
+ const values = {};
133
+ for (const name of Object.keys(STRING_FIELDS)) {
134
+ values[name] = name === 'supportUrl' || name === 'publicUrl'
135
+ ? requireHttpUrl(body[name], name === 'supportUrl' ? 'Support URL' : 'Public URL')
136
+ : cleanSingleLine(body[name], name);
137
+ }
138
+
139
+ values.smtpPort = requireInteger(body.smtpPort, 'SMTP port', { min: 1, max: 65535 });
140
+ values.tokenTtlHours = requireInteger(body.tokenTtlHours, 'Token lifetime', { min: 1, max: 8760 });
141
+
142
+ for (const name of Object.keys(BOOLEAN_FIELDS)) {
143
+ values[name] = requireBoolean(body[name], name);
144
+ }
145
+
146
+ const smtpPassword = cleanSingleLine(body.smtpPassword, 'SMTP password');
147
+ const clearSmtpPassword = body.clearSmtpPassword === true;
148
+
149
+ for (const [name, key] of Object.entries(STRING_FIELDS)) {
150
+ persistValue(envFile, env, key, values[name]);
151
+ }
152
+ persistValue(envFile, env, 'NEOAGENT_EMAIL_SMTP_PORT', values.smtpPort);
153
+ persistValue(envFile, env, 'NEOAGENT_EMAIL_TOKEN_TTL_HOURS', values.tokenTtlHours);
154
+ for (const [name, [key]] of Object.entries(BOOLEAN_FIELDS)) {
155
+ persistValue(envFile, env, key, values[name]);
156
+ }
157
+ if (smtpPassword || clearSmtpPassword) {
158
+ persistValue(envFile, env, 'NEOAGENT_EMAIL_SMTP_PASS', clearSmtpPassword ? '' : smtpPassword);
159
+ }
160
+
161
+ return getAdminEmailSettings({ env });
162
+ }
163
+
164
+ module.exports = {
165
+ getAdminEmailSettings,
166
+ updateAdminEmailSettings,
167
+ };