1 parent
3b9110c8c8
commit
beae94e932
42 files changed
+2518
-274
No files matched your search
+106
-58
@@ -1,6 +1,53 @@
|
||||
use crate::db::pool::{Connection, DbValue, ToDbValue};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Every predefined interface style. `default` (follow the server-wide
|
||||
/// `default_interface_skin`) is accepted as a user preference in addition to
|
||||
/// these, but is not itself a style. Shared with configuration validation so
|
||||
/// the server default and the per-user preference can never drift apart.
|
||||
pub const INTERFACE_SKINS: &[&str] = &[
|
||||
"rustbox",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"clean",
|
||||
"gmail",
|
||||
"proton",
|
||||
"zoho",
|
||||
"outlook",
|
||||
"minimal",
|
||||
"graphite",
|
||||
"high-contrast",
|
||||
"fastmail",
|
||||
"apple-mail",
|
||||
"yahoo",
|
||||
"hey",
|
||||
"nord",
|
||||
"github",
|
||||
"gitlab",
|
||||
"discord",
|
||||
"slack",
|
||||
"reddit",
|
||||
"notion",
|
||||
"dracula",
|
||||
"catppuccin",
|
||||
"solarized",
|
||||
"gruvbox",
|
||||
"tokyo-night",
|
||||
"rose-pine",
|
||||
"everforest",
|
||||
"synthwave",
|
||||
"paper",
|
||||
"terminal",
|
||||
"aqua",
|
||||
"linear",
|
||||
"material",
|
||||
"retro",
|
||||
];
|
||||
|
||||
/// How far a predefined interface style may reshape the interface.
|
||||
pub const SKIN_DEPTHS: &[&str] = &["colors", "standard", "immersive"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DisplayPreferences {
|
||||
pub density: String,
|
||||
@@ -56,6 +103,8 @@ pub struct DisplayPreferences {
|
||||
pub interface_skin: String,
|
||||
pub preset_scale_unlocked: bool,
|
||||
pub adaptive_email_colors: bool,
|
||||
pub skin_depth: String,
|
||||
pub skin_gradients: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -115,6 +164,8 @@ pub struct UpdateDisplayPreferences {
|
||||
pub interface_skin: Option<String>,
|
||||
pub preset_scale_unlocked: Option<bool>,
|
||||
pub adaptive_email_colors: Option<bool>,
|
||||
pub skin_depth: Option<String>,
|
||||
pub skin_gradients: Option<bool>,
|
||||
}
|
||||
|
||||
fn deserialize_animation_mode_field<'de, D>(
|
||||
@@ -182,6 +233,8 @@ fn defaults() -> DisplayPreferences {
|
||||
interface_skin: "default".into(),
|
||||
preset_scale_unlocked: false,
|
||||
adaptive_email_colors: false,
|
||||
skin_depth: "standard".into(),
|
||||
skin_gradients: false,
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -242,7 +295,9 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
|
||||
interface_skin: row.get(50)?,
|
||||
preset_scale_unlocked: row.get::<_, i32>(51)? != 0,
|
||||
adaptive_email_colors: row.get::<_, i32>(52)? != 0,
|
||||
updated_at: row.get(53)?,
|
||||
skin_depth: row.get(53)?,
|
||||
skin_gradients: row.get::<_, i32>(54)? != 0,
|
||||
updated_at: row.get(55)?,
|
||||
})
|
||||
});
|
||||
match result {
|
||||
@@ -380,37 +435,13 @@ pub fn update_preferences(
|
||||
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",
|
||||
"github",
|
||||
"gitlab",
|
||||
"discord",
|
||||
"slack",
|
||||
"reddit",
|
||||
"notion",
|
||||
],
|
||||
)?;
|
||||
if let Some(ref v) = data.interface_skin
|
||||
&& v != "default"
|
||||
{
|
||||
enum_value("interface_skin", v, INTERFACE_SKINS)?;
|
||||
}
|
||||
if let Some(ref v) = data.skin_depth {
|
||||
enum_value("skin_depth", v, SKIN_DEPTHS)?;
|
||||
}
|
||||
if let Some(v) = data.font_size {
|
||||
range("font_size", v, 11, 18)?;
|
||||
@@ -544,6 +575,8 @@ pub fn update_preferences(
|
||||
set_text!(interface_skin, "interface_skin");
|
||||
set_bool!(preset_scale_unlocked, "preset_scale_unlocked");
|
||||
set_bool!(adaptive_email_colors, "adaptive_email_colors");
|
||||
set_text!(skin_depth, "skin_depth");
|
||||
set_bool!(skin_gradients, "skin_gradients");
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_preferences(conn);
|
||||
@@ -610,32 +643,7 @@ mod tests {
|
||||
|
||||
#[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",
|
||||
"github",
|
||||
"gitlab",
|
||||
"discord",
|
||||
"slack",
|
||||
"reddit",
|
||||
"notion",
|
||||
] {
|
||||
for skin in std::iter::once("default").chain(INTERFACE_SKINS.iter().copied()) {
|
||||
let conn = open_test_db();
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"interface_skin": skin})).unwrap();
|
||||
@@ -660,4 +668,44 @@ mod tests {
|
||||
serde_json::from_value(serde_json::json!({"accent_color":"red"})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_depth_defaults_to_standard_and_roundtrips() {
|
||||
let conn = open_test_db();
|
||||
assert_eq!(get_preferences(&conn).unwrap().skin_depth, "standard");
|
||||
for depth in SKIN_DEPTHS {
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"skin_depth": depth})).unwrap();
|
||||
assert_eq!(
|
||||
update_preferences(&conn, &update).unwrap().skin_depth,
|
||||
*depth
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_gradients_default_off_and_roundtrip() {
|
||||
let conn = open_test_db();
|
||||
assert!(!get_preferences(&conn).unwrap().skin_gradients);
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"skin_gradients": true})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).unwrap().skin_gradients);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_skin_depth_is_rejected() {
|
||||
let conn = open_test_db();
|
||||
let update: UpdateDisplayPreferences =
|
||||
serde_json::from_value(serde_json::json!({"skin_depth":"total"})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_skin_catalog_has_no_duplicates_or_default() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for skin in INTERFACE_SKINS {
|
||||
assert_ne!(*skin, "default");
|
||||
assert!(seen.insert(*skin), "duplicate interface skin {skin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-52
@@ -16,38 +16,23 @@ use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
pub const BASE_SCHEMA_VERSION: u32 = 50;
|
||||
pub const BASE_SCHEMA_VERSION: u32 = 52;
|
||||
const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
|
||||
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
|
||||
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
|
||||
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
),
|
||||
(52, include_str!("../../migrations/sqlite/V052__snooze.sql")),
|
||||
];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
),
|
||||
(52, include_str!("../../migrations/mysql/V052__snooze.sql")),
|
||||
];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/pgsql/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
),
|
||||
(52, include_str!("../../migrations/pgsql/V052__snooze.sql")),
|
||||
];
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
53,
|
||||
include_str!("../../migrations/sqlite/V053__skin_depth_and_gradients.sql"),
|
||||
)];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
53,
|
||||
include_str!("../../migrations/mysql/V053__skin_depth_and_gradients.sql"),
|
||||
)];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
53,
|
||||
include_str!("../../migrations/pgsql/V053__skin_depth_and_gradients.sql"),
|
||||
)];
|
||||
|
||||
const TENANT_TABLES: &[&str] = &[
|
||||
"blob_delete_outbox",
|
||||
@@ -1836,7 +1821,7 @@ fn read_legacy_sqlite(path: &Path) -> Result<Vec<LegacyTableData>, String> {
|
||||
.map_err(|e| format!("failed to read legacy SQLite schema version: {e}"))?;
|
||||
if version < BASE_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"legacy SQLite schema version {version} is older than the consolidated RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with a V050-capable RustBox release first"
|
||||
"legacy SQLite schema version {version} is older than the consolidated RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with a V{BASE_SCHEMA_VERSION:03}-capable RustBox release first"
|
||||
));
|
||||
}
|
||||
let latest_supported_sqlite = SQLITE_MIGRATIONS
|
||||
@@ -2197,7 +2182,7 @@ fn apply_sqlite_migrations(
|
||||
) -> Result<(), String> {
|
||||
if current < BASE_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"database schema version {current} is older than the consolidated RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with a V050-capable RustBox release first"
|
||||
"database schema version {current} is older than the consolidated RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with a V{BASE_SCHEMA_VERSION:03}-capable RustBox release first"
|
||||
));
|
||||
}
|
||||
let latest = latest_schema_version();
|
||||
@@ -2642,19 +2627,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_v051_migrates_existing_v050_preferences() {
|
||||
fn sqlite_v053_adds_skin_depth_and_gradients_to_existing_baseline_tenants() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE rustbox_accounts (user_hash TEXT PRIMARY KEY, table_prefix TEXT NOT NULL, schema_version INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\n\
|
||||
CREATE TABLE u_test__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system');\n\
|
||||
CREATE TABLE u_test__mail_preferences (id INTEGER PRIMARY KEY, messages_per_page INTEGER NOT NULL DEFAULT 300);\n\
|
||||
INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES('test', 'u_test__', 50);\n\
|
||||
INSERT INTO u_test__display_preferences(id) VALUES(1);\n\
|
||||
INSERT INTO u_test__mail_preferences(id) VALUES(1);",
|
||||
CREATE TABLE u_test__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system', interface_skin TEXT NOT NULL DEFAULT 'rustbox');\n\
|
||||
INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES('test', 'u_test__', 52);\n\
|
||||
INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_sqlite_migrations(&conn, "u_test__", 50).unwrap();
|
||||
apply_sqlite_migrations(&conn, "u_test__", BASE_SCHEMA_VERSION).unwrap();
|
||||
|
||||
let version: u32 = conn
|
||||
.query_row(
|
||||
@@ -2663,28 +2646,30 @@ mod tests {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(version, 52);
|
||||
assert_eq!(version, latest_schema_version());
|
||||
|
||||
let adaptive: i64 = conn
|
||||
let (skin, depth, gradients): (String, String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT adaptive_email_colors FROM u_test__display_preferences WHERE id=1",
|
||||
"SELECT interface_skin, skin_depth, skin_gradients FROM u_test__display_preferences WHERE id=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
let mode: String = conn
|
||||
.query_row(
|
||||
"SELECT message_loading_mode FROM u_test__mail_preferences WHERE id=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(adaptive, 0);
|
||||
assert_eq!(mode, "pagination");
|
||||
assert_eq!(skin, "nord");
|
||||
assert_eq!(depth, "standard");
|
||||
assert_eq!(gradients, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sqlite_v050_is_imported_and_backed_up() {
|
||||
fn sqlite_migrations_reject_tenants_older_than_the_baseline() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let error =
|
||||
apply_sqlite_migrations(&conn, "u_test__", BASE_SCHEMA_VERSION - 1).unwrap_err();
|
||||
assert!(error.contains("older than the consolidated RustBox baseline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sqlite_baseline_is_imported_and_backed_up() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let data_dir = temp.path();
|
||||
let user_hash = format!("{:x}", Sha256::digest(b"legacy-user"));
|
||||
@@ -2700,7 +2685,9 @@ mod tests {
|
||||
legacy
|
||||
.execute_batch(&tenant_seed_sql(DatabaseBackend::Sqlite, ""))
|
||||
.unwrap();
|
||||
legacy.execute_batch("PRAGMA user_version=50;").unwrap();
|
||||
legacy
|
||||
.execute_batch(&format!("PRAGMA user_version={BASE_SCHEMA_VERSION};"))
|
||||
.unwrap();
|
||||
legacy.execute(
|
||||
"INSERT INTO display_preferences(id, theme, accent_color) VALUES(1, 'dark', '#123456')",
|
||||
[],
|
||||
|
||||
Reference in new issue
Block a user