use rusqlite::Connection; use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, Serialize)] pub struct DisplayPreferences { pub density: String, pub theme: String, pub language: String, pub compose_format: String, pub compose_autosave_ms: i32, pub default_priority: String, pub request_mdn_default: bool, pub attachment_reminder: bool, pub deep_index: bool, pub animation_mode: Option, pub preview_pane: String, pub ui_scale: String, pub color_preset: String, pub accent_color: String, pub background_color: String, pub panel_color: String, pub text_color: String, pub font_family: String, pub font_size: i32, pub row_height: i32, pub folder_pane_width: i32, pub message_pane_width: i32, pub border_radius: i32, pub contrast: String, pub show_avatars: bool, pub show_attachment_icons: bool, pub show_flags: bool, pub reduce_motion: bool, pub date_format: String, pub time_format: String, pub icon_size: i32, pub toolbar_density: String, pub sidebar_item_height: i32, pub reader_max_width: i32, pub composer_width: i32, pub selection_color: String, pub unread_font_weight: i32, pub unread_badge_style: String, pub compact_headers: bool, pub show_borders: bool, pub scrollbar_density: String, pub monochrome: bool, pub folder_background: String, pub message_background: String, pub reader_background: String, pub compose_font_family: String, pub reply_position: String, pub quote_style: String, pub forward_mode: String, pub spellcheck_enabled: bool, pub interface_skin: String, pub preset_scale_unlocked: bool, pub updated_at: String, } #[derive(Debug, Default, Deserialize)] pub struct UpdateDisplayPreferences { pub density: Option, pub theme: Option, pub language: Option, pub compose_format: Option, pub compose_autosave_ms: Option, pub default_priority: Option, pub request_mdn_default: Option, pub attachment_reminder: Option, pub deep_index: Option, #[serde(default, deserialize_with = "deserialize_animation_mode_field")] pub animation_mode: Option>, pub preview_pane: Option, pub ui_scale: Option, pub color_preset: Option, pub accent_color: Option, pub background_color: Option, pub panel_color: Option, pub text_color: Option, pub font_family: Option, pub font_size: Option, pub row_height: Option, pub folder_pane_width: Option, pub message_pane_width: Option, pub border_radius: Option, pub contrast: Option, pub show_avatars: Option, pub show_attachment_icons: Option, pub show_flags: Option, pub reduce_motion: Option, pub date_format: Option, pub time_format: Option, pub icon_size: Option, pub toolbar_density: Option, pub sidebar_item_height: Option, pub reader_max_width: Option, pub composer_width: Option, pub selection_color: Option, pub unread_font_weight: Option, pub unread_badge_style: Option, pub compact_headers: Option, pub show_borders: Option, pub scrollbar_density: Option, pub monochrome: Option, pub folder_background: Option, pub message_background: Option, pub reader_background: Option, pub compose_font_family: Option, pub reply_position: Option, pub quote_style: Option, pub forward_mode: Option, pub spellcheck_enabled: Option, pub interface_skin: Option, pub preset_scale_unlocked: Option, } fn deserialize_animation_mode_field<'de, D>( deserializer: D, ) -> Result>, D::Error> where D: Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; Ok(Some(value)) } fn defaults() -> DisplayPreferences { DisplayPreferences { density: "compact".into(), theme: "system".into(), language: "en".into(), compose_format: "html".into(), compose_autosave_ms: 3000, default_priority: "normal".into(), request_mdn_default: false, attachment_reminder: true, deep_index: false, animation_mode: None, preview_pane: "right".into(), ui_scale: "normal".into(), color_preset: "sage".into(), accent_color: "#6f9c86".into(), background_color: "#121513".into(), panel_color: "#181c19".into(), text_color: "#edf0ec".into(), font_family: "system".into(), font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal".into(), show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale".into(), time_format: "locale".into(), icon_size: 18, toolbar_density: "normal".into(), sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e".into(), unread_font_weight: 730, unread_badge_style: "dot".into(), compact_headers: false, show_borders: true, scrollbar_density: "thin".into(), monochrome: false, folder_background: String::new(), message_background: String::new(), reader_background: String::new(), compose_font_family: "system".into(), reply_position: "above".into(), quote_style: "standard".into(), forward_mode: "inline".into(), spellcheck_enabled: true, interface_skin: "default".into(), preset_scale_unlocked: false, updated_at: String::new(), } } pub fn get_preferences(conn: &Connection) -> Result { let result = conn.query_row( "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1", [], |row| Ok(DisplayPreferences { density: row.get(0)?, theme: row.get(1)?, language: row.get(2)?, compose_format: row.get(3)?, deep_index: row.get::<_, i32>(4)? != 0, animation_mode: row.get(5)?, preview_pane: row.get(6)?, ui_scale: row.get(7)?, color_preset: row.get(8)?, accent_color: row.get(9)?, background_color: row.get(10)?, panel_color: row.get(11)?, text_color: row.get(12)?, font_family: row.get(13)?, font_size: row.get(14)?, row_height: row.get(15)?, folder_pane_width: row.get(16)?, message_pane_width: row.get(17)?, border_radius: row.get(18)?, contrast: row.get(19)?, show_avatars: row.get::<_, i32>(20)? != 0, show_attachment_icons: row.get::<_, i32>(21)? != 0, show_flags: row.get::<_, i32>(22)? != 0, reduce_motion: row.get::<_, i32>(23)? != 0, date_format: row.get(24)?, time_format: row.get(25)?, compose_autosave_ms: row.get(26)?, default_priority: row.get(27)?, request_mdn_default: row.get::<_, i32>(28)? != 0, attachment_reminder: row.get::<_, i32>(29)? != 0, icon_size: row.get(30)?, toolbar_density: row.get(31)?, sidebar_item_height: row.get(32)?, reader_max_width: row.get(33)?, composer_width: row.get(34)?, selection_color: row.get(35)?, unread_font_weight: row.get(36)?, unread_badge_style: row.get(37)?, compact_headers: row.get::<_, i32>(38)? != 0, show_borders: row.get::<_, i32>(39)? != 0, scrollbar_density: row.get(40)?, monochrome: row.get::<_, i32>(41)? != 0, folder_background: row.get(42)?, message_background: row.get(43)?, reader_background: row.get(44)?, compose_font_family: row.get(45)?, reply_position: row.get(46)?, quote_style: row.get(47)?, forward_mode: row.get(48)?, spellcheck_enabled: row.get::<_, i32>(49)? != 0, interface_skin: row.get(50)?, preset_scale_unlocked: row.get::<_, i32>(51)? != 0, updated_at: row.get(52)?, }), ); match result { Ok(value) => Ok(value), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(defaults()), Err(error) => Err(format!("Failed to get display preferences: {error}")), } } fn enum_value(name: &str, value: &str, allowed: &[&str]) -> Result<(), String> { if allowed.contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) } } fn range(name: &str, value: i32, min: i32, max: i32) -> Result<(), String> { if (min..=max).contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) } } fn valid_hex(value: &str) -> bool { value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit()) } pub fn update_preferences( conn: &Connection, data: &UpdateDisplayPreferences, ) -> Result { conn.execute( "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)", [], ) .map_err(|e| format!("Failed to ensure preferences row: {e}"))?; let mut sets = Vec::new(); let mut values: Vec> = Vec::new(); let mut idx = 1usize; macro_rules! set_text { ($field:ident, $column:literal) => { if let Some(ref value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value.clone())); idx += 1; } }; } macro_rules! set_int { ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value)); idx += 1; } }; } macro_rules! set_bool { ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value as i32)); idx += 1; } }; } if let Some(ref v) = data.density { enum_value("density", v, &["compact", "comfortable"])?; } if let Some(ref v) = data.theme { enum_value("theme", v, &["light", "dark", "system"])?; } if let Some(ref v) = data.compose_format { enum_value("compose_format", v, &["html", "text", "markdown"])?; } if let Some(v) = data.compose_autosave_ms { range("compose_autosave_ms", v, 1000, 60000)?; } if let Some(ref v) = data.default_priority { enum_value("default_priority", v, &["high", "normal", "low"])?; } if let Some(ref v) = data.preview_pane { enum_value("preview_pane", v, &["right", "bottom", "off"])?; } if let Some(ref v) = data.ui_scale { enum_value("ui_scale", v, &["small", "normal", "large"])?; } if let Some(ref v) = data.color_preset { enum_value( "color_preset", v, &[ "sage", "blue", "teal", "cyan", "indigo", "slate", "graphite", "amber", "orange", "violet", "rose", "red", "emerald", "lime", "sky", "navy", "plum", "magenta", "copper", "sand", "custom", ], )?; } if let Some(ref v) = data.font_family { enum_value("font_family", v, &["system", "inter", "humanist", "mono"])?; } if let Some(ref v) = data.contrast { enum_value("contrast", v, &["soft", "normal", "high"])?; } if let Some(ref v) = data.language { enum_value("language", v, &["en", "pl"])?; } if let Some(ref v) = data.date_format { enum_value("date_format", v, &["locale", "iso", "eu", "us"])?; } if let Some(ref v) = data.time_format { enum_value("time_format", v, &["locale", "24h", "12h"])?; } if let Some(ref v) = data.toolbar_density { enum_value("toolbar_density", v, &["compact", "normal", "spacious"])?; } if let Some(ref v) = data.unread_badge_style { enum_value("unread_badge_style", v, &["dot", "outline", "solid"])?; } if let Some(ref v) = data.scrollbar_density { enum_value("scrollbar_density", v, &["hidden", "thin", "normal"])?; } if let Some(ref v) = data.compose_font_family { enum_value( "compose_font_family", v, &["system", "humanist", "serif", "mono"], )?; } if let Some(ref v) = data.reply_position { enum_value("reply_position", v, &["above", "below"])?; } if let Some(ref v) = data.quote_style { enum_value("quote_style", v, &["standard", "compact", "none"])?; } if let Some(ref v) = data.forward_mode { enum_value("forward_mode", v, &["inline", "attachment"])?; } if let Some(ref v) = data.interface_skin { enum_value( "interface_skin", v, &[ "default", "rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord", ], )?; } if let Some(v) = data.font_size { range("font_size", v, 11, 18)?; } if let Some(v) = data.row_height { range("row_height", v, 42, 88)?; } if let Some(v) = data.folder_pane_width { range("folder_pane_width", v, 180, 360)?; } if let Some(v) = data.message_pane_width { range("message_pane_width", v, 300, 720)?; } if let Some(v) = data.border_radius { range("border_radius", v, 0, 12)?; } if let Some(v) = data.icon_size { range("icon_size", v, 14, 28)?; } if let Some(v) = data.sidebar_item_height { range("sidebar_item_height", v, 24, 48)?; } if let Some(v) = data.reader_max_width { if v != 0 { range("reader_max_width", v, 560, 1400)?; } } if let Some(v) = data.composer_width { range("composer_width", v, 640, 1400)?; } if let Some(v) = data.unread_font_weight { range("unread_font_weight", v, 500, 800)?; } if let Some(ref v) = data.accent_color { if !valid_hex(v) { return Err(format!("Invalid accent_color: {v}")); } } if let Some(ref v) = data.background_color { if !valid_hex(v) { return Err(format!("Invalid background_color: {v}")); } } if let Some(ref v) = data.panel_color { if !valid_hex(v) { return Err(format!("Invalid panel_color: {v}")); } } if let Some(ref v) = data.text_color { if !valid_hex(v) { return Err(format!("Invalid text_color: {v}")); } } if let Some(ref v) = data.selection_color { if !valid_hex(v) { return Err(format!("Invalid selection_color: {v}")); } } for (name, value) in [ ("folder_background", data.folder_background.as_deref()), ("message_background", data.message_background.as_deref()), ("reader_background", data.reader_background.as_deref()), ] { if let Some(v) = value { if !v.is_empty() && !valid_hex(v) { return Err(format!("Invalid {name}: {v}")); } } } if let Some(ref mode) = data.animation_mode { if let Some(v) = mode { enum_value("animation_mode", v, &["rich", "medium", "subtle", "off"])?; } } set_text!(density, "density"); set_text!(theme, "theme"); set_text!(language, "language"); set_text!(compose_format, "compose_format"); set_int!(compose_autosave_ms, "compose_autosave_ms"); set_text!(default_priority, "default_priority"); set_bool!(request_mdn_default, "request_mdn_default"); set_bool!(attachment_reminder, "attachment_reminder"); set_bool!(deep_index, "deep_index"); if let Some(ref value) = data.animation_mode { sets.push(format!("animation_mode = ?{}", idx)); values.push(Box::new(value.clone())); idx += 1; } set_text!(preview_pane, "preview_pane"); set_text!(ui_scale, "ui_scale"); set_text!(color_preset, "color_preset"); set_text!(accent_color, "accent_color"); set_text!(background_color, "background_color"); set_text!(panel_color, "panel_color"); set_text!(text_color, "text_color"); set_text!(font_family, "font_family"); set_int!(font_size, "font_size"); set_int!(row_height, "row_height"); set_int!(folder_pane_width, "folder_pane_width"); set_int!(message_pane_width, "message_pane_width"); set_int!(border_radius, "border_radius"); set_text!(contrast, "contrast"); set_bool!(show_avatars, "show_avatars"); set_bool!(show_attachment_icons, "show_attachment_icons"); set_bool!(show_flags, "show_flags"); set_bool!(reduce_motion, "reduce_motion"); set_text!(date_format, "date_format"); set_text!(time_format, "time_format"); set_int!(icon_size, "icon_size"); set_text!(toolbar_density, "toolbar_density"); set_int!(sidebar_item_height, "sidebar_item_height"); set_int!(reader_max_width, "reader_max_width"); set_int!(composer_width, "composer_width"); set_text!(selection_color, "selection_color"); set_int!(unread_font_weight, "unread_font_weight"); set_text!(unread_badge_style, "unread_badge_style"); set_bool!(compact_headers, "compact_headers"); set_bool!(show_borders, "show_borders"); set_text!(scrollbar_density, "scrollbar_density"); set_bool!(monochrome, "monochrome"); set_text!(folder_background, "folder_background"); set_text!(message_background, "message_background"); set_text!(reader_background, "reader_background"); set_text!(compose_font_family, "compose_font_family"); set_text!(reply_position, "reply_position"); set_text!(quote_style, "quote_style"); set_text!(forward_mode, "forward_mode"); set_bool!(spellcheck_enabled, "spellcheck_enabled"); set_text!(interface_skin, "interface_skin"); set_bool!(preset_scale_unlocked, "preset_scale_unlocked"); if sets.is_empty() { return get_preferences(conn); } sets.push("updated_at = datetime('now')".to_string()); let sql = format!( "UPDATE display_preferences SET {} WHERE id = ?{}", sets.join(", "), idx ); values.push(Box::new(1_i32)); let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect(); conn.execute(&sql, refs.as_slice()) .map_err(|e| format!("Failed to update display preferences: {e}"))?; get_preferences(conn) } #[cfg(test)] mod tests { use super::*; use crate::db::pool::open_test_db; #[test] fn defaults_and_personalization_roundtrip() { let conn = open_test_db(); let defaults = get_preferences(&conn).unwrap(); assert_eq!(defaults.color_preset, "sage"); let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({ "theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699", "font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480, "show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic", "preset_scale_unlocked":true })) .unwrap(); let prefs = update_preferences(&conn, &update).unwrap(); assert_eq!(prefs.language, "pl"); assert_eq!(prefs.accent_color, "#336699"); assert_eq!(prefs.font_size, 15); assert!(!prefs.show_avatars); assert_eq!(prefs.interface_skin, "roundcube-elastic"); assert!(prefs.preset_scale_unlocked); } #[test] fn built_in_interface_skins_are_accepted() { for skin in [ "default", "rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord", ] { let conn = open_test_db(); let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({"interface_skin": skin})).unwrap(); let prefs = update_preferences(&conn, &update).unwrap(); assert_eq!(prefs.interface_skin, skin); } } #[test] fn invalid_interface_skin_is_rejected() { let conn = open_test_db(); let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({"interface_skin":"roundcube-unknown"})) .unwrap(); assert!(update_preferences(&conn, &update).is_err()); } #[test] fn invalid_customization_is_rejected() { let conn = open_test_db(); let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({"accent_color":"red"})).unwrap(); assert!(update_preferences(&conn, &update).is_err()); } }