1use serde::{Deserialize, Serialize};
8use sqlx::{Row, SqlitePool};
9
10use crate::error::Result;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
13#[cfg_attr(feature = "specta", derive(specta::Type))]
14#[serde(rename_all = "lowercase")]
15pub enum SettingsSection {
16 Network,
17 Agents,
18 Inbox,
19 Advanced,
20}
21
22impl SettingsSection {
23 fn as_str(self) -> &'static str {
24 match self {
25 SettingsSection::Network => "network",
26 SettingsSection::Agents => "agents",
27 SettingsSection::Inbox => "inbox",
28 SettingsSection::Advanced => "advanced",
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[cfg_attr(feature = "specta", derive(specta::Type))]
35#[serde(rename_all = "camelCase")]
36pub struct NetworkPrefs {
37 pub smtp_port: u16,
38 pub http_api_port: u16,
39 pub mcp_enabled: bool,
40 pub mcp_port: u16,
41 pub expose_on_lan: bool,
42 #[serde(default)]
45 pub api_tls: bool,
46 #[serde(default)]
50 pub api_auth_token: Option<String>,
51}
52
53impl Default for NetworkPrefs {
54 fn default() -> Self {
55 Self {
56 smtp_port: 1025,
57 http_api_port: 1080,
58 mcp_enabled: true,
59 mcp_port: 1081,
60 expose_on_lan: false,
61 api_tls: false,
62 api_auth_token: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "specta", derive(specta::Type))]
69#[serde(rename_all = "camelCase")]
70pub struct AgentPrefs {
71 pub default_wait_timeout_seconds: u32,
72 pub log_agent_requests: bool,
73 pub confirm_destructive_actions: bool,
74}
75
76impl Default for AgentPrefs {
77 fn default() -> Self {
78 Self {
79 default_wait_timeout_seconds: 30,
80 log_agent_requests: true,
81 confirm_destructive_actions: true,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[cfg_attr(feature = "specta", derive(specta::Type))]
88#[serde(rename_all = "camelCase")]
89pub struct InboxPrefs {
90 pub max_retained_emails: u32,
91 pub auto_clear_after_days: u32,
92 pub thread_related: bool,
93 pub auto_tag: bool,
94}
95
96impl Default for InboxPrefs {
97 fn default() -> Self {
98 Self {
99 max_retained_emails: 5000,
100 auto_clear_after_days: 14,
101 thread_related: true,
102 auto_tag: true,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[cfg_attr(feature = "specta", derive(specta::Type))]
109#[serde(rename_all = "camelCase")]
110pub struct AdvancedPrefs {
111 pub debug_logging: bool,
112 pub preserve_smtp_transcript: bool,
113 pub audit_retain_days: u32,
114}
115
116impl Default for AdvancedPrefs {
117 fn default() -> Self {
118 Self {
119 debug_logging: false,
120 preserve_smtp_transcript: true,
121 audit_retain_days: 90,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[cfg_attr(feature = "specta", derive(specta::Type))]
128#[serde(rename_all = "camelCase")]
129pub struct BackendSettings {
130 pub network: NetworkPrefs,
131 pub agents: AgentPrefs,
132 pub inbox: InboxPrefs,
133 pub advanced: AdvancedPrefs,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138#[cfg_attr(feature = "specta", derive(specta::Type))]
139#[serde(rename_all = "camelCase", tag = "section", content = "value")]
140pub enum SettingsPatch {
141 Network(NetworkPrefs),
142 Agents(AgentPrefs),
143 Inbox(InboxPrefs),
144 Advanced(AdvancedPrefs),
145}
146
147impl SettingsPatch {
148 pub(crate) fn section(&self) -> SettingsSection {
149 match self {
150 SettingsPatch::Network(_) => SettingsSection::Network,
151 SettingsPatch::Agents(_) => SettingsSection::Agents,
152 SettingsPatch::Inbox(_) => SettingsSection::Inbox,
153 SettingsPatch::Advanced(_) => SettingsSection::Advanced,
154 }
155 }
156}
157
158pub(crate) async fn get_section_raw(
159 pool: &SqlitePool,
160 section: SettingsSection,
161) -> Result<serde_json::Value> {
162 let rows = sqlx::query("SELECT key, value FROM settings WHERE section = ?")
163 .bind(section.as_str())
164 .fetch_all(pool)
165 .await?;
166 let mut map = serde_json::Map::new();
167 for r in rows {
168 let k: String = r.try_get("key").unwrap_or_default();
169 let v: String = r.try_get("value").unwrap_or_default();
170 let parsed: serde_json::Value =
171 serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v));
172 map.insert(k, parsed);
173 }
174 Ok(serde_json::Value::Object(map))
175}
176
177pub(crate) async fn save_section(
178 pool: &SqlitePool,
179 section: SettingsSection,
180 value: serde_json::Value,
181) -> Result<()> {
182 let serde_json::Value::Object(map) = value else {
183 let mut tx = pool.begin().await?;
185 sqlx::query("DELETE FROM settings WHERE section = ?")
186 .bind(section.as_str())
187 .execute(&mut *tx)
188 .await?;
189 sqlx::query(
190 r"INSERT INTO settings (section, key, value)
191 VALUES (?, ?, ?)",
192 )
193 .bind(section.as_str())
194 .bind("_value")
195 .bind(serde_json::to_string(&value).unwrap_or_else(|_| "null".into()))
196 .execute(&mut *tx)
197 .await?;
198 tx.commit().await?;
199 return Ok(());
200 };
201
202 let mut tx = pool.begin().await?;
203 sqlx::query("DELETE FROM settings WHERE section = ?")
204 .bind(section.as_str())
205 .execute(&mut *tx)
206 .await?;
207 for (k, v) in map {
208 sqlx::query(
209 r"INSERT INTO settings (section, key, value)
210 VALUES (?, ?, ?)",
211 )
212 .bind(section.as_str())
213 .bind(&k)
214 .bind(serde_json::to_string(&v).unwrap_or_else(|_| "null".into()))
215 .execute(&mut *tx)
216 .await?;
217 }
218 tx.commit().await?;
219 Ok(())
220}
221
222pub(crate) async fn load_all(pool: &SqlitePool) -> Result<BackendSettings> {
223 let network = match get_section_raw(pool, SettingsSection::Network).await? {
224 serde_json::Value::Object(m) if !m.is_empty() => {
225 serde_json::from_value(serde_json::Value::Object(m)).unwrap_or_default()
226 }
227 _ => NetworkPrefs::default(),
228 };
229 let agents = match get_section_raw(pool, SettingsSection::Agents).await? {
230 serde_json::Value::Object(m) if !m.is_empty() => {
231 serde_json::from_value(serde_json::Value::Object(m)).unwrap_or_default()
232 }
233 _ => AgentPrefs::default(),
234 };
235 let inbox = match get_section_raw(pool, SettingsSection::Inbox).await? {
236 serde_json::Value::Object(m) if !m.is_empty() => {
237 serde_json::from_value(serde_json::Value::Object(m)).unwrap_or_default()
238 }
239 _ => InboxPrefs::default(),
240 };
241 let advanced = match get_section_raw(pool, SettingsSection::Advanced).await? {
242 serde_json::Value::Object(m) if !m.is_empty() => {
243 serde_json::from_value(serde_json::Value::Object(m)).unwrap_or_default()
244 }
245 _ => AdvancedPrefs::default(),
246 };
247 Ok(BackendSettings {
248 network,
249 agents,
250 inbox,
251 advanced,
252 })
253}
254
255pub(crate) async fn apply_patch(pool: &SqlitePool, patch: &SettingsPatch) -> Result<()> {
256 match patch {
257 SettingsPatch::Network(v) => {
258 save_section(pool, SettingsSection::Network, serde_json::to_value(v)?).await
259 }
260 SettingsPatch::Agents(v) => {
261 save_section(pool, SettingsSection::Agents, serde_json::to_value(v)?).await
262 }
263 SettingsPatch::Inbox(v) => {
264 save_section(pool, SettingsSection::Inbox, serde_json::to_value(v)?).await
265 }
266 SettingsPatch::Advanced(v) => {
267 save_section(pool, SettingsSection::Advanced, serde_json::to_value(v)?).await
268 }
269 }
270}