v0.2.15
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 23:13:21 +01:00
1 parent fb9eab87eb
commit d91212d24c
40 files changed
+585 -380

No files matched your search

+8 -2
View File
@@ -55,6 +55,7 @@ pub struct DisplayPreferences {
pub spellcheck_enabled: bool,
pub interface_skin: String,
pub preset_scale_unlocked: bool,
pub adaptive_email_colors: bool,
pub updated_at: String,
}
@@ -113,6 +114,7 @@ pub struct UpdateDisplayPreferences {
pub spellcheck_enabled: Option<bool>,
pub interface_skin: Option<String>,
pub preset_scale_unlocked: Option<bool>,
pub adaptive_email_colors: Option<bool>,
}
fn deserialize_animation_mode_field<'de, D>(
@@ -179,6 +181,7 @@ fn defaults() -> DisplayPreferences {
spellcheck_enabled: true,
interface_skin: "default".into(),
preset_scale_unlocked: false,
adaptive_email_colors: false,
updated_at: String::new(),
}
}
@@ -238,7 +241,8 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
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)?,
adaptive_email_colors: row.get::<_, i32>(52)? != 0,
updated_at: row.get(53)?,
})
});
match result {
@@ -539,6 +543,7 @@ pub fn update_preferences(
set_bool!(spellcheck_enabled, "spellcheck_enabled");
set_text!(interface_skin, "interface_skin");
set_bool!(preset_scale_unlocked, "preset_scale_unlocked");
set_bool!(adaptive_email_colors, "adaptive_email_colors");
if sets.is_empty() {
return get_preferences(conn);
@@ -568,7 +573,7 @@ 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, "interface_skin":"roundcube-elastic", "preset_scale_unlocked":true
"show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic", "preset_scale_unlocked":true, "adaptive_email_colors":true
}))
.unwrap();
let prefs = update_preferences(&conn, &update).unwrap();
@@ -578,6 +583,7 @@ mod tests {
assert!(!prefs.show_avatars);
assert_eq!(prefs.interface_skin, "roundcube-elastic");
assert!(prefs.preset_scale_unlocked);
assert!(prefs.adaptive_email_colors);
}
#[test]
+1
View File
@@ -107,6 +107,7 @@ mod tests {
&conn,
&mail_preferences::UpdateMailPreferences {
messages_per_page: None,
message_loading_mode: None,
mark_read_delay_ms: None,
remote_images: None,
preferred_body: None,
+27 -11
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub struct MailPreferences {
pub messages_per_page: u32,
pub message_loading_mode: String,
pub mark_read_delay_ms: u64,
pub remote_images: String,
pub preferred_body: String,
@@ -20,6 +21,7 @@ pub struct MailPreferences {
#[derive(Debug, Deserialize)]
pub struct UpdateMailPreferences {
pub messages_per_page: Option<u32>,
pub message_loading_mode: Option<String>,
pub mark_read_delay_ms: Option<u64>,
pub remote_images: Option<String>,
pub preferred_body: Option<String>,
@@ -36,17 +38,18 @@ pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
conn.query_row(crate::dbq!(db_mail_preferences::Q_001), [], |row| {
Ok(MailPreferences {
messages_per_page: row.get::<_, u32>(0)?,
mark_read_delay_ms: row.get::<_, u64>(1)?,
remote_images: row.get(2)?,
preferred_body: row.get(3)?,
thread_mode: row.get(4)?,
collapse_quotes: row.get::<_, i32>(5)? != 0,
show_snippets: row.get::<_, i32>(6)? != 0,
default_sort: row.get(7)?,
flagged_on_top: row.get::<_, i32>(8)? != 0,
sync_imap_keywords: row.get::<_, i32>(9)? != 0,
compact_wide_rows: row.get::<_, i32>(10)? != 0,
updated_at: row.get(11)?,
message_loading_mode: row.get(1)?,
mark_read_delay_ms: row.get::<_, u64>(2)?,
remote_images: row.get(3)?,
preferred_body: row.get(4)?,
thread_mode: row.get(5)?,
collapse_quotes: row.get::<_, i32>(6)? != 0,
show_snippets: row.get::<_, i32>(7)? != 0,
default_sort: row.get(8)?,
flagged_on_top: row.get::<_, i32>(9)? != 0,
sync_imap_keywords: row.get::<_, i32>(10)? != 0,
compact_wide_rows: row.get::<_, i32>(11)? != 0,
updated_at: row.get(12)?,
})
})
.map_err(|error| format!("Failed to read mail preferences: {error}"))
@@ -64,6 +67,11 @@ pub fn update_preferences(
{
return Err("messages_per_page must be between 20 and 300".to_string());
}
if let Some(ref value) = data.message_loading_mode
&& !matches!(value.as_str(), "pagination" | "progressive" | "all")
{
return Err("message_loading_mode must be pagination, progressive or all".to_string());
}
if let Some(value) = data.mark_read_delay_ms
&& value > 30_000
{
@@ -102,6 +110,10 @@ pub fn update_preferences(
let current = get_preferences(conn)?;
let messages_per_page = data.messages_per_page.unwrap_or(current.messages_per_page);
let message_loading_mode = data
.message_loading_mode
.as_deref()
.unwrap_or(&current.message_loading_mode);
let mark_read_delay_ms = data
.mark_read_delay_ms
.unwrap_or(current.mark_read_delay_ms);
@@ -130,6 +142,7 @@ pub fn update_preferences(
crate::dbq!(db_mail_preferences::Q_003),
params![
messages_per_page,
message_loading_mode,
mark_read_delay_ms,
remote_images,
preferred_body,
@@ -157,12 +170,14 @@ mod tests {
let conn = open_test_db();
let defaults = get_preferences(&conn).unwrap();
assert_eq!(defaults.messages_per_page, 300);
assert_eq!(defaults.message_loading_mode, "pagination");
assert_eq!(defaults.remote_images, "ask");
let updated = update_preferences(
&conn,
&UpdateMailPreferences {
messages_per_page: Some(100),
message_loading_mode: Some("progressive".to_string()),
mark_read_delay_ms: Some(1500),
remote_images: Some("never".to_string()),
preferred_body: Some("text".to_string()),
@@ -177,6 +192,7 @@ mod tests {
)
.unwrap();
assert_eq!(updated.messages_per_page, 100);
assert_eq!(updated.message_loading_mode, "progressive");
assert_eq!(updated.mark_read_delay_ms, 1500);
assert_eq!(updated.thread_mode, "flat");
assert!(updated.flagged_on_top);
+17 -59
View File
@@ -16,65 +16,23 @@ use tokio::sync::Mutex as AsyncMutex;
use crate::config::AppConfig;
pub const BASE_SCHEMA_VERSION: u32 = 46;
pub const BASE_SCHEMA_VERSION: u32 = 49;
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)] = &[
(
47,
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
),
(
48,
include_str!("../../migrations/sqlite/V048__folder_counter_generation.sql"),
),
(
49,
include_str!("../../migrations/sqlite/V049__blob_delete_outbox.sql"),
),
(
50,
include_str!("../../migrations/sqlite/V050__compact_rows_and_signature_mode.sql"),
),
];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
(
47,
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"),
),
(
48,
include_str!("../../migrations/mysql/V048__folder_counter_generation.sql"),
),
(
49,
include_str!("../../migrations/mysql/V049__blob_delete_outbox.sql"),
),
(
50,
include_str!("../../migrations/mysql/V050__compact_rows_and_signature_mode.sql"),
),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
47,
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"),
),
(
48,
include_str!("../../migrations/pgsql/V048__folder_counter_generation.sql"),
),
(
49,
include_str!("../../migrations/pgsql/V049__blob_delete_outbox.sql"),
),
(
50,
include_str!("../../migrations/pgsql/V050__compact_rows_and_signature_mode.sql"),
),
];
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
50,
include_str!("../../migrations/sqlite/V050__compact_rows_and_signature_mode.sql"),
)];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
50,
include_str!("../../migrations/mysql/V050__compact_rows_and_signature_mode.sql"),
)];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
50,
include_str!("../../migrations/pgsql/V050__compact_rows_and_signature_mode.sql"),
)];
const TENANT_TABLES: &[&str] = &[
"blob_delete_outbox",
@@ -1861,7 +1819,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 legacy RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first"
"legacy SQLite schema version {version} is older than the consolidated RustBox baseline {BASE_SCHEMA_VERSION}; upgrade to schema V049 first"
));
}
let latest_supported_sqlite = SQLITE_MIGRATIONS
@@ -2222,7 +2180,7 @@ fn apply_sqlite_migrations(
) -> Result<(), String> {
if current < BASE_SCHEMA_VERSION {
return Err(format!(
"database schema version {current} is older than the legacy RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first"
"database schema version {current} is older than the consolidated RustBox baseline {BASE_SCHEMA_VERSION}; upgrade to schema V049 first"
));
}
let latest = latest_schema_version();
@@ -2667,7 +2625,7 @@ mod tests {
}
#[test]
fn legacy_sqlite_v046_is_imported_and_backed_up() {
fn legacy_sqlite_v050_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"));
@@ -2683,7 +2641,7 @@ mod tests {
legacy
.execute_batch(&tenant_seed_sql(DatabaseBackend::Sqlite, ""))
.unwrap();
legacy.execute_batch("PRAGMA user_version=46;").unwrap();
legacy.execute_batch("PRAGMA user_version=50;").unwrap();
legacy.execute(
"INSERT INTO display_preferences(id, theme, accent_color) VALUES(1, 'dark', '#123456')",
[],
+1
View File
@@ -53,6 +53,7 @@ mod tests {
&conn,
&UpdateMailPreferences {
messages_per_page: Some(100),
message_loading_mode: None,
mark_read_delay_ms: None,
remote_images: None,
preferred_body: None,
+3 -3
View File
@@ -127,7 +127,7 @@ pub mod db_contacts {
}
pub mod db_display_preferences {
pub const Q_001: &str = "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";
pub const Q_001: &str = "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, adaptive_email_colors, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?";
}
@@ -272,10 +272,10 @@ pub mod db_identities {
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT messages_per_page, message_loading_mode, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, compact_wide_rows = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, message_loading_mode = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, compact_wide_rows = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
}
pub mod db_messages {
+3 -3
View File
@@ -127,7 +127,7 @@ pub mod db_contacts {
}
pub mod db_display_preferences {
pub const Q_001: &str = "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";
pub const Q_001: &str = "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, adaptive_email_colors, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT INTO display_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ${}";
@@ -272,9 +272,9 @@ pub mod db_identities {
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT messages_per_page, message_loading_mode, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, compact_wide_rows = $11, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, message_loading_mode = $2, mark_read_delay_ms = $3, remote_images = $4, preferred_body = $5, thread_mode = $6, collapse_quotes = $7, show_snippets = $8, default_sort = $9, flagged_on_top = $10, sync_imap_keywords = $11, compact_wide_rows = $12, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1";
}
pub mod db_messages {
+3 -3
View File
@@ -125,7 +125,7 @@ pub mod db_contacts {
}
pub mod db_display_preferences {
pub const Q_001: &str = "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";
pub const Q_001: &str = "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, adaptive_email_colors, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?{}";
}
@@ -271,10 +271,10 @@ pub mod db_identities {
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT messages_per_page, message_loading_mode, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT OR IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, compact_wide_rows = ?11, updated_at = datetime('now') WHERE id = 1";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, message_loading_mode = ?2, mark_read_delay_ms = ?3, remote_images = ?4, preferred_body = ?5, thread_mode = ?6, collapse_quotes = ?7, show_snippets = ?8, default_sort = ?9, flagged_on_top = ?10, sync_imap_keywords = ?11, compact_wide_rows = ?12, updated_at = datetime('now') WHERE id = 1";
}
pub mod db_messages {
+37 -3
View File
@@ -244,7 +244,7 @@
.message-load-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
gap: 6px;
padding: 7px;
box-sizing: border-box;
@@ -254,6 +254,40 @@
display: none;
}
.message-pagination {
width: 100%;
display: grid;
grid-template-columns: 30px auto 30px;
justify-content: center;
align-items: center;
gap: 8px;
padding: 7px;
box-sizing: border-box;
}
.message-pagination.hidden {
display: none;
}
.pagination-arrow {
width: 30px;
height: 30px;
border-color: var(--line);
}
.pagination-arrow svg {
width: 16px;
height: 16px;
}
.message-page-status {
min-width: 88px;
text-align: center;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.load-more {
width: 100%;
min-width: 0;
@@ -1355,6 +1389,7 @@ body.reader-fullscreen-open {
min-width: max-content;
}
}
/* Optional wide-pane single-line message rows. The preference is on by default,
but the layout only activates once the message pane has enough horizontal room. */
@container message-pane (min-width: 520px) {
@@ -1443,5 +1478,4 @@ body.reader-fullscreen-open {
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-icons {
grid-column: 6;
}
}
}
+16
View File
@@ -274,6 +274,22 @@
max-width: 760px;
}
.appearance-toggle-described {
align-items: flex-start;
}
.appearance-toggle-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.appearance-toggle-copy small {
display: block;
line-height: 1.35;
font-weight: 400;
}
.compose-limit-summary {
grid-column: 1 / -1;
display: flex;
+4 -1
View File
@@ -40,6 +40,7 @@
"loading": "Loading…",
"loading_quota": "Loading quota…",
"of": "of",
"page": "Page",
"used": "used",
"server_limit_unavailable": "server limit unavailable",
"quota_unavailable": "Quota unavailable",
@@ -56,5 +57,7 @@
"move_copy": "Move / copy",
"move_to": "Move to…",
"copy_to": "Copy to…",
"login_success": "Successfully logged in"
"login_success": "Successfully logged in",
"adaptive_email_colors": "Adapt HTML mail colors to current theme",
"adaptive_email_colors_note": "Adjusts neutral backgrounds and low-contrast text while preserving layout, images and brand colors."
}
+12 -1
View File
@@ -40,6 +40,7 @@
"loading": "Ładowanie…",
"loading_quota": "Ładowanie limitu…",
"of": "z",
"page": "Strona",
"used": "wykorzystano",
"server_limit_unavailable": "limit serwera niedostępny",
"quota_unavailable": "Limit niedostępny",
@@ -189,6 +190,14 @@
"Message-list background override": "Nadpisanie tła listy wiadomości",
"Reader background override": "Nadpisanie tła czytnika",
"Control message-list and reader behavior for this account.": "Ustaw zachowanie listy wiadomości i czytnika dla tego konta.",
"Message loading": "Ładowanie wiadomości",
"Pagination": "Paginacja",
"Smooth loading": "Płynne doładowywanie",
"Load all messages": "Załaduj wszystkie wiadomości",
"Mailbox pages": "Strony skrzynki",
"Previous page": "Poprzednia strona",
"Next page": "Następna strona",
"Page 1 of 1": "Strona 1 z 1",
"Messages per page": "Wiadomości na stronę",
"Mark as read": "Oznacz jako przeczytane",
"Immediately": "Natychmiast",
@@ -655,5 +664,7 @@
"move_copy": "Przenieś / kopiuj",
"move_to": "Przenieś do…",
"copy_to": "Kopiuj do…",
"login_success": "Pomyślnie zalogowano"
"login_success": "Pomyślnie zalogowano",
"adaptive_email_colors": "Dopasuj kolory maili HTML do bieżącego motywu",
"adaptive_email_colors_note": "Dostosowuje neutralne tła i tekst o niskim kontraście bez zmiany układu, obrazów i kolorów firmowych."
}
+30 -9
View File
@@ -116,10 +116,10 @@
</svg></button></div>
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
<div id="folder-quota" class="folder-quota" aria-live="polite">
<div class="folder-quota-label"><span>IMAP storage</span><span id="folder-quota-text">Loading…</span></div>
<div class="folder-quota-label"><span>Mailbox storage</span><span id="folder-quota-text">Loading…</span></div>
<div class="folder-quota-track"><span id="folder-quota-bar"></span></div>
</div>
<div class="folder-pane-footer"><span id="mail-server-label">IMAP</span><span>RustBox __RUSTBOX_VERSION__</span>
<div class="folder-pane-footer"><span id="mail-server-label">RustBox</span><span>__RUSTBOX_VERSION__</span>
</div>
<div id="folder-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
aria-label="Resize folder pane"></div>
@@ -180,9 +180,19 @@
<button id="selection-done" class="button quiet" type="button">Done</button>
</div>
<div id="message-list" class="message-list" aria-live="polite"></div>
<div id="message-pagination" class="message-pagination hidden" aria-label="Mailbox pages">
<button id="page-prev" class="icon-button pagination-arrow" type="button" title="Previous page"
aria-label="Previous page"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-arrow-left"></use>
</svg></button>
<span id="page-status" class="message-page-status">Page 1 of 1</span>
<button id="page-next" class="icon-button pagination-arrow" type="button" title="Next page"
aria-label="Next page"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use>
</svg></button>
</div>
<div id="message-load-actions" class="message-load-actions hidden">
<button id="load-more" class="button quiet load-more" type="button">Load more</button>
<button id="load-all" class="button quiet load-more" type="button">Load all</button>
</div>
<div id="message-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
aria-label="Resize message list"></div>
@@ -311,11 +321,11 @@
<form id="compose-form" class="dialog-card compose-card" method="dialog">
<header class="dialog-header"><strong id="compose-title">New message</strong>
<div class="compose-header-actions"><button type="button" id="compose-fields-toggle" class="button quiet small"
aria-expanded="true" title="Hide addressing fields">Hide fields</button><button type="button" id="compose-recover" class="button quiet small"
title="Recover a saved draft">Recover draft</button><button type="button" id="compose-fullscreen"
class="icon-button compose-fullscreen-toggle" aria-label="Toggle full screen composer" aria-pressed="false"
title="Full screen">⛶</button><button type="button" class="icon-button close-dialog"
aria-label="Close"><svg>
aria-expanded="true" title="Hide addressing fields">Hide fields</button><button type="button"
id="compose-recover" class="button quiet small" title="Recover a saved draft">Recover draft</button><button
type="button" id="compose-fullscreen" class="icon-button compose-fullscreen-toggle"
aria-label="Toggle full screen composer" aria-pressed="false" title="Full screen">⛶</button><button
type="button" class="icon-button close-dialog" aria-label="Close"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use>
</svg></button></div>
</header>
@@ -444,7 +454,8 @@
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg></button>
</div>
<div id="compose-image-actions" class="compose-image-actions hidden" role="toolbar" aria-label="Inline image actions">
<div id="compose-image-actions" class="compose-image-actions hidden" role="toolbar"
aria-label="Inline image actions">
<span class="compose-image-actions-label">Inline image</span>
<button type="button" class="button quiet small" data-image-width="25">25%</button>
<button type="button" class="button quiet small" data-image-width="50">50%</button>
@@ -740,6 +751,11 @@
flags</span></label>
<label class="checkbox-row"><input id="pref-reduce-motion" type="checkbox"><span
data-i18n="reduce_motion">Reduce motion</span></label>
<label class="checkbox-row appearance-toggle-described"><input id="pref-adaptive-email-colors"
type="checkbox"><span class="appearance-toggle-copy"><span data-i18n="adaptive_email_colors">Adapt
HTML mail colors to current theme</span><small class="muted"
data-i18n="adaptive_email_colors_note">Adjusts neutral backgrounds and low-contrast text while
preserving layout, images and brand colors.</small></span></label>
</div>
<details class="appearance-advanced">
<summary>Advanced interface styling</summary>
@@ -808,6 +824,11 @@
</div>
</div>
<div class="settings-grid">
<label><span>Message loading</span><select id="mail-pref-loading-mode">
<option value="pagination">Pagination</option>
<option value="progressive">Smooth loading</option>
<option value="all">Load all messages</option>
</select></label>
<label><span>Messages per page</span><select id="mail-pref-per-page">
<option value="20">20</option>
<option value="50">50</option>
+4 -2
View File
@@ -17,6 +17,8 @@
page: 0,
perPage: 300,
total: 0,
loadingMoreMessages: false,
loadingAllMessages: false,
messages: [],
selected: null,
selectedMessages: new Set(),
@@ -45,7 +47,7 @@
savedSearches: [],
responseTemplates: [],
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false, compact_wide_rows: true },
mailPrefs: { messages_per_page: 300, message_loading_mode: "pagination", mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false, compact_wide_rows: true },
currentFolderFlaggedOnTop: false,
markReadTimer: null,
ws: null,
@@ -98,7 +100,7 @@
colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13,
rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4,
contrast: "normal", showAvatars: true, showAttachmentIcons: true, showFlags: true, reduceMotion: false, dateFormat: "locale", timeFormat: "locale",
iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#28352e", unreadFontWeight: 730, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false, folderBackground: "", messageBackground: "", readerBackground: "",
iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#28352e", unreadFontWeight: 730, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false, folderBackground: "", messageBackground: "", readerBackground: "", adaptiveEmailColors: false,
},
translations: {},
quotaWarningLevel: null,
+6 -4
View File
@@ -13,7 +13,7 @@ const preferenceMap = {
composeAutosaveMs: "compose_autosave_ms", defaultPriority: "default_priority", requestMdnDefault: "request_mdn_default", attachmentReminder: "attachment_reminder", composeFontFamily: "compose_font_family", replyPosition: "reply_position", quoteStyle: "quote_style", forwardMode: "forward_mode", spellcheckEnabled: "spellcheck_enabled",
dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height",
readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style",
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background",
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background", adaptiveEmailColors: "adaptive_email_colors",
};
const preferenceTimers = new Map();
let presetPromotionTimer = null;
@@ -273,7 +273,7 @@ function applyPreferences() {
"#pref-selection-color": visual.selectionColor, "#pref-unread-weight": visual.unreadFontWeight, "#pref-unread-badge": visual.unreadBadgeStyle, "#pref-scrollbar-density": visual.scrollbarDensity,
};
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
const checks = { "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
const checks = { "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-adaptive-email-colors": p.adaptiveEmailColors, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); });
const outputs = { "#pref-accent-value": displayedAccent, "#pref-background-value": displayedBackground, "#pref-panel-value": displayedPanel, "#pref-text-color-value": displayedText, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) };
Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; });
@@ -314,7 +314,7 @@ function normalizeServerPreferences(data) {
rowHeight: data.row_height || 58, folderPaneWidth: data.folder_pane_width || 228, messagePaneWidth: data.message_pane_width || 420,
borderRadius: data.border_radius ?? 4, contrast: data.contrast || "normal", showAvatars: data.show_avatars !== false,
showAttachmentIcons: data.show_attachment_icons !== false, showFlags: data.show_flags !== false, reduceMotion: Boolean(data.reduce_motion), dateFormat: data.date_format || "locale", timeFormat: data.time_format || "locale",
iconSize: Number(data.icon_size || 18), toolbarDensity: data.toolbar_density || "normal", sidebarItemHeight: Number(data.sidebar_item_height || 29), readerMaxWidth: Number(data.reader_max_width || 0), composerWidth: Number(data.composer_width || 900), selectionColor: data.selection_color || "#28352e", unreadFontWeight: Number(data.unread_font_weight || 730), unreadBadgeStyle: data.unread_badge_style || "dot", compactHeaders: Boolean(data.compact_headers), showBorders: data.show_borders !== false, scrollbarDensity: data.scrollbar_density || "thin", monochrome: Boolean(data.monochrome), folderBackground: data.folder_background || "", messageBackground: data.message_background || "", readerBackground: data.reader_background || "",
iconSize: Number(data.icon_size || 18), toolbarDensity: data.toolbar_density || "normal", sidebarItemHeight: Number(data.sidebar_item_height || 29), readerMaxWidth: Number(data.reader_max_width || 0), composerWidth: Number(data.composer_width || 900), selectionColor: data.selection_color || "#28352e", unreadFontWeight: Number(data.unread_font_weight || 730), unreadBadgeStyle: data.unread_badge_style || "dot", compactHeaders: Boolean(data.compact_headers), showBorders: data.show_borders !== false, scrollbarDensity: data.scrollbar_density || "thin", monochrome: Boolean(data.monochrome), folderBackground: data.folder_background || "", messageBackground: data.message_background || "", readerBackground: data.reader_background || "", adaptiveEmailColors: Boolean(data.adaptive_email_colors),
};
}
@@ -331,6 +331,7 @@ async function loadMailPreferences() {
state.mailPrefs = { ...state.mailPrefs, ...prefs };
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
const values = {
"#mail-pref-loading-mode": state.mailPrefs.message_loading_mode || "pagination",
"#mail-pref-per-page": String(state.perPage),
"#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0),
"#mail-pref-remote-images": state.mailPrefs.remote_images || "ask",
@@ -374,6 +375,7 @@ async function saveCompactWideRowsAppearance(enabled) {
async function saveMailPreferences() {
const payload = {
message_loading_mode: $("#mail-pref-loading-mode").value,
messages_per_page: Number($("#mail-pref-per-page").value),
mark_read_delay_ms: Number($("#mail-pref-read-delay").value),
remote_images: $("#mail-pref-remote-images").value,
@@ -564,7 +566,7 @@ function savePreference(key, value) {
}
async function resetAppearance() {
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "" };
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "", adaptive_email_colors: false };
try {
normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }));
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: true }) });
+284 -26
View File
@@ -1,11 +1,45 @@
function mailboxLoadingMode() {
const mode = state.mailPrefs.message_loading_mode || "pagination";
return ["pagination", "progressive", "all"].includes(mode) ? mode : "pagination";
}
function mailboxPageCount() {
return Math.max(1, Math.ceil(Number(state.total || 0) / Math.max(20, Number(state.perPage || 300))));
}
function updateMessageLoadControls() {
const pagination = $("#message-pagination");
const loadActions = $("#message-load-actions");
const mode = mailboxLoadingMode();
const searchable = Boolean(state.searchContext);
const canLoadMore = !searchable && state.messages.length < state.total;
if (pagination) {
const pages = mailboxPageCount();
const showPagination = !searchable && mode === "pagination" && pages > 1;
pagination.classList.toggle("hidden", !showPagination);
const previous = $("#page-prev");
const next = $("#page-next");
const status = $("#page-status");
if (previous) previous.disabled = state.page <= 0;
if (next) next.disabled = state.page >= pages - 1;
if (status) status.textContent = `${tr("page", "Page")} ${Math.min(state.page + 1, pages)} ${tr("of", "of")} ${pages}`;
}
if (loadActions) {
loadActions.classList.toggle("hidden", mode !== "progressive" || !canLoadMore);
}
}
async function loadMessages(reset = false, options = {}) {
const list = $("#message-list");
const mode = mailboxLoadingMode();
const restoreState = Boolean(options.restoreState);
const savedState = restoreState ? readMessageListState(state.folder) : null;
const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null;
const pageBeforeReset = state.page;
if (reset) {
state.page = 0;
state.page = options.background && mode === "pagination" ? pageBeforeReset : 0;
if (!options.background) {
state.messages = [];
state.selectedMessages.clear();
@@ -17,15 +51,20 @@ async function loadMessages(reset = false, options = {}) {
try {
const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc");
const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1);
const targetPage = reset && savedState ? Math.min(savedState.page, maxRestorePage) : state.page;
const pages = reset && targetPage > 0 ? Array.from({ length: targetPage + 1 }, (_, index) => index) : [state.page];
const savedPage = reset && savedState
? (mode === "pagination" ? savedState.page : Math.min(savedState.page, maxRestorePage))
: state.page;
const targetPage = reset && mode === "all" ? 0 : savedPage;
const pages = reset && mode === "progressive" && targetPage > 0
? Array.from({ length: targetPage + 1 }, (_, index) => index)
: [targetPage];
let loaded = [];
let totalCount = 0;
let flaggedOnTop = state.currentFolderFlaggedOnTop;
let folderTotalCount = null;
let folderUnreadCount = null;
let syncing = false;
let lastLoadedPage = state.page;
let lastLoadedPage = targetPage;
for (const page of pages) {
const forceRefresh = options.force ? "&refresh=true" : "";
const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}${forceRefresh}`, { method: "GET" });
@@ -38,10 +77,10 @@ async function loadMessages(reset = false, options = {}) {
lastLoadedPage = page;
if (loaded.length >= totalCount) break;
}
state.page = reset ? lastLoadedPage : state.page;
state.page = lastLoadedPage;
state.total = totalCount;
state.currentFolderFlaggedOnTop = flaggedOnTop;
state.messages = reset ? loaded : [...state.messages, ...loaded];
state.messages = reset || mode === "pagination" ? loaded : [...state.messages, ...loaded];
if (state.folders.some((item) => item.name === state.folder)) {
setFolderCounts(state.folder, folderTotalCount, folderUnreadCount);
}
@@ -54,39 +93,87 @@ async function loadMessages(reset = false, options = {}) {
$("#connection-label").textContent = "Connected";
renderMessages();
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
if (Number.isFinite(desiredScroll) && desiredScroll > 0) {
if (mode === "pagination") {
requestAnimationFrame(() => { list.scrollTop = 0; });
} else if (Number.isFinite(desiredScroll) && desiredScroll > 0) {
requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); });
}
saveMessageListState();
if (reset && mode === "all" && !state.searchContext && state.messages.length < state.total && !options.skipAutoAll) {
await loadAllMessages();
}
return true;
} catch (error) {
$("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error";
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
$("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable";
updateMessageLoadControls();
return false;
}
}
async function changeMailboxPage(delta) {
if (state.searchContext || mailboxLoadingMode() !== "pagination") return false;
const pages = mailboxPageCount();
const target = Math.max(0, Math.min(pages - 1, state.page + delta));
if (target === state.page) return false;
const previousPage = state.page;
state.page = target;
const loaded = await loadMessages(false, { background: true });
if (!loaded) {
state.page = previousPage;
updateMessageLoadControls();
return false;
}
const list = $("#message-list");
if (list) list.scrollTop = 0;
saveMessageListState();
return true;
}
async function loadNextProgressivePage() {
if (state.searchContext || mailboxLoadingMode() !== "progressive" || state.loadingMoreMessages || state.messages.length >= state.total) return;
state.loadingMoreMessages = true;
const before = state.messages.length;
const previousPage = state.page;
state.page += 1;
try {
await loadMessages(false, { background: true });
if (state.messages.length <= before) state.page = previousPage;
} finally {
state.loadingMoreMessages = false;
updateMessageLoadControls();
}
}
function handleMessageListScroll() {
scheduleMessageListStateSave();
if (mailboxLoadingMode() !== "progressive" || state.loadingMoreMessages || state.searchContext) return;
const list = $("#message-list");
if (!list || state.messages.length >= state.total) return;
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 240) loadNextProgressivePage();
}
async function loadAllMessages() {
if (state.searchContext || state.messages.length >= state.total) return;
const loadAll = $("#load-all");
const loadMore = $("#load-more");
setBusy(loadAll, true);
if (loadMore) loadMore.disabled = true;
if (state.searchContext || mailboxLoadingMode() !== "all" || state.messages.length >= state.total || state.loadingAllMessages) return;
state.loadingAllMessages = true;
try {
let guard = 0;
while (state.messages.length < state.total && guard < 10000) {
const before = state.messages.length;
const previousPage = state.page;
state.page += 1;
await loadMessages(false, { background: true });
await loadMessages(false, { background: true, skipAutoAll: true });
guard += 1;
if (state.messages.length <= before) {
state.page = Math.max(0, state.page - 1);
state.page = previousPage;
break;
}
}
saveMessageListState();
} finally {
setBusy(loadAll, false);
if (loadMore) loadMore.disabled = false;
state.loadingAllMessages = false;
updateMessageLoadControls();
}
}
@@ -301,24 +388,30 @@ function renderMessages() {
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
list.append(row);
}
const canLoadMore = !state.searchContext && state.messages.length < state.total;
$("#message-load-actions")?.classList.toggle("hidden", !canLoadMore);
updateMessageLoadControls();
renderBulkToolbar();
}
function navigateMessage(delta) {
async function navigateMessage(delta) {
if (!state.selected) return;
const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder);
if (index < 0) return;
const next = state.messages[index + delta];
if (!next) return toast(delta > 0 ? "No next loaded message" : "No previous loaded message");
openMessage(next.folder || state.folder, next.uid);
if (next) return openMessage(next.folder || state.folder, next.uid);
if (!state.searchContext && mailboxLoadingMode() === "pagination" && await changeMailboxPage(delta > 0 ? 1 : -1)) {
const boundary = delta > 0 ? state.messages[0] : state.messages[state.messages.length - 1];
if (boundary) return openMessage(boundary.folder || state.folder, boundary.uid);
}
toast(delta > 0 ? "No next message" : "No previous message");
}
function updateMessageNavigation() {
const index = state.selected ? state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder) : -1;
$("#reader-prev").disabled = index <= 0;
$("#reader-next").disabled = index < 0 || index >= state.messages.length - 1;
const paginated = !state.searchContext && mailboxLoadingMode() === "pagination";
const hasPreviousPage = paginated && state.page > 0;
const hasNextPage = paginated && state.page < mailboxPageCount() - 1;
$("#reader-prev").disabled = index < 0 || (index <= 0 && !hasPreviousPage);
$("#reader-next").disabled = index < 0 || (index >= state.messages.length - 1 && !hasNextPage);
}
async function senderIsSavedContact(email) {
@@ -730,6 +823,162 @@ async function discoverCurrentPgpSigner() {
if (key) await processCurrentPgpMessage();
}
function parseAdaptiveEmailColor(value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw || ["transparent", "inherit", "initial", "unset", "currentcolor"].includes(raw)) return null;
const named = {
black: [0, 0, 0], white: [255, 255, 255], gray: [128, 128, 128], grey: [128, 128, 128],
silver: [192, 192, 192], navy: [0, 0, 128], red: [255, 0, 0], green: [0, 128, 0],
blue: [0, 0, 255], yellow: [255, 255, 0], maroon: [128, 0, 0], purple: [128, 0, 128],
teal: [0, 128, 128], olive: [128, 128, 0], aqua: [0, 255, 255], fuchsia: [255, 0, 255],
lime: [0, 255, 0], whitesmoke: [245, 245, 245], gainsboro: [220, 220, 220],
};
if (named[raw]) return { r: named[raw][0], g: named[raw][1], b: named[raw][2], a: 1 };
let match = raw.match(/^#([0-9a-f]{3,8})$/i);
if (match) {
let hex = match[1];
if (hex.length === 3 || hex.length === 4) hex = [...hex].map((part) => part + part).join("");
if (hex.length === 6 || hex.length === 8) {
return {
r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16),
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
};
}
}
match = raw.match(/^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i);
if (!match) return null;
const alpha = match[4]?.endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4] ?? 1);
const clamp = (value) => Math.max(0, Math.min(255, Math.round(Number(value))));
return { r: clamp(match[1]), g: clamp(match[2]), b: clamp(match[3]), a: Math.max(0, Math.min(1, alpha)) };
}
function adaptiveEmailRgbCss(color) {
return `rgb(${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)})`;
}
function adaptiveEmailMix(a, b, amount) {
const t = Math.max(0, Math.min(1, Number(amount) || 0));
return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t, a: 1 };
}
function adaptiveEmailComposite(foreground, background) {
if (!foreground || foreground.a >= 0.999) return foreground;
const alpha = Math.max(0, Math.min(1, foreground.a));
return {
r: foreground.r * alpha + background.r * (1 - alpha),
g: foreground.g * alpha + background.g * (1 - alpha),
b: foreground.b * alpha + background.b * (1 - alpha),
a: 1,
};
}
function adaptiveEmailBrightness(color) {
return (0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b) / 255;
}
function adaptiveEmailSaturation(color) {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
return max <= 0 ? 0 : (max - min) / max;
}
function adaptiveEmailLuminance(color) {
const channel = (value) => {
const normalized = value / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
}
function adaptiveEmailContrast(a, b) {
const one = adaptiveEmailLuminance(a);
const two = adaptiveEmailLuminance(b);
return (Math.max(one, two) + 0.05) / (Math.min(one, two) + 0.05);
}
function adaptiveEmailReadableColor(color, background, preferred) {
if (!color || !background || adaptiveEmailContrast(color, background) >= 3.5) return color;
for (let step = 0.18; step <= 1.001; step += 0.14) {
const candidate = adaptiveEmailMix(color, preferred, step);
if (adaptiveEmailContrast(candidate, background) >= 4.5) return candidate;
}
const black = { r: 0, g: 0, b: 0, a: 1 };
const white = { r: 255, g: 255, b: 255, a: 1 };
return adaptiveEmailContrast(white, background) >= adaptiveEmailContrast(black, background) ? white : black;
}
function adaptMessagePreviewColors(html, detail, body) {
const targetTheme = resolveTheme(state.prefs.theme);
const targetDark = targetTheme === "dark";
const bodyStyles = getComputedStyle(body);
const surface = parseAdaptiveEmailColor(bodyStyles.backgroundColor) || (targetDark ? { r: 24, g: 28, b: 25, a: 1 } : { r: 255, g: 255, b: 255, a: 1 });
const text = parseAdaptiveEmailColor(bodyStyles.color) || (targetDark ? { r: 237, g: 240, b: 236, a: 1 } : { r: 32, g: 38, b: 34, a: 1 });
const mailTheme = String(detail?.email_theme || "").toLowerCase();
const themeAlreadyMatches = mailTheme === targetTheme;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
const remapBackground = (color) => {
if (!color || color.a < 0.45) return null;
const brightness = adaptiveEmailBrightness(color);
const saturation = adaptiveEmailSaturation(color);
if (saturation > 0.38) return null;
if (targetDark && brightness >= 0.62) {
return adaptiveEmailMix(surface, text, Math.max(0, Math.min(0.12, (1 - brightness) * 0.26)));
}
if (!targetDark && brightness <= 0.38) {
return adaptiveEmailMix(surface, text, Math.max(0, Math.min(0.10, brightness * 0.24)));
}
return null;
};
const remapNeutralText = (color) => {
if (!color || color.a < 0.45) return null;
const brightness = adaptiveEmailBrightness(color);
const saturation = adaptiveEmailSaturation(color);
if (saturation > 0.32) return null;
if (targetDark && brightness <= 0.45) return adaptiveEmailMix(text, surface, brightness * 0.28);
if (!targetDark && brightness >= 0.62) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.28);
return null;
};
const walk = (node, inheritedBackground) => {
let effectiveBackground = inheritedBackground;
const style = node.style;
const backgroundAttribute = node.getAttribute("bgcolor");
const sourceBackground = parseAdaptiveEmailColor(style?.backgroundColor || backgroundAttribute);
if (sourceBackground) {
const composited = adaptiveEmailComposite(sourceBackground, inheritedBackground);
const mappedBackground = themeAlreadyMatches ? null : remapBackground(composited);
if (mappedBackground) {
if (style) style.backgroundColor = adaptiveEmailRgbCss(mappedBackground);
else node.setAttribute("bgcolor", adaptiveEmailRgbCss(mappedBackground));
if (backgroundAttribute) node.setAttribute("bgcolor", adaptiveEmailRgbCss(mappedBackground));
effectiveBackground = mappedBackground;
} else {
effectiveBackground = composited;
}
}
const colorAttribute = node.getAttribute("color");
const sourceColor = parseAdaptiveEmailColor(style?.color || colorAttribute);
if (sourceColor) {
const composited = adaptiveEmailComposite(sourceColor, effectiveBackground);
const neutralMapped = themeAlreadyMatches ? null : remapNeutralText(composited);
const readable = adaptiveEmailReadableColor(neutralMapped || composited, effectiveBackground, text);
if (neutralMapped || readable !== composited) {
const replacement = adaptiveEmailRgbCss(readable);
if (style) style.color = replacement;
if (colorAttribute) node.setAttribute("color", replacement);
}
}
for (const child of node.children) walk(child, effectiveBackground);
};
for (const child of doc.body.children) walk(child, surface);
return { html: doc.body.innerHTML, targetTheme, surface: adaptiveEmailRgbCss(surface), text: adaptiveEmailRgbCss(text) };
}
function renderMessageBody(detail, forceRemote = false) {
const body = $("#message-body");
const remoteBar = $("#remote-image-bar");
@@ -751,9 +1000,18 @@ function renderMessageBody(detail, forceRemote = false) {
iframe.setAttribute("referrerpolicy", "no-referrer");
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}";
const safeHtml = sanitizeMessagePreviewHtml(detail.html);
iframe.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
let safeHtml = sanitizeMessagePreviewHtml(detail.html);
let previewTheme = { targetTheme: "light", surface: "#fff", text: "#202622" };
if (state.prefs.adaptiveEmailColors) {
previewTheme = adaptMessagePreviewColors(safeHtml, detail, body);
safeHtml = previewTheme.html;
}
const quoteColor = state.prefs.adaptiveEmailColors ? `color-mix(in srgb, ${previewTheme.text} 25%, ${previewTheme.surface})` : "#d9ddda";
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? `blockquote{max-height:16em;overflow:auto;border-left:2px solid ${quoteColor};padding-left:12px;margin-left:0}` : `blockquote{border-left:2px solid ${quoteColor};padding-left:12px;margin-left:0}`;
const previewStyle = state.prefs.adaptiveEmailColors
? `body{margin:0;padding:22px;color:${previewTheme.text};background:${previewTheme.surface};font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:inherit;text-decoration:underline}pre{white-space:pre-wrap}${quoteStyle}`
: `body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}`;
iframe.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="${previewTheme.targetTheme}"><style>${previewStyle}</style></head><body>${safeHtml}</body></html>`;
body.append(iframe);
}
+7 -5
View File
@@ -324,9 +324,10 @@ function bindEvents() {
toast("Message sort updated", "success");
} catch (error) { toast(error.message, "error"); }
});
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
$("#load-all").addEventListener("click", loadAllMessages);
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true });
$("#page-prev").addEventListener("click", () => changeMailboxPage(-1));
$("#page-next").addEventListener("click", () => changeMailboxPage(1));
$("#load-more").addEventListener("click", loadNextProgressivePage);
$("#message-list").addEventListener("scroll", handleMessageListScroll, { passive: true });
window.addEventListener("offline", () => setConnectivityState(false));
window.addEventListener("online", async () => {
setConnectivityState(true);
@@ -404,7 +405,7 @@ function bindEvents() {
$("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value = ""; });
$("#pgp-discover").addEventListener("click", () => discoverPgpEmail());
$$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); if (state.authPolicy.pgpEnabled !== false) loadOpenPgp(); } if (button.dataset.tab === "openpgp" && state.authPolicy.pgpEnabled !== false) loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "composing") loadResponseTemplates(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } }));
$("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value));
$("#pref-theme").addEventListener("change", (event) => { savePreference("theme", event.target.value); if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail); });
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
$("#pref-preset-scale-unlocked").addEventListener("change", (event) => savePreference("presetScaleUnlocked", event.target.checked));
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
@@ -423,6 +424,7 @@ function bindEvents() {
$("#pref-attachment-icons").addEventListener("change", (event) => savePreference("showAttachmentIcons", event.target.checked));
$("#pref-flags").addEventListener("change", (event) => savePreference("showFlags", event.target.checked));
$("#pref-reduce-motion").addEventListener("change", (event) => savePreference("reduceMotion", event.target.checked));
$("#pref-adaptive-email-colors").addEventListener("change", (event) => { savePreference("adaptiveEmailColors", event.target.checked); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
[["#pref-icon-size", "iconSize"], ["#pref-sidebar-height", "sidebarItemHeight"], ["#pref-composer-width", "composerWidth"], ["#pref-unread-weight", "unreadFontWeight"]].forEach(([selector, key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value))));
$("#pref-reader-width").addEventListener("change", (event) => savePreference("readerMaxWidth", Number(event.target.value)));
$("#pref-toolbar-density").addEventListener("change", (event) => savePreference("toolbarDensity", event.target.value));
@@ -639,7 +641,7 @@ function bindEvents() {
});
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (!$("#login-view").classList.contains("hidden")) showLogin();
else if (state.prefs.theme === "system") applyPreferences();
else if (state.prefs.theme === "system") { applyPreferences(); if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail); }
});
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
window.addEventListener("popstate", async (event) => {