Skip to main content

postcrate_core/db/
settings.rs

1//! Typed setting sections persisted in the `settings` table.
2//!
3//! The keys mirror the existing TypeScript zustand store sections
4//! (`network`, `agents`, `inbox`, `advanced`) so a downstream UI can sync
5//! to backend defaults.
6
7use 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    /// Serve the HTTP API over HTTPS, reusing the cert/key configured
43    /// for STARTTLS. Requires `--features tls` and a valid cert.
44    #[serde(default)]
45    pub api_tls: bool,
46    /// When set, every `/api/v1/...` request must carry
47    /// `Authorization: Bearer <token>`. The healthz endpoint is
48    /// always open so liveness probes still work.
49    #[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/// One-of patch — exactly one section is set per call.
137#[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        // Coerce a non-object payload into a single `_value` slot.
184        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}