1 parent
d55640d37a
commit
fff8521a17
67 files changed
+11016
-16763
No files matched your search
+311
-71
@@ -53,6 +53,7 @@ pub struct DisplayPreferences {
|
||||
pub quote_style: String,
|
||||
pub forward_mode: String,
|
||||
pub spellcheck_enabled: bool,
|
||||
pub interface_skin: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -109,9 +110,12 @@ pub struct UpdateDisplayPreferences {
|
||||
pub quote_style: Option<String>,
|
||||
pub forward_mode: Option<String>,
|
||||
pub spellcheck_enabled: Option<bool>,
|
||||
pub interface_skin: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_animation_mode_field<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
|
||||
fn deserialize_animation_mode_field<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
@@ -171,13 +175,14 @@ fn defaults() -> DisplayPreferences {
|
||||
quote_style: "standard".into(),
|
||||
forward_mode: "inline".into(),
|
||||
spellcheck_enabled: true,
|
||||
interface_skin: "rustbox".into(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String> {
|
||||
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, updated_at FROM display_preferences WHERE id = 1",
|
||||
"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, 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)?,
|
||||
@@ -190,7 +195,7 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
|
||||
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, updated_at: row.get(50)?,
|
||||
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)?, updated_at: row.get(51)?,
|
||||
}),
|
||||
);
|
||||
match result {
|
||||
@@ -201,89 +206,293 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
|
||||
}
|
||||
|
||||
fn enum_value(name: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
|
||||
if allowed.contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) }
|
||||
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}")) }
|
||||
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<DisplayPreferences, String> {
|
||||
conn.execute("INSERT OR IGNORE INTO display_preferences (id) VALUES (1)", [])
|
||||
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
|
||||
pub fn update_preferences(
|
||||
conn: &Connection,
|
||||
data: &UpdateDisplayPreferences,
|
||||
) -> Result<DisplayPreferences, String> {
|
||||
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<Box<dyn rusqlite::types::ToSql>> = 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", "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(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}")); } }
|
||||
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 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");
|
||||
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", "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,
|
||||
&[
|
||||
"rustbox",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"clean",
|
||||
"graphite",
|
||||
"high-contrast",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
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");
|
||||
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");
|
||||
|
||||
if sets.is_empty() { return get_preferences(conn); }
|
||||
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);
|
||||
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}"))?;
|
||||
conn.execute(&sql, refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update display preferences: {e}"))?;
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
@@ -300,19 +509,50 @@ mod tests {
|
||||
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
|
||||
})).unwrap();
|
||||
"show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic"
|
||||
}))
|
||||
.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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_interface_skins_are_accepted() {
|
||||
for skin in [
|
||||
"rustbox",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"clean",
|
||||
"graphite",
|
||||
"high-contrast",
|
||||
] {
|
||||
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();
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"accent_color":"red"})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ pub mod mail_preferences;
|
||||
pub mod messages;
|
||||
pub mod notification_preferences;
|
||||
pub mod pgp;
|
||||
pub mod response_templates;
|
||||
pub mod pool;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
pub mod trusted_senders;
|
||||
|
||||
pub mod saved_searches;
|
||||
+161
-42
@@ -35,46 +35,164 @@ impl UserDb {
|
||||
/// All migration scripts in order. Each entry is (version, sql).
|
||||
const MIGRATIONS: &[(u32, &str)] = &[
|
||||
(1, include_str!("../../migrations/V001__initial_schema.sql")),
|
||||
(2, include_str!("../../migrations/V002__folders_and_messages.sql")),
|
||||
(
|
||||
2,
|
||||
include_str!("../../migrations/V002__folders_and_messages.sql"),
|
||||
),
|
||||
(3, include_str!("../../migrations/V003__add_date_epoch.sql")),
|
||||
(4, include_str!("../../migrations/V004__folder_messages_updated_at.sql")),
|
||||
(5, include_str!("../../migrations/V005__drafts_and_attachments.sql")),
|
||||
(6, include_str!("../../migrations/V006__cache_attachments_and_headers.sql")),
|
||||
(
|
||||
4,
|
||||
include_str!("../../migrations/V004__folder_messages_updated_at.sql"),
|
||||
),
|
||||
(
|
||||
5,
|
||||
include_str!("../../migrations/V005__drafts_and_attachments.sql"),
|
||||
),
|
||||
(
|
||||
6,
|
||||
include_str!("../../migrations/V006__cache_attachments_and_headers.sql"),
|
||||
),
|
||||
(7, include_str!("../../migrations/V007__contacts.sql")),
|
||||
(8, include_str!("../../migrations/V008__identities.sql")),
|
||||
(9, include_str!("../../migrations/V009__notification_preferences.sql")),
|
||||
(10, include_str!("../../migrations/V010__contact_groups.sql")),
|
||||
(11, include_str!("../../migrations/V011__message_reaction.sql")),
|
||||
(
|
||||
9,
|
||||
include_str!("../../migrations/V009__notification_preferences.sql"),
|
||||
),
|
||||
(
|
||||
10,
|
||||
include_str!("../../migrations/V010__contact_groups.sql"),
|
||||
),
|
||||
(
|
||||
11,
|
||||
include_str!("../../migrations/V011__message_reaction.sql"),
|
||||
),
|
||||
(12, include_str!("../../migrations/V012__tags.sql")),
|
||||
(13, include_str!("../../migrations/V013__display_preferences.sql")),
|
||||
(14, include_str!("../../migrations/V014__compose_format.sql")),
|
||||
(
|
||||
13,
|
||||
include_str!("../../migrations/V013__display_preferences.sql"),
|
||||
),
|
||||
(
|
||||
14,
|
||||
include_str!("../../migrations/V014__compose_format.sql"),
|
||||
),
|
||||
(15, include_str!("../../migrations/V015__deep_index.sql")),
|
||||
(16, include_str!("../../migrations/V016__calendar.sql")),
|
||||
(17, include_str!("../../migrations/V017__add_email_theme.sql")),
|
||||
(18, include_str!("../../migrations/V018__animation_mode.sql")),
|
||||
(19, include_str!("../../migrations/V019__known_addresses.sql")),
|
||||
(20, include_str!("../../migrations/V020__appearance_personalization.sql")),
|
||||
(21, include_str!("../../migrations/V021__special_folders.sql")),
|
||||
(22, include_str!("../../migrations/V022__identity_options.sql")),
|
||||
(23, include_str!("../../migrations/V023__saved_searches.sql")),
|
||||
(24, include_str!("../../migrations/V024__notification_dnd.sql")),
|
||||
(25, include_str!("../../migrations/V025__custom_theme_colors.sql")),
|
||||
(26, include_str!("../../migrations/V026__mail_preferences.sql")),
|
||||
(27, include_str!("../../migrations/V027__contact_profile.sql")),
|
||||
(28, include_str!("../../migrations/V028__contact_multi_values.sql")),
|
||||
(29, include_str!("../../migrations/V029__draft_send_options.sql")),
|
||||
(30, include_str!("../../migrations/V030__folder_list_preferences.sql")),
|
||||
(31, include_str!("../../migrations/V031__compose_preferences.sql")),
|
||||
(32, include_str!("../../migrations/V032__advanced_appearance.sql")),
|
||||
(33, include_str!("../../migrations/V033__draft_compose_format.sql")),
|
||||
(34, include_str!("../../migrations/V034__imap_keyword_sync.sql")),
|
||||
(35, include_str!("../../migrations/V035__compose_behavior_preferences.sql")),
|
||||
(36, include_str!("../../migrations/V036__remote_draft_tracking.sql")),
|
||||
(37, include_str!("../../migrations/V037__openpgp_keyring.sql")),
|
||||
(38, include_str!("../../migrations/V038__identity_openpgp.sql")),
|
||||
(39, include_str!("../../migrations/V039__draft_openpgp_options.sql")),
|
||||
(40, include_str!("../../migrations/V040__draft_openpgp_attach_key.sql")),
|
||||
(41, include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql")),
|
||||
(
|
||||
17,
|
||||
include_str!("../../migrations/V017__add_email_theme.sql"),
|
||||
),
|
||||
(
|
||||
18,
|
||||
include_str!("../../migrations/V018__animation_mode.sql"),
|
||||
),
|
||||
(
|
||||
19,
|
||||
include_str!("../../migrations/V019__known_addresses.sql"),
|
||||
),
|
||||
(
|
||||
20,
|
||||
include_str!("../../migrations/V020__appearance_personalization.sql"),
|
||||
),
|
||||
(
|
||||
21,
|
||||
include_str!("../../migrations/V021__special_folders.sql"),
|
||||
),
|
||||
(
|
||||
22,
|
||||
include_str!("../../migrations/V022__identity_options.sql"),
|
||||
),
|
||||
(
|
||||
23,
|
||||
include_str!("../../migrations/V023__saved_searches.sql"),
|
||||
),
|
||||
(
|
||||
24,
|
||||
include_str!("../../migrations/V024__notification_dnd.sql"),
|
||||
),
|
||||
(
|
||||
25,
|
||||
include_str!("../../migrations/V025__custom_theme_colors.sql"),
|
||||
),
|
||||
(
|
||||
26,
|
||||
include_str!("../../migrations/V026__mail_preferences.sql"),
|
||||
),
|
||||
(
|
||||
27,
|
||||
include_str!("../../migrations/V027__contact_profile.sql"),
|
||||
),
|
||||
(
|
||||
28,
|
||||
include_str!("../../migrations/V028__contact_multi_values.sql"),
|
||||
),
|
||||
(
|
||||
29,
|
||||
include_str!("../../migrations/V029__draft_send_options.sql"),
|
||||
),
|
||||
(
|
||||
30,
|
||||
include_str!("../../migrations/V030__folder_list_preferences.sql"),
|
||||
),
|
||||
(
|
||||
31,
|
||||
include_str!("../../migrations/V031__compose_preferences.sql"),
|
||||
),
|
||||
(
|
||||
32,
|
||||
include_str!("../../migrations/V032__advanced_appearance.sql"),
|
||||
),
|
||||
(
|
||||
33,
|
||||
include_str!("../../migrations/V033__draft_compose_format.sql"),
|
||||
),
|
||||
(
|
||||
34,
|
||||
include_str!("../../migrations/V034__imap_keyword_sync.sql"),
|
||||
),
|
||||
(
|
||||
35,
|
||||
include_str!("../../migrations/V035__compose_behavior_preferences.sql"),
|
||||
),
|
||||
(
|
||||
36,
|
||||
include_str!("../../migrations/V036__remote_draft_tracking.sql"),
|
||||
),
|
||||
(
|
||||
37,
|
||||
include_str!("../../migrations/V037__openpgp_keyring.sql"),
|
||||
),
|
||||
(
|
||||
38,
|
||||
include_str!("../../migrations/V038__identity_openpgp.sql"),
|
||||
),
|
||||
(
|
||||
39,
|
||||
include_str!("../../migrations/V039__draft_openpgp_options.sql"),
|
||||
),
|
||||
(
|
||||
40,
|
||||
include_str!("../../migrations/V040__draft_openpgp_attach_key.sql"),
|
||||
),
|
||||
(
|
||||
41,
|
||||
include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql"),
|
||||
),
|
||||
(
|
||||
42,
|
||||
include_str!("../../migrations/V042__trusted_senders.sql"),
|
||||
),
|
||||
(
|
||||
43,
|
||||
include_str!("../../migrations/V043__response_templates.sql"),
|
||||
),
|
||||
(
|
||||
44,
|
||||
include_str!("../../migrations/V044__interface_skin.sql"),
|
||||
),
|
||||
(
|
||||
45,
|
||||
include_str!("../../migrations/V045__response_template_format.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Run any pending migrations based on SQLite's `user_version` PRAGMA.
|
||||
@@ -128,8 +246,7 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
|
||||
|
||||
let db_path = dir.join("db.sqlite");
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
|
||||
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
|
||||
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")
|
||||
@@ -216,12 +333,14 @@ mod async_tests {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let job = tokio::spawn(async move {
|
||||
database.run(move |_| {
|
||||
started.send(()).unwrap();
|
||||
wait.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.map_err(|e| format!("runtime could not release database job: {e}"))?;
|
||||
Ok(())
|
||||
}).await
|
||||
database
|
||||
.run(move |_| {
|
||||
started.send(()).unwrap();
|
||||
wait.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.map_err(|e| format!("runtime could not release database job: {e}"))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
ready.await.unwrap();
|
||||
// On a current-thread runtime, an inline DB job prevents this send.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ResponseTemplate {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub format: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ResponseTemplate> {
|
||||
Ok(ResponseTemplate {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
body: row.get(2)?,
|
||||
format: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(conn: &Connection) -> Result<Vec<ResponseTemplate>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name COLLATE NOCASE")
|
||||
.map_err(|e| format!("Failed to prepare response templates: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], from_row)
|
||||
.map_err(|e| format!("Failed to list response templates: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("Failed to read response template: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get(conn: &Connection, id: i64) -> Result<Option<ResponseTemplate>, String> {
|
||||
match conn.query_row(
|
||||
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?1",
|
||||
params![id],
|
||||
from_row,
|
||||
) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get response template: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
body: &str,
|
||||
format: &str,
|
||||
) -> Result<ResponseTemplate, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Template name is required".to_string());
|
||||
}
|
||||
if name.chars().count() > 120 {
|
||||
return Err("Template name is too long".to_string());
|
||||
}
|
||||
if body.len() > 256 * 1024 {
|
||||
return Err("Template body is too large".to_string());
|
||||
}
|
||||
let format = match format {
|
||||
"html" => "html",
|
||||
"text" | "" => "text",
|
||||
_ => return Err("Invalid response template format".to_string()),
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO response_templates(name,body,format) VALUES (?1,?2,?3)",
|
||||
params![name, body, format],
|
||||
)
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("UNIQUE constraint failed") {
|
||||
"A response template with this name already exists".to_string()
|
||||
} else {
|
||||
format!("Failed to create response template: {e}")
|
||||
}
|
||||
})?;
|
||||
get(conn, conn.last_insert_rowid())?
|
||||
.ok_or_else(|| "Failed to read response template".to_string())
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
name: &str,
|
||||
body: &str,
|
||||
format: &str,
|
||||
) -> Result<Option<ResponseTemplate>, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Template name is required".to_string());
|
||||
}
|
||||
if name.chars().count() > 120 {
|
||||
return Err("Template name is too long".to_string());
|
||||
}
|
||||
if body.len() > 256 * 1024 {
|
||||
return Err("Template body is too large".to_string());
|
||||
}
|
||||
let format = match format {
|
||||
"html" => "html",
|
||||
"text" | "" => "text",
|
||||
_ => return Err("Invalid response template format".to_string()),
|
||||
};
|
||||
let changed = conn.execute(
|
||||
"UPDATE response_templates SET name=?1,body=?2,format=?3,updated_at=datetime('now') WHERE id=?4",
|
||||
params![name, body, format, id],
|
||||
).map_err(|e| if e.to_string().contains("UNIQUE constraint failed") {
|
||||
"A response template with this name already exists".to_string()
|
||||
} else { format!("Failed to update response template: {e}") })?;
|
||||
if changed == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
get(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection, id: i64) -> Result<bool, String> {
|
||||
Ok(conn
|
||||
.execute("DELETE FROM response_templates WHERE id=?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete response template: {e}"))?
|
||||
> 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn response_templates_support_text_and_html() {
|
||||
let conn = open_test_db();
|
||||
let text = create(&conn, "Thanks", "Thank you", "text").unwrap();
|
||||
assert_eq!(text.format, "text");
|
||||
let html = create(&conn, "Welcome", "<strong>Welcome</strong>", "html").unwrap();
|
||||
assert_eq!(html.format, "html");
|
||||
let updated = update(&conn, html.id, "Welcome", "<em>Hello</em>", "html")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.body, "<em>Hello</em>");
|
||||
assert_eq!(list(&conn).unwrap().len(), 2);
|
||||
assert!(delete(&conn, text.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_template_format_is_validated() {
|
||||
let conn = open_test_db();
|
||||
assert!(create(&conn, "Bad", "Body", "markdown").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub fn list(conn: &Connection) -> Result<Vec<String>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT email FROM trusted_senders ORDER BY lower(email)")
|
||||
.map_err(|e| format!("Failed to prepare trusted sender list: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| format!("Failed to query trusted senders: {e}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to read trusted sender: {e}"))
|
||||
}
|
||||
|
||||
pub fn add(conn: &Connection, email: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO trusted_senders(email) VALUES(?1)",
|
||||
[email],
|
||||
)
|
||||
.map_err(|e| format!("Failed to trust sender: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(conn: &Connection, email: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"DELETE FROM trusted_senders WHERE email = ?1 COLLATE NOCASE",
|
||||
[email],
|
||||
)
|
||||
.map_err(|e| format!("Failed to remove trusted sender: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn trusted_senders_roundtrip_case_insensitive() {
|
||||
let conn = open_test_db();
|
||||
add(&conn, "Alice@Example.com").unwrap();
|
||||
add(&conn, "alice@example.com").unwrap();
|
||||
assert_eq!(list(&conn).unwrap().len(), 1);
|
||||
remove(&conn, "ALICE@example.com").unwrap();
|
||||
assert!(list(&conn).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user