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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 19:28:47 +01:00
1 parent d55640d37a
commit fff8521a17
67 files changed
+11016 -16763

No files matched your search

+32 -19
View File
@@ -1,8 +1,8 @@
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use dashmap::DashMap;
use rand::RngCore;
@@ -102,17 +102,8 @@ impl SessionStore {
smtp_tls: bool,
) -> (SessionId, AccountId) {
self.add_account_to_browser_with_timeout(
browser_id,
email,
password,
user_hash,
imap_host,
imap_port,
imap_tls,
smtp_host,
smtp_port,
smtp_tls,
None,
browser_id, email, password, user_hash, imap_host, imap_port, imap_tls, smtp_host,
smtp_port, smtp_tls, None,
)
}
@@ -234,7 +225,11 @@ impl SessionStore {
/// Store a display-only User-Agent string for active-session diagnostics.
/// It is never used for authentication or authorization decisions.
pub fn set_user_agent(&self, account_id: &str, user_agent: String) {
let Some(session_id) = self.account_to_session.get(account_id).map(|entry| entry.clone()) else {
let Some(session_id) = self
.account_to_session
.get(account_id)
.map(|entry| entry.clone())
else {
return;
};
if let Some(mut session) = self.sessions.get_mut(&session_id) {
@@ -267,7 +262,11 @@ impl SessionStore {
/// Revoke one session only when it belongs to the requested user.
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
let Some(session_id) = self.account_to_session.get(account_id).map(|entry| entry.clone()) else {
let Some(session_id) = self
.account_to_session
.get(account_id)
.map(|entry| entry.clone())
else {
return false;
};
let belongs_to_user = self
@@ -890,7 +889,11 @@ mod tests {
// --- Consistency tests ---
fn add_test_account(store: &SessionStore, browser_id: &str, email: &str) -> (SessionId, AccountId) {
fn add_test_account(
store: &SessionStore,
browser_id: &str,
email: &str,
) -> (SessionId, AccountId) {
store.add_account_to_browser(
browser_id,
email.into(),
@@ -1231,7 +1234,11 @@ mod tests {
store.purge_expired();
assert_eq!(store.sessions.len(), 1, "only the live session should remain");
assert_eq!(
store.sessions.len(),
1,
"only the live session should remain"
);
assert!(
!store.account_to_session.contains_key(&account1),
"expired account mapping should be gone"
@@ -1299,7 +1306,11 @@ mod tests {
thread::sleep(Duration::from_millis(100));
assert!(store.get_account_session(&browser_id, &account_id, &token).is_none());
assert!(
store
.get_account_session(&browser_id, &account_id, &token)
.is_none()
);
assert!(
!store.account_to_session.contains_key(&account_id),
@@ -1325,7 +1336,9 @@ mod tests {
);
thread::sleep(Duration::from_millis(100));
assert!(store.get(&token).is_some(), "remembered session should outlive default timeout");
assert!(
store.get(&token).is_some(),
"remembered session should outlive default timeout"
);
}
}
+4 -1
View File
@@ -94,7 +94,10 @@ mod tests {
let user_dir = provision_user_data(data_dir, &user_hash).expect("provisioning failed");
assert!(user_dir.exists(), "user directory should exist");
assert!(user_dir.join("db.sqlite").exists(), "db.sqlite should exist");
assert!(
user_dir.join("db.sqlite").exists(),
"db.sqlite should exist"
);
assert!(
user_dir.join("tantivy").is_dir(),
"tantivy/ subdirectory should exist"
+17
View File
@@ -121,6 +121,18 @@ pub struct AppConfig {
/// Disabled by default; public-key export is always available.
#[serde(default)]
pub pgp_allow_private_export: bool,
/// Administrator defaults for special IMAP folders. Per-account settings override these.
#[serde(default)]
pub default_sent_folder: Option<String>,
#[serde(default)]
pub default_drafts_folder: Option<String>,
#[serde(default)]
pub default_trash_folder: Option<String>,
#[serde(default)]
pub default_junk_folder: Option<String>,
#[serde(default)]
pub default_archive_folder: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -385,6 +397,11 @@ mod tests {
assert!(config.trusted_proxies.is_none());
assert!(config.outgoing_headers.is_empty());
assert!(!config.pgp_allow_private_export);
assert!(config.default_sent_folder.is_none());
assert!(config.default_drafts_folder.is_none());
assert!(config.default_trash_folder.is_none());
assert!(config.default_junk_folder.is_none());
assert!(config.default_archive_folder.is_none());
}
#[test]
+311 -71
View File
@@ -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());
}
}
+2
View File
@@ -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
View File
@@ -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.
+150
View File
@@ -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());
}
}
+46
View File
@@ -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());
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ async fn https_get_address(
format!("{host}:{port}")
};
let request = format!(
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.2\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
);
timeout(duration, stream.write_all(request.as_bytes()))
.await
+1 -1
View File
@@ -192,7 +192,7 @@ pub async fn export_ics(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut body = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.1//EN\r\nCALSCALE:GREGORIAN\r\n",
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.2//EN\r\nCALSCALE:GREGORIAN\r\n",
);
for event in &events {
append_ics_event(&mut body, event);
+18
View File
@@ -16,11 +16,13 @@ pub mod messages;
pub mod notification_preferences;
pub mod pgp;
pub mod quota;
pub mod response_templates;
pub mod search;
pub mod send;
pub mod sieve;
pub mod special_folders;
pub mod tags;
pub mod trusted_senders;
pub mod unsubscribe;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
@@ -247,6 +249,14 @@ pub fn create_router(
get(attachments::get_attachment_content),
)
.route("/search", get(search::search_messages))
.route(
"/response-templates",
get(response_templates::list).post(response_templates::create),
)
.route(
"/response-templates/{id}",
put(response_templates::update).delete(response_templates::delete),
)
.route(
"/search/saved",
get(search::list_saved_searches).post(search::create_saved_search),
@@ -399,6 +409,14 @@ pub fn create_router(
get(mail_preferences::get_mail_preferences)
.put(mail_preferences::update_mail_preferences),
)
.route(
"/settings/trusted-senders",
get(trusted_senders::list_trusted_senders).post(trusted_senders::add_trusted_sender),
)
.route(
"/settings/trusted-senders/{email}",
delete(trusted_senders::remove_trusted_sender),
)
.route(
"/folders/{folder}/preferences",
get(folder_preferences::get_folder_preferences)
+77
View File
@@ -0,0 +1,77 @@
use std::sync::Arc;
use axum::{
extract::Path,
response::{IntoResponse, Response},
Extension, Json,
};
use serde::Deserialize;
use crate::{
auth::session::SessionState,
config::AppConfig,
db,
error::AppError,
};
#[derive(Debug, Deserialize)]
pub struct ResponseTemplateRequest {
name: String,
#[serde(default)]
body: String,
#[serde(default = "default_template_format")]
format: String,
}
fn default_template_format() -> String { "text".to_string() }
pub async fn list(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let templates = db::response_templates::list(&conn).map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "templates": templates })).into_response())
}
pub async fn create(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(data): Json<ResponseTemplateRequest>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let template = db::response_templates::create(&conn, &data.name, &data.body, &data.format)
.map_err(AppError::BadRequest)?;
Ok((axum::http::StatusCode::CREATED, Json(template)).into_response())
}
pub async fn update(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<i64>,
Json(data): Json<ResponseTemplateRequest>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
match db::response_templates::update(&conn, id, &data.name, &data.body, &data.format)
.map_err(AppError::BadRequest)? {
Some(template) => Ok(Json(template).into_response()),
None => Err(AppError::NotFound("Response template not found".to_string())),
}
}
pub async fn delete(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
if db::response_templates::delete(&conn, id).map_err(AppError::InternalError)? {
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
} else {
Err(AppError::NotFound("Response template not found".to_string()))
}
}
+48 -2
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use rusqlite::Connection;
use crate::auth::session::SessionState;
use crate::config::AppConfig;
@@ -9,13 +10,57 @@ use crate::db;
use crate::db::special_folders::SpecialFolders;
use crate::error::AppError;
fn configured_folder_if_present(
conn: &Connection,
value: &Option<String>,
) -> Result<Option<String>, String> {
let Some(folder) = value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
[folder],
|row| row.get(0),
)
.map_err(|e| format!("Failed to validate configured special folder {folder}: {e}"))?;
Ok(exists.then(|| folder.to_string()))
}
fn effective_special_folders(
conn: &Connection,
config: &AppConfig,
) -> Result<SpecialFolders, String> {
let mut value = db::special_folders::get_all(conn)?;
if value.sent.is_none() {
value.sent = configured_folder_if_present(conn, &config.default_sent_folder)?;
}
if value.drafts.is_none() {
value.drafts = configured_folder_if_present(conn, &config.default_drafts_folder)?;
}
if value.trash.is_none() {
value.trash = configured_folder_if_present(conn, &config.default_trash_folder)?;
}
if value.junk.is_none() {
value.junk = configured_folder_if_present(conn, &config.default_junk_folder)?;
}
if value.archive.is_none() {
value.archive = configured_folder_if_present(conn, &config.default_archive_folder)?;
}
Ok(value)
}
pub async fn get_special_folders(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let value = db::special_folders::get_all(&conn).map_err(AppError::InternalError)?;
let value = effective_special_folders(&conn, &config).map_err(AppError::InternalError)?;
Ok(Json(value).into_response())
}
@@ -26,7 +71,7 @@ pub async fn update_special_folders(
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let saved = db::special_folders::update_all(&conn, &value).map_err(|message| {
db::special_folders::update_all(&conn, &value).map_err(|message| {
if message.starts_with("Folder does not exist:")
|| message.starts_with("Invalid special folder role:")
{
@@ -35,5 +80,6 @@ pub async fn update_special_folders(
AppError::InternalError(message)
}
})?;
let saved = effective_special_folders(&conn, &config).map_err(AppError::InternalError)?;
Ok(Json(saved).into_response())
}
+4 -2
View File
@@ -319,7 +319,8 @@ async fn root_serves_embedded_rustbox_ui() {
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("RustBox"));
assert!(html.contains("assets/app.js"));
assert!(html.contains("assets/app."));
assert!(html.contains(".js"));
}
#[tokio::test]
@@ -386,7 +387,8 @@ async fn base_path_serves_the_same_embedded_ui() {
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("data-base-path=\"/mail\""));
assert!(html.contains("/mail/assets/app.js"));
assert!(html.contains("/mail/assets/app."));
assert!(html.contains(".js"));
}
#[tokio::test]
+62
View File
@@ -0,0 +1,62 @@
use std::sync::Arc;
use axum::extract::Path;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Deserialize;
use crate::auth::session::SessionState;
use crate::config::AppConfig;
use crate::db;
use crate::error::AppError;
#[derive(Deserialize)]
pub struct TrustSenderRequest {
pub email: String,
}
fn normalize_email(value: &str) -> Result<String, AppError> {
let email = value.trim().to_ascii_lowercase();
if email.is_empty()
|| email.len() > 320
|| !email.contains('@')
|| email.chars().any(char::is_whitespace)
{
return Err(AppError::BadRequest("Invalid sender email".to_string()));
}
Ok(email)
}
pub async fn list_trusted_senders(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let senders = db::trusted_senders::list(&conn).map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "senders": senders })).into_response())
}
pub async fn add_trusted_sender(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(body): Json<TrustSenderRequest>,
) -> Result<Response, AppError> {
let email = normalize_email(&body.email)?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::trusted_senders::add(&conn, &email).map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "email": email, "trusted": true })).into_response())
}
pub async fn remove_trusted_sender(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(email): Path<String>,
) -> Result<Response, AppError> {
let email = normalize_email(&email)?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::trusted_senders::remove(&conn, &email).map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "email": email, "trusted": false })).into_response())
}
+1 -1
View File
@@ -229,7 +229,7 @@ async fn post_one_click_to_address(
format!("{host}:{port}")
};
let request = format!(
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.2\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
ONE_CLICK_BODY.len()
);
+1 -1
View File
@@ -29,7 +29,7 @@ impl fmt::Display for SieveError {
write!(f, "ManageSieve server rejected the command: {msg}")
}
Self::Timeout => write!(f, "ManageSieve operation timed out"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.1 size limit"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.2 size limit"),
}
}
}
+21 -3
View File
@@ -8,11 +8,12 @@ use crate::config::AppConfig;
const INDEX_HTML: &str = include_str!("web/index.html");
const APP_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.css"));
const APP_JS: &[u8] = include_bytes!("web/app.js");
const APP_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"));
const ICONS: &[u8] = include_bytes!("web/icons.svg");
const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json");
const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json");
const OXI_BASE: &str = include_str!("../UPSTREAM_BASE");
const ASSET_ID: &str = env!("RUSTBOX_ASSET_ID");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="9" y="14" width="46" height="36" rx="5" fill="none" stroke="#6f9c86" stroke-width="5"/><path d="m11 18 21 15 21-15" fill="none" stroke="#6f9c86" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>"##;
@@ -45,9 +46,25 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
.trim_start_matches('/')
};
let versioned_css = format!("assets/app.{ASSET_ID}.css");
let versioned_js = format!("assets/app.{ASSET_ID}.js");
if relative == versioned_css.as_str() {
return bytes_response(
"text/css; charset=utf-8",
"public, max-age=31536000, immutable",
APP_CSS,
);
}
if relative == versioned_js.as_str() {
return bytes_response(
"text/javascript; charset=utf-8",
"public, max-age=31536000, immutable",
APP_JS,
);
}
match relative {
// Internal 0.0.1 snapshots change frequently while keeping the same version.
// Do not let a browser mix a new HTML/JS bundle with stale CSS/icons.
// Keep unversioned aliases non-cacheable for compatibility with old bookmarks/proxies.
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
@@ -76,6 +93,7 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
let html = INDEX_HTML
.replace("__RUSTBOX_BASE_PATH__", base_path)
.replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix)
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
(
-7050
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -233,6 +233,10 @@
display: none;
}
.folder-drawer-scrim {
display: none;
}
.search-filter-button {
width: 24px;
height: 24px;
+98
View File
@@ -324,6 +324,73 @@
font-size: 12px;
}
.sender-line {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.sender-line #reader-from {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-contact-button {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--muted);
font-size: 10px;
}
.address-contact-button:hover,
.address-contact-button:focus-visible {
background: var(--hover);
color: var(--text);
}
.address-contact-button svg {
width: 13px;
height: 13px;
}
.reader-recipients {
display: grid;
gap: 2px;
min-width: 0;
}
.recipient-line {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
color: var(--muted);
font-size: 10px;
}
.recipient-line>span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-line .address-contact-button {
width: 21px;
height: 21px;
grid-template-columns: 12px 5px;
}
.sender span,
.message-meta time {
color: var(--muted);
@@ -1032,4 +1099,35 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
.bulk-toolbar .move-control select {
width: 116px;
}
}
@media (max-width: 820px) {
.folder-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.folder-expander {
width: 32px;
height: 34px;
}
.folder-expander svg {
width: 15px;
height: 15px;
}
.folder-row .folder-button {
min-height: 34px;
}
.message-meta {
align-items: start;
}
}
.attachment-preview-body video {
width: 100%;
height: 100%;
max-height: 72dvh;
background: #000;
}
+33 -7
View File
@@ -762,14 +762,30 @@
}
@media (max-width: 560px) {
.compose-addresses>label,
.compose-options-row {
grid-template-columns: 1fr;
gap: 3px;
.compose-addresses {
gap: 7px;
max-height: min(44dvh, 390px);
padding: 8px;
}
.compose-addresses>label {
grid-template-columns: 44px minmax(0, 1fr);
gap: 7px;
min-height: 38px;
align-items: center;
}
.compose-addresses>label>span {
text-align: right;
padding-top: 0;
line-height: 1.2;
}
.compose-options-row {
grid-template-columns: 1fr;
gap: 4px;
}
.compose-addresses>label>span,
.compose-options-row>span {
text-align: left;
padding-top: 0;
@@ -783,4 +799,14 @@
max-height: 112px;
overflow: auto;
}
}
}
.compose-response-control {
min-width: 118px;
max-width: 190px;
}
.compose-response-control select {
min-height: 28px;
padding-top: 2px;
padding-bottom: 2px;
}
+36
View File
@@ -2357,4 +2357,40 @@ kbd {
align-items: flex-start;
flex-direction: column
}
}
.response-template-heading {
margin-top: 18px;
}
.response-template-list {
display: grid;
gap: 6px;
}
.response-template-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--panel-2);
}
.response-template-meta {
min-width: 0;
display: grid;
gap: 2px;
}
.response-template-meta strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.response-template-meta span {
font-size: 10px;
}
+434
View File
@@ -0,0 +1,434 @@
/* Roundcube-compatible visual skins.
Palette values are aligned to Roundcube Classic and Elastic upstream CSS.
Classic never shipped an official dark skin; the dark block is a contrast-preserving adaptation. */
/* ---------- Roundcube Classic ---------- */
html[data-interface-skin="roundcube-classic"] {
--font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif;
--font-mono: Monaco, "Courier New", monospace;
--radius-control: 0px;
--radius-panel: 0px;
}
html[data-interface-skin="roundcube-classic"][data-theme="light"] {
color-scheme: light;
--bg: #f6f6f6;
--panel: #ffffff;
--panel-2: #f2f2f2;
--panel-3: #ebebeb;
--sidebar: #ffffff;
--line: #ebebeb;
--line-strong: #999999;
--text: #000000;
--muted: #666666;
--subtle: #888888;
--accent: #cc3333;
--accent-strong: #b32323;
--accent-ink: #ffffff;
--danger: #cc0000;
--warning: #8a6700;
--success: #4b7f3e;
--field: #ffffff;
--selected: #cc3333;
--hover: #f2f2f2;
--shadow: rgba(0, 0, 0, .22);
--folder-bg: #ffffff;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="roundcube-classic"][data-theme="dark"] {
color-scheme: dark;
--bg: #191919;
--panel: #242424;
--panel-2: #2e2e2e;
--panel-3: #363636;
--sidebar: #202020;
--line: #383838;
--line-strong: #707070;
--text: #f2f2f2;
--muted: #b7b7b7;
--subtle: #929292;
--accent: #d34b4b;
--accent-strong: #e06060;
--accent-ink: #ffffff;
--danger: #ff6666;
--warning: #d2aa4b;
--success: #83ad76;
--field: #1d1d1d;
--selected: #9e3333;
--hover: #303030;
--shadow: rgba(0, 0, 0, .45);
--folder-bg: #202020;
--message-bg: #242424;
--reader-bg: #242424;
}
html[data-interface-skin="roundcube-classic"] body {
font-family: var(--font-sans);
letter-spacing: 0;
}
html[data-interface-skin="roundcube-classic"] .appbar,
html[data-interface-skin="roundcube-classic"] .pane-toolbar,
html[data-interface-skin="roundcube-classic"] .reader-toolbar,
html[data-interface-skin="roundcube-classic"] .dialog-header {
box-shadow: none;
}
html[data-interface-skin="roundcube-classic"][data-theme="light"] .appbar,
html[data-interface-skin="roundcube-classic"][data-theme="light"] .pane-toolbar,
html[data-interface-skin="roundcube-classic"][data-theme="light"] .reader-toolbar,
html[data-interface-skin="roundcube-classic"][data-theme="light"] .dialog-header,
html[data-interface-skin="roundcube-classic"][data-theme="light"] .section-heading {
background: linear-gradient(#f4f4f4, #cccccc);
border-color: #999999;
color: #333333;
}
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .appbar,
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .pane-toolbar,
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .reader-toolbar,
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .dialog-header,
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .section-heading {
background: linear-gradient(#3a3a3a, #292929);
border-color: #666666;
color: #f2f2f2;
}
html[data-interface-skin="roundcube-classic"] .section-heading {
min-height: 22px;
margin: 7px -10px 3px;
padding: 3px 8px;
border-top: 1px solid var(--line-strong);
border-bottom: 1px solid var(--line-strong);
font-size: 10px;
letter-spacing: 0;
text-transform: none;
}
html[data-interface-skin="roundcube-classic"] input,
html[data-interface-skin="roundcube-classic"] select,
html[data-interface-skin="roundcube-classic"] textarea,
html[data-interface-skin="roundcube-classic"] .search-field {
border-color: var(--line-strong);
border-radius: 0;
box-shadow: none;
}
html[data-interface-skin="roundcube-classic"] input:focus,
html[data-interface-skin="roundcube-classic"] select:focus,
html[data-interface-skin="roundcube-classic"] textarea:focus,
html[data-interface-skin="roundcube-classic"] [contenteditable="true"]:focus,
html[data-interface-skin="roundcube-classic"] .search-field:focus-within {
border-color: #cc3333;
box-shadow: 0 0 0 1px #cc3333;
}
html[data-interface-skin="roundcube-classic"] .button,
html[data-interface-skin="roundcube-classic"] .appearance-preset {
border-radius: 0;
box-shadow: none;
}
html[data-interface-skin="roundcube-classic"][data-theme="light"] .button,
html[data-interface-skin="roundcube-classic"][data-theme="light"] .button.primary {
border-color: #a4a4a4;
background: linear-gradient(#ffffff, #e2e2e2);
color: #333333;
}
html[data-interface-skin="roundcube-classic"][data-theme="light"] .button:hover,
html[data-interface-skin="roundcube-classic"][data-theme="light"] .button.primary:hover {
border-color: #777777;
background: linear-gradient(#ffffff, #d5d5d5);
}
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .button,
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .button.primary {
border-color: #6f6f6f;
background: linear-gradient(#3c3c3c, #292929);
color: #f2f2f2;
}
html[data-interface-skin="roundcube-classic"] .button.danger,
html[data-interface-skin="roundcube-classic"] a {
color: var(--danger);
}
html[data-interface-skin="roundcube-classic"] .folder-button {
border-radius: 0;
min-height: var(--sidebar-item-height);
padding-top: 2px;
padding-bottom: 2px;
}
html[data-interface-skin="roundcube-classic"] .folder-button.active {
background: #929292;
border-color: #929292;
color: #ffffff;
font-weight: 700;
}
html[data-interface-skin="roundcube-classic"] .folder-button.active .folder-count,
html[data-interface-skin="roundcube-classic"] .folder-button.active svg {
color: #ffffff;
}
html[data-interface-skin="roundcube-classic"] .message-row {
border-bottom-color: var(--line);
border-radius: 0;
box-shadow: none;
}
html[data-interface-skin="roundcube-classic"] .message-row.active,
html[data-interface-skin="roundcube-classic"] .message-row.selected {
background: var(--selected);
color: #ffffff;
}
html[data-interface-skin="roundcube-classic"] .message-row.active .message-date,
html[data-interface-skin="roundcube-classic"] .message-row.active .message-snippet,
html[data-interface-skin="roundcube-classic"] .message-row.active .message-state,
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-date,
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-snippet {
color: #ffffff;
border-color: rgba(255,255,255,.8);
}
html[data-interface-skin="roundcube-classic"] .settings-tab {
border-radius: 0;
}
html[data-interface-skin="roundcube-classic"] .settings-tab.active {
background: #929292;
color: #ffffff;
}
html[data-interface-skin="roundcube-classic"] .dialog-card,
html[data-interface-skin="roundcube-classic"] .login-panel,
html[data-interface-skin="roundcube-classic"] .appearance-preset,
html[data-interface-skin="roundcube-classic"] .diagnostic-card {
border-radius: 0;
}
/* ---------- Roundcube Elastic ---------- */
html[data-interface-skin="roundcube-elastic"] {
--font-sans: Roboto, "Segoe UI", Arial, sans-serif;
--radius-control: .3rem;
--radius-panel: .3rem;
}
html[data-interface-skin="roundcube-elastic"][data-theme="light"] {
color-scheme: light;
--bg: #ffffff;
--panel: #ffffff;
--panel-2: #f4f4f4;
--panel-3: #fbfbfb;
--sidebar: #ffffff;
--line: #dddddd;
--line-strong: #ced4da;
--text: #2b3438;
--muted: #737f84;
--subtle: #879298;
--accent: #37beff;
--accent-strong: #00acff;
--accent-ink: #ffffff;
--danger: #ff5552;
--warning: #a27d00;
--success: #41b849;
--field: #ffffff;
--selected: #ebf8ff;
--hover: #f4fafe;
--shadow: rgba(22, 27, 29, .18);
--folder-bg: #ffffff;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] {
color-scheme: dark;
--bg: #21292c;
--panel: #21292c;
--panel-2: #2f3a3f;
--panel-3: #283236;
--sidebar: #21292c;
--line: #3a494e;
--line-strong: #4d6066;
--text: #c5d1d3;
--muted: #9dafb3;
--subtle: #85999e;
--accent: #37beff;
--accent-strong: #55c7ff;
--accent-ink: #07181f;
--danger: #ff7774;
--warning: #ffd452;
--success: #65c86c;
--field: #21292c;
--selected: #374549;
--hover: #2c373a;
--shadow: rgba(0, 0, 0, .42);
--folder-bg: #21292c;
--message-bg: #21292c;
--reader-bg: #21292c;
}
html[data-interface-skin="roundcube-elastic"] body {
font-family: var(--font-sans);
line-height: 1.5;
}
html[data-interface-skin="roundcube-elastic"] .appbar,
html[data-interface-skin="roundcube-elastic"] .pane-toolbar,
html[data-interface-skin="roundcube-elastic"] .reader-toolbar,
html[data-interface-skin="roundcube-elastic"] .dialog-header {
background: #f4f4f4;
box-shadow: none;
}
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .appbar,
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .pane-toolbar,
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .reader-toolbar,
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .dialog-header {
background: #2f3a3f;
}
html[data-interface-skin="roundcube-elastic"] input,
html[data-interface-skin="roundcube-elastic"] select,
html[data-interface-skin="roundcube-elastic"] textarea,
html[data-interface-skin="roundcube-elastic"] .search-field {
border-color: var(--line-strong);
box-shadow: none;
}
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] input,
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] select,
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] textarea,
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .search-field {
border-color: #7c949c;
}
html[data-interface-skin="roundcube-elastic"] input:focus,
html[data-interface-skin="roundcube-elastic"] select:focus,
html[data-interface-skin="roundcube-elastic"] textarea:focus,
html[data-interface-skin="roundcube-elastic"] [contenteditable="true"]:focus,
html[data-interface-skin="roundcube-elastic"] .search-field:focus-within {
border-color: #37beff;
box-shadow: 0 0 0 1px #37beff;
}
html[data-interface-skin="roundcube-elastic"] .button.primary {
border-color: #37beff;
background: #37beff;
color: #ffffff;
}
html[data-interface-skin="roundcube-elastic"] .button.primary:hover {
border-color: #00acff;
background: #00acff;
}
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .button.primary {
border-color: #006a9d;
background: #006a9d;
color: #ffffff;
}
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .button.primary:hover {
border-color: #007fb9;
background: #007fb9;
}
html[data-interface-skin="roundcube-elastic"] .folder-button.active,
html[data-interface-skin="roundcube-elastic"] .message-row.active,
html[data-interface-skin="roundcube-elastic"] .message-row.selected,
html[data-interface-skin="roundcube-elastic"] .settings-tab.active {
background: var(--selected);
border-color: var(--line);
color: var(--text);
}
html[data-interface-skin="roundcube-elastic"] .folder-count.unread,
html[data-interface-skin="roundcube-elastic"] .message-row.unread .message-state,
html[data-interface-skin="roundcube-elastic"] .brand-icon {
color: #37beff;
}
html[data-interface-skin="roundcube-elastic"] .message-row.unread .message-state {
background: #37beff;
border-color: #37beff;
}
html[data-interface-skin="roundcube-elastic"] .section-heading {
color: var(--muted);
letter-spacing: .04em;
}
html[data-interface-skin="roundcube-elastic"] .appearance-preset:focus-visible,
html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover {
border-color: #37beff;
}
/* Mobile drawer follows the active skin and uses Roundcube Elastic's back affordance. */
@media (max-width: 820px) {
.folder-drawer-header.mobile-only {
display: grid;
width: auto;
min-height: 42px;
grid-template-columns: 34px minmax(0, 1fr) 34px;
align-items: center;
gap: 6px;
margin: -10px -10px 8px;
padding: 4px 8px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.folder-drawer-header strong {
grid-column: 2;
justify-self: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-drawer-close {
grid-column: 1;
position: static;
width: 34px;
height: 34px;
background: transparent;
border: 0;
color: var(--text);
}
.folder-drawer-close svg {
width: 20px;
height: 20px;
stroke-width: 2;
}
.folder-pane .compose-button {
padding-right: var(--button-pad-x);
}
html[data-interface-skin="roundcube-classic"] .folder-drawer-header {
min-height: 32px;
background: linear-gradient(#f4f4f4, #cccccc);
border-color: #999999;
}
html[data-interface-skin="roundcube-classic"][data-theme="dark"] .folder-drawer-header {
background: linear-gradient(#3a3a3a, #292929);
border-color: #666666;
}
html[data-interface-skin="roundcube-elastic"] .folder-drawer-header {
min-height: 58px;
background: #f4f4f4;
}
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .folder-drawer-header {
background: #2f3a3f;
}
}
+362
View File
@@ -0,0 +1,362 @@
/* Fixed interface styles for RustBox 0.0.2.
These styles intentionally ignore RustBox custom palette variables. */
/* ---------- Roundcube Larry ---------- */
html[data-interface-skin="roundcube-larry"] {
--font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif;
--radius-control: 3px;
--radius-panel: 4px;
}
html[data-interface-skin="roundcube-larry"][data-theme="light"] {
color-scheme: light;
--bg: #e9e9e9;
--panel: #ffffff;
--panel-2: #f2f2f2;
--panel-3: #e6e6e6;
--sidebar: #f0f0f0;
--line: #c7c7c7;
--line-strong: #9a9a9a;
--text: #333333;
--muted: #666666;
--subtle: #858585;
--accent: #005d76;
--accent-strong: #00475b;
--accent-ink: #ffffff;
--danger: #b73838;
--warning: #8b6b15;
--success: #4f7b43;
--field: #ffffff;
--selected: #c7e3ef;
--hover: #e3f1f6;
--shadow: rgba(0, 0, 0, .22);
--folder-bg: #f0f0f0;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="roundcube-larry"][data-theme="dark"] {
color-scheme: dark;
--bg: #1d2225;
--panel: #252c30;
--panel-2: #30383d;
--panel-3: #384146;
--sidebar: #20272a;
--line: #465158;
--line-strong: #65737a;
--text: #edf2f4;
--muted: #b6c0c5;
--subtle: #8f9ba1;
--accent: #5aa9c2;
--accent-strong: #73bdd4;
--accent-ink: #07171c;
--danger: #ef7777;
--warning: #d6b85d;
--success: #82b578;
--field: #1d2225;
--selected: #314f5a;
--hover: #2d3a3f;
--shadow: rgba(0, 0, 0, .42);
--folder-bg: #20272a;
--message-bg: #252c30;
--reader-bg: #252c30;
}
html[data-interface-skin="roundcube-larry"] .appbar,
html[data-interface-skin="roundcube-larry"] .pane-toolbar,
html[data-interface-skin="roundcube-larry"] .reader-toolbar,
html[data-interface-skin="roundcube-larry"] .dialog-header {
background: #3c3c3c;
border-color: #202020;
color: #ffffff;
box-shadow: none;
}
html[data-interface-skin="roundcube-larry"][data-theme="dark"] .appbar,
html[data-interface-skin="roundcube-larry"][data-theme="dark"] .pane-toolbar,
html[data-interface-skin="roundcube-larry"][data-theme="dark"] .reader-toolbar,
html[data-interface-skin="roundcube-larry"][data-theme="dark"] .dialog-header {
background: #171b1d;
border-color: #3b4449;
}
html[data-interface-skin="roundcube-larry"] .folder-button.active,
html[data-interface-skin="roundcube-larry"] .message-row.active,
html[data-interface-skin="roundcube-larry"] .message-row.selected,
html[data-interface-skin="roundcube-larry"] .settings-tab.active {
background: var(--selected);
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
color: var(--text);
}
html[data-interface-skin="roundcube-larry"] .button.primary {
background: var(--accent);
border-color: var(--accent-strong);
color: #ffffff;
}
html[data-interface-skin="roundcube-larry"] input:focus,
html[data-interface-skin="roundcube-larry"] select:focus,
html[data-interface-skin="roundcube-larry"] textarea:focus,
html[data-interface-skin="roundcube-larry"] [contenteditable="true"]:focus,
html[data-interface-skin="roundcube-larry"] .search-field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
/* ---------- Clean ---------- */
html[data-interface-skin="clean"][data-theme="light"] {
color-scheme: light;
--bg: #f5f7fa;
--panel: #ffffff;
--panel-2: #f8fafc;
--panel-3: #eef2f7;
--sidebar: #f8fafc;
--line: #dfe5ec;
--line-strong: #c8d1dc;
--text: #17202a;
--muted: #667281;
--subtle: #8793a1;
--accent: #4d75e6;
--accent-strong: #365fcf;
--accent-ink: #ffffff;
--danger: #c94b50;
--warning: #98721a;
--success: #4c8553;
--field: #ffffff;
--selected: #e8eefc;
--hover: #f1f4fa;
--shadow: rgba(30, 46, 66, .12);
--folder-bg: #f8fafc;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="clean"][data-theme="dark"] {
color-scheme: dark;
--bg: #15181d;
--panel: #1b1f25;
--panel-2: #20262d;
--panel-3: #272e36;
--sidebar: #181c21;
--line: #303842;
--line-strong: #46515e;
--text: #eef2f6;
--muted: #a8b2bf;
--subtle: #818d9b;
--accent: #7d9bf2;
--accent-strong: #97aff6;
--accent-ink: #0c1429;
--danger: #ec777c;
--warning: #d7b35d;
--success: #84bd8a;
--field: #171b20;
--selected: #283653;
--hover: #222a34;
--shadow: rgba(0, 0, 0, .35);
--folder-bg: #181c21;
--message-bg: #1b1f25;
--reader-bg: #1b1f25;
}
html[data-interface-skin="clean"] .appbar,
html[data-interface-skin="clean"] .pane-toolbar,
html[data-interface-skin="clean"] .reader-toolbar,
html[data-interface-skin="clean"] .dialog-header {
box-shadow: none;
}
html[data-interface-skin="clean"] .folder-button.active,
html[data-interface-skin="clean"] .message-row.active,
html[data-interface-skin="clean"] .message-row.selected,
html[data-interface-skin="clean"] .settings-tab.active {
background: var(--selected);
color: var(--text);
}
html[data-interface-skin="clean"] .button.primary {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
/* ---------- Graphite ---------- */
html[data-interface-skin="graphite"][data-theme="light"] {
color-scheme: light;
--bg: #e4e6e8;
--panel: #f6f7f8;
--panel-2: #eceef0;
--panel-3: #e1e4e7;
--sidebar: #dde0e3;
--line: #c5c9cd;
--line-strong: #9ea4aa;
--text: #22272b;
--muted: #60686e;
--subtle: #7f878d;
--accent: #5e6972;
--accent-strong: #424b53;
--accent-ink: #ffffff;
--danger: #a94d4d;
--warning: #7c681f;
--success: #557556;
--field: #ffffff;
--selected: #d1d5d9;
--hover: #e5e8eb;
--shadow: rgba(24, 28, 31, .18);
--folder-bg: #dde0e3;
--message-bg: #f6f7f8;
--reader-bg: #f6f7f8;
}
html[data-interface-skin="graphite"][data-theme="dark"] {
color-scheme: dark;
--bg: #141619;
--panel: #1a1d21;
--panel-2: #20242a;
--panel-3: #292e34;
--sidebar: #171a1e;
--line: #343a41;
--line-strong: #4b535c;
--text: #e7eaed;
--muted: #a3aab1;
--subtle: #7d858d;
--accent: #98a1aa;
--accent-strong: #b2bac1;
--accent-ink: #121416;
--danger: #dc7777;
--warning: #c5aa5c;
--success: #81a983;
--field: #16191d;
--selected: #30363d;
--hover: #24292f;
--shadow: rgba(0, 0, 0, .42);
--folder-bg: #171a1e;
--message-bg: #1a1d21;
--reader-bg: #1a1d21;
}
html[data-interface-skin="graphite"] .appbar,
html[data-interface-skin="graphite"] .pane-toolbar,
html[data-interface-skin="graphite"] .reader-toolbar,
html[data-interface-skin="graphite"] .dialog-header {
background: var(--panel-2);
box-shadow: none;
}
html[data-interface-skin="graphite"] .folder-button.active,
html[data-interface-skin="graphite"] .message-row.active,
html[data-interface-skin="graphite"] .message-row.selected,
html[data-interface-skin="graphite"] .settings-tab.active {
background: var(--selected);
color: var(--text);
}
html[data-interface-skin="graphite"] .button.primary {
background: var(--accent-strong);
border-color: var(--accent-strong);
color: #ffffff;
}
/* ---------- High contrast ---------- */
html[data-interface-skin="high-contrast"] {
--radius-control: 2px;
--radius-panel: 2px;
}
html[data-interface-skin="high-contrast"][data-theme="light"] {
color-scheme: light;
--bg: #ffffff;
--panel: #ffffff;
--panel-2: #f2f2f2;
--panel-3: #e6e6e6;
--sidebar: #ffffff;
--line: #111111;
--line-strong: #000000;
--text: #000000;
--muted: #303030;
--subtle: #505050;
--accent: #004fc4;
--accent-strong: #003c96;
--accent-ink: #ffffff;
--danger: #b00020;
--warning: #6e5200;
--success: #126b24;
--field: #ffffff;
--selected: #ffe500;
--hover: #eaf2ff;
--shadow: rgba(0, 0, 0, .28);
--folder-bg: #ffffff;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="high-contrast"][data-theme="dark"] {
color-scheme: dark;
--bg: #000000;
--panel: #000000;
--panel-2: #111111;
--panel-3: #1c1c1c;
--sidebar: #000000;
--line: #d8d8d8;
--line-strong: #ffffff;
--text: #ffffff;
--muted: #e0e0e0;
--subtle: #bdbdbd;
--accent: #64c8ff;
--accent-strong: #9adcff;
--accent-ink: #000000;
--danger: #ff808f;
--warning: #ffe066;
--success: #7ee08c;
--field: #000000;
--selected: #675d00;
--hover: #18222a;
--shadow: rgba(255, 255, 255, .12);
--folder-bg: #000000;
--message-bg: #000000;
--reader-bg: #000000;
}
html[data-interface-skin="high-contrast"] .appbar,
html[data-interface-skin="high-contrast"] .pane-toolbar,
html[data-interface-skin="high-contrast"] .reader-toolbar,
html[data-interface-skin="high-contrast"] .dialog-header,
html[data-interface-skin="high-contrast"] input,
html[data-interface-skin="high-contrast"] select,
html[data-interface-skin="high-contrast"] textarea,
html[data-interface-skin="high-contrast"] .button,
html[data-interface-skin="high-contrast"] .message-row,
html[data-interface-skin="high-contrast"] .folder-button {
border-color: var(--line-strong);
}
html[data-interface-skin="high-contrast"] .folder-button.active,
html[data-interface-skin="high-contrast"] .message-row.active,
html[data-interface-skin="high-contrast"] .message-row.selected,
html[data-interface-skin="high-contrast"] .settings-tab.active {
background: var(--selected);
color: var(--text);
outline: 2px solid var(--line-strong);
outline-offset: -2px;
}
html[data-interface-skin="high-contrast"] .button.primary {
background: var(--accent);
border-color: var(--line-strong);
color: var(--accent-ink);
font-weight: 800;
}
@media (max-width: 820px) {
html[data-interface-skin="roundcube-larry"] .folder-drawer-header {
min-height: 44px;
background: #3c3c3c;
border-color: #202020;
color: #ffffff;
}
html[data-interface-skin="roundcube-larry"][data-theme="dark"] .folder-drawer-header {
background: #171b1d;
border-color: #3b4449;
}
}
+20 -1
View File
@@ -85,16 +85,35 @@ html[data-preview="bottom"] .reader-pane {
top: 0;
bottom: 0;
left: 0;
width: 250px;
width: min(84vw, 320px);
transform: translateX(-100%);
transition: transform .15s ease;
box-shadow: 12px 0 28px var(--shadow);
}
.folder-drawer-scrim {
display: block;
position: absolute;
z-index: 19;
inset: 0;
border: 0;
padding: 0;
background: rgba(0, 0, 0, .38);
opacity: 0;
pointer-events: none;
transition: opacity .15s ease;
}
.folder-drawer-scrim.open {
opacity: 1;
pointer-events: auto;
}
.folder-pane.open {
transform: translateX(0);
}
.message-pane {
height: 100%;
border-right: 0;
+2
View File
@@ -4,4 +4,6 @@
30-mail.css
40-compose.css
50-settings.css
55-roundcube-skins.css
56-interface-styles.css
60-responsive.css
+1284 -321
View File
File diff suppressed because it is too large. Load diff
+153
View File
@@ -0,0 +1,153 @@
/* RustBox UI module: 00-bootstrap.js */
(() => {
"use strict";
const root = document.documentElement;
const configuredBase = root.dataset.basePath || "/";
const BASE = configuredBase === "/" ? "" : configuredBase.replace(/\/$/, "");
const API = `${BASE}/api`;
const ICONS = `${BASE}/assets/icons.svg`;
const state = {
accounts: [],
accountId: localStorage.getItem("rustbox.activeAccount") || null,
folders: [],
folder: "INBOX",
collapsedFolders: new Set(),
page: 0,
perPage: 50,
total: 0,
messages: [],
selected: null,
selectedMessages: new Set(),
selectionMode: false,
searchContext: null,
searchAllResults: null,
currentDetail: null,
specialFolders: { sent: null, drafts: null, trash: null, junk: null, archive: null },
tags: [],
sieveScripts: [],
sieveInfo: null,
sieveEditingRuleId: null,
identities: [],
pgpKeys: [],
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
pgpRecipientStatus: [],
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 },
contacts: [],
trustedSenders: new Set(),
contactGroups: [],
contactPhotoUrl: null,
calendarCursor: new Date(),
calendarView: localStorage.getItem("rustbox.calendarView") || "month",
threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false",
calendarEvents: [],
savedSearches: [],
responseTemplates: [],
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
mailPrefs: { messages_per_page: 50, 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 },
currentFolderFlaggedOnTop: false,
markReadTimer: null,
ws: null,
wsRetryTimer: null,
realtimeRefreshTimer: null,
calendarReminderTimer: null,
composeDraftId: null,
composeAttachments: [],
composeDirty: false,
composeNeedsRemoteSync: false,
composeUploading: false,
composeReplyMeta: null,
composeInsertRange: null,
composeSelectedImage: null,
composeSignatureGeneration: 0,
identitySignatureRange: null,
inlineAttachmentIds: new Set(),
inlineImageCleanupTimer: null,
imageEditorBitmap: null,
imageEditorBlob: null,
draftSaveTimer: null,
prefs: {
theme: "system", interfaceSkin: "rustbox", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true,
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: "",
},
translations: {},
quotaWarningLevel: null,
networkOnline: navigator.onLine,
listStateTimer: null,
};
const $ = (selector, parent = document) => parent.querySelector(selector);
const $$ = (selector, parent = document) => [...parent.querySelectorAll(selector)];
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
function svgIcon(name) {
return `<svg aria-hidden="true"><use href="${ICONS}#icon-${name}"></use></svg>`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function stripRustBoxUiLeak(value) {
let text = String(value ?? "");
// Strip only RustBox's own embedded SVG sprite fragments if malformed MIME
// content captured adjacent UI markup. Legitimate SVG/code in mail remains.
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
text = text.replace(/&lt;svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?&lt;\/svg&gt;/gi, "");
return text;
}
function cleanPlainMessageText(value) {
return stripRustBoxUiLeak(value);
}
function sanitizeMessageHtml(html) {
const doc = new DOMParser().parseFromString(stripRustBoxUiLeak(html), "text/html");
doc.querySelectorAll("script,iframe,frame,frameset,object,embed,applet,form,input,button,select,textarea,base,meta,link,svg,math").forEach((node) => node.remove());
doc.querySelectorAll("*").forEach((node) => {
for (const attr of [...node.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
if (name.startsWith("on") || ["srcdoc", "formaction"].includes(name)) node.removeAttribute(attr.name);
if (["href", "src", "background", "action", "poster"].includes(name) && /^\s*javascript:/i.test(value)) node.removeAttribute(attr.name);
}
});
return doc.body.innerHTML;
}
function sanitizeAttachmentHtml(html) {
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href"));
doc.querySelectorAll("[src],[background],[poster]").forEach((node) => {
for (const name of ["src", "background", "poster"]) {
if (!node.hasAttribute(name)) continue;
const value = String(node.getAttribute(name) || "").trim();
if (!/^data:/i.test(value)) node.removeAttribute(name);
}
});
return doc.body.innerHTML;
}
function apiPath(path) {
return `${API}${path.startsWith("/") ? path : `/${path}`}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+333
View File
@@ -0,0 +1,333 @@
/* RustBox UI module: 10-core.js */
function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline);
const banner = $("#offline-banner");
if (banner) {
banner.classList.toggle("hidden", state.networkOnline);
banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns.");
}
const label = $("#connection-label");
if (!label) return;
if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline";
else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected";
}
function listStateStorageKey(folder = state.folder) {
return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`;
}
function lastFolderStorageKey() {
return `rustbox.lastFolder.${state.accountId || "anonymous"}`;
}
function activeSearchStorageKey() {
return `rustbox.activeSearch.${state.accountId || "anonymous"}`;
}
function saveActiveSearchContext() {
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey());
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext));
}
function readActiveSearchContext() {
try {
const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null");
return parsed && typeof parsed.params === "string" ? parsed : null;
} catch (_) {
return null;
}
}
function clearActiveSearchContext() {
state.searchContext = null;
state.searchAllResults = null;
sessionStorage.removeItem(activeSearchStorageKey());
}
function readMessageListState(folder = state.folder) {
try {
const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null");
if (!parsed || typeof parsed !== "object") return null;
return {
scrollTop: Math.max(0, Number(parsed.scrollTop) || 0),
page: Math.max(0, Number(parsed.page) || 0),
};
} catch (_) {
return null;
}
}
function saveMessageListState() {
if (!state.accountId) return;
const list = $("#message-list");
if (!list) return;
if (state.searchContext) {
state.searchContext.scrollTop = list.scrollTop;
saveActiveSearchContext();
return;
}
localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop: list.scrollTop, page: state.page }));
localStorage.setItem(lastFolderStorageKey(), state.folder);
}
function scheduleMessageListStateSave() {
clearTimeout(state.listStateTimer);
state.listStateTimer = setTimeout(saveMessageListState, 120);
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.accountId) headers.set("X-Active-Account", state.accountId);
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
const method = String(options.method || "GET").toUpperCase();
const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1;
let lastNetworkError = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetch(apiPath(path), {
credentials: "include",
...options,
headers,
});
if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
await sleep(250 * (attempt + 1));
continue;
}
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json().catch(() => ({}))
: await response.text().catch(() => "");
if (!response.ok) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`;
const error = new Error(message);
error.status = response.status;
throw error;
}
setConnectivityState(true);
return payload;
} catch (error) {
if (error?.status) throw error;
lastNetworkError = error;
setConnectivityState(false);
if (attempt + 1 < maxAttempts) {
await sleep(350 * (attempt + 1));
continue;
}
}
}
const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
error.cause = lastNetworkError;
error.network = true;
throw error;
}
function uploadRequest(path, formData, onProgress = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiPath(path), true);
xhr.withCredentials = true;
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
if (typeof onProgress === "function") {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
});
}
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
xhr.addEventListener("load", () => {
const contentType = xhr.getResponseHeader("content-type") || "";
let payload = xhr.responseText || "";
if (contentType.includes("application/json")) {
try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; }
}
if (xhr.status < 200 || xhr.status >= 300) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const error = new Error(message);
error.status = xhr.status;
reject(error);
return;
}
resolve(payload);
});
xhr.send(formData);
});
}
function toast(message, kind = "info", duration = null, action = null) {
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
const node = el("div", `toast toast-${normalized}`);
node.setAttribute("role", normalized === "error" ? "alert" : "status");
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info";
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon(iconName);
const text = el("span", "toast-message", message);
let actionButton = null;
if (action?.label && typeof action.onClick === "function") {
actionButton = el("button", "toast-action", action.label);
actionButton.type = "button";
}
const countdown = el("span", "toast-countdown");
countdown.setAttribute("aria-hidden", "true");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
const controls = el("span", "toast-controls");
if (actionButton) controls.append(actionButton);
controls.append(close);
node.append(icon, text, countdown, controls);
const openDialogs = $$("dialog[open]");
let stack = $("#toast-stack");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
}
}
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let remaining = lifetime;
let startedAt = 0;
let timeout = null;
let ticker = null;
let dismissed = false;
const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; };
const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; };
const dismiss = (immediate = false) => {
if (dismissed) return;
dismissed = true;
stopTimers();
if (immediate) return node.remove();
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
};
const pause = () => {
if (dismissed || !startedAt) return;
remaining = Math.max(0, remaining - (performance.now() - startedAt));
startedAt = 0;
stopTimers();
renderCountdown();
};
const resume = () => {
if (dismissed || remaining <= 0) return dismiss();
stopTimers();
startedAt = performance.now();
renderCountdown();
timeout = setTimeout(() => dismiss(), remaining);
ticker = setInterval(() => {
if (!startedAt) return;
const elapsed = performance.now() - startedAt;
const visibleRemaining = Math.max(0, remaining - elapsed);
countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`;
}, 200);
};
close.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
});
if (actionButton) {
actionButton.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
try { await action.onClick(); }
catch (error) { toast(error?.message || "Action failed", "error"); }
});
}
node.addEventListener("mouseenter", pause);
node.addEventListener("mouseleave", resume);
node.addEventListener("focusin", pause);
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
resume();
}
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => {
const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("form", "dialog-card app-prompt-card");
form.method = "dialog";
const header = el("header", "dialog-header");
header.append(el("strong", "", title));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", cancelLabel);
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "app-prompt-body");
if (message) body.append(el("p", "app-prompt-message", message));
let control = null;
if (Array.isArray(selectOptions)) {
control = document.createElement("select");
control.className = "app-prompt-control";
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
if (input?.value !== undefined) control.value = String(input.value);
body.append(control);
} else if (input) {
control = input.multiline ? document.createElement("textarea") : document.createElement("input");
control.className = "app-prompt-control";
if (!input.multiline) control.type = input.type || "text";
control.value = input.value ?? "";
if (input.placeholder) control.placeholder = input.placeholder;
if (input.required) control.required = true;
body.append(control);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const cancel = el("button", "button quiet", cancelLabel);
cancel.type = "button";
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
confirm.type = "submit";
actions.append(cancel, confirm);
form.append(header, body, actions);
dialog.append(form);
document.body.append(dialog);
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
if (dialog.open) dialog.close();
else dialog.remove();
};
close.addEventListener("click", () => finish(null));
cancel.addEventListener("click", () => finish(null));
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); });
dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true });
form.addEventListener("submit", (event) => {
event.preventDefault();
if (control && !control.reportValidity()) return;
finish(control ? control.value : true);
});
dialog.showModal();
setTimeout(() => (control || confirm).focus(), 20);
});
}
async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) {
return (await appDialog({ title, message, confirmLabel, danger })) === true;
}
function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) {
return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel });
}
function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) {
return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel });
}
function setBusy(button, busy) {
if (!button) return;
button.disabled = busy;
button.setAttribute("aria-busy", String(busy));
}
+558
View File
@@ -0,0 +1,558 @@
/* RustBox UI module: 20-preferences-auth.js */
function resolveTheme(theme) {
if (theme !== "system") return theme;
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
const preferenceMap = {
theme: "theme", interfaceSkin: "interface_skin", density: "density", preview: "preview_pane", scale: "ui_scale", language: "language",
colorPreset: "color_preset", accentColor: "accent_color", backgroundColor: "background_color", panelColor: "panel_color", textColor: "text_color", fontFamily: "font_family", fontSize: "font_size",
rowHeight: "row_height", folderPaneWidth: "folder_pane_width", messagePaneWidth: "message_pane_width",
borderRadius: "border_radius", contrast: "contrast", showAvatars: "show_avatars",
showAttachmentIcons: "show_attachment_icons", showFlags: "show_flags", reduceMotion: "reduce_motion", composeFormat: "compose_format",
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",
};
const preferenceTimers = new Map();
const interfaceStyleProfiles = {
"roundcube-classic": {
label: "Roundcube Classic",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 224, messagePaneWidth: 410, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cc3333", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
"roundcube-larry": {
label: "Roundcube Larry",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 48, folderPaneWidth: 230, messagePaneWidth: 420, borderRadius: 3, contrast: "normal", iconSize: 17, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#c7e3ef", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
"roundcube-elastic": {
label: "Roundcube Elastic",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 440, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 35, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#ebf8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
clean: {
label: "Clean",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#dce9ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
graphite: {
label: "Graphite",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 400, borderRadius: 2, contrast: "normal", iconSize: 17, toolbarDensity: "compact", sidebarItemHeight: 27, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#32373c", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
"high-contrast": {
label: "High contrast",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
};
function activeInterfaceStyle(prefs = state.prefs) {
return interfaceStyleProfiles[prefs.interfaceSkin] || null;
}
async function loadTranslations(language) {
const locale = ["en", "pl"].includes(language) ? language : "en";
try {
const response = await fetch(`${BASE}/assets/i18n/${locale}.json`, { credentials: "same-origin" });
state.translations = response.ok ? await response.json() : {};
} catch (_) { state.translations = {}; }
root.lang = locale;
$$('[data-i18n]').forEach((node) => { const key = node.dataset.i18n; if (state.translations[key]) node.textContent = state.translations[key]; });
$$('[data-i18n-placeholder]').forEach((node) => { const key = node.dataset.i18nPlaceholder; if (state.translations[key]) node.placeholder = state.translations[key]; });
$$('[data-i18n-title]').forEach((node) => { const key = node.dataset.i18nTitle; if (state.translations[key]) node.title = state.translations[key]; });
$$('[data-i18n-aria-label]').forEach((node) => { const key = node.dataset.i18nAriaLabel; if (state.translations[key]) node.setAttribute('aria-label', state.translations[key]); });
}
function tr(key, fallback = key) {
return state.translations[key] || fallback;
}
function applyPreferences() {
const p = state.prefs;
const fixedStyle = activeInterfaceStyle(p);
const visual = fixedStyle ? { ...p, ...fixedStyle.preferences } : p;
root.dataset.theme = resolveTheme(p.theme);
root.dataset.interfaceSkin = p.interfaceSkin || "rustbox";
root.dataset.density = visual.density;
root.dataset.preview = visual.preview;
root.dataset.scale = visual.scale;
root.dataset.colorPreset = p.colorPreset;
root.dataset.fontFamily = visual.fontFamily;
root.dataset.contrast = visual.contrast;
root.dataset.hideAvatars = String(!p.showAvatars);
root.dataset.hideAttachments = String(!p.showAttachmentIcons);
root.dataset.hideFlags = String(!p.showFlags);
root.dataset.reduceMotion = String(p.reduceMotion);
root.dataset.toolbarDensity = visual.toolbarDensity;
root.dataset.unreadBadge = visual.unreadBadgeStyle;
root.dataset.compactHeaders = String(visual.compactHeaders);
root.dataset.showBorders = String(visual.showBorders);
root.dataset.scrollbarDensity = visual.scrollbarDensity;
root.dataset.monochrome = String(visual.monochrome);
root.style.setProperty("--user-accent", p.accentColor);
root.style.setProperty("--user-bg", p.backgroundColor);
root.style.setProperty("--user-panel", p.panelColor);
root.style.setProperty("--user-text", p.textColor);
const scaleFactor = ({ small: 0.90, normal: 1, large: 1.12 })[visual.scale] || 1;
const densityFactor = visual.density === "comfortable" ? 1.13 : 0.93;
root.style.setProperty("--ui-scale-factor", String(scaleFactor));
root.style.setProperty("--density-factor", String(densityFactor));
root.style.setProperty("--ui-size", `${Math.round(Number(visual.fontSize || 13) * scaleFactor * 100) / 100}px`);
root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(visual.rowHeight || 58) * scaleFactor))}px`);
root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(visual.folderPaneWidth || 228) * scaleFactor))}px`);
root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(visual.messagePaneWidth || 420) * scaleFactor))}px`);
root.style.setProperty("--control-height", `${Math.max(26, Math.round(30 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--appbar-height", `${Math.max(38, Math.round(44 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--dialog-header-height", `${Math.max(38, Math.round(43 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--icon-size", `${Math.max(14, Math.round(Number(visual.iconSize || 18) * scaleFactor))}px`);
const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[visual.toolbarDensity] || 1;
root.style.setProperty("--toolbar-density-factor", String(toolbarFactor));
root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(visual.sidebarItemHeight || 29) * scaleFactor))}px`);
root.style.setProperty("--reader-max-width", Number(visual.readerMaxWidth) > 0 ? `${Number(visual.readerMaxWidth)}px` : "none");
root.style.setProperty("--composer-width", `${Math.max(640, Number(visual.composerWidth || 900))}px`);
root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily));
const lockedRoundcubeSkin = Boolean(fixedStyle);
if (lockedRoundcubeSkin) {
["--selected", "--folder-bg", "--message-bg", "--reader-bg"].forEach((name) => root.style.removeProperty(name));
} else {
root.style.setProperty("--selected", visual.selectionColor || "#28352e");
root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)");
root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)");
root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)");
}
root.style.setProperty("--unread-font-weight", String(Number(visual.unreadFontWeight || 730)));
root.style.setProperty("--button-pad-x", `${Math.max(8, Math.round(10 * scaleFactor))}px`);
root.style.setProperty("--button-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--field-pad-x", `${Math.max(7, Math.round(8 * scaleFactor))}px`);
root.style.setProperty("--field-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`);
root.style.setProperty("--radius-control", `${visual.borderRadius}px`);
root.style.setProperty("--radius-panel", `${Math.min(visual.borderRadius + 1, 13)}px`);
const values = {
"#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
"#pref-language": p.language, "#pref-color-preset": p.colorPreset, "#pref-accent": p.accentColor,
"#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor,
"#pref-font-family": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight,
"#pref-folder-width": visual.folderPaneWidth, "#pref-message-width": visual.messagePaneWidth, "#pref-radius": visual.borderRadius,
"#pref-contrast": visual.contrast, "#pref-date-format": p.dateFormat, "#pref-time-format": p.timeFormat,
"#compose-pref-format": p.composeFormat, "#compose-pref-autosave": String(p.composeAutosaveMs), "#compose-pref-priority": p.defaultPriority, "#compose-pref-font": p.composeFontFamily, "#compose-pref-reply-position": p.replyPosition, "#compose-pref-quote-style": p.quoteStyle, "#compose-pref-forward-mode": p.forwardMode,
"#pref-icon-size": visual.iconSize, "#pref-toolbar-density": visual.toolbarDensity, "#pref-sidebar-height": visual.sidebarItemHeight, "#pref-reader-width": String(visual.readerMaxWidth), "#pref-composer-width": visual.composerWidth,
"#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-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": p.accentColor, "#pref-background-value": p.backgroundColor, "#pref-panel-value": p.panelColor, "#pref-text-color-value": p.textColor, "#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; });
[["#pref-folder-bg", p.folderBackground || "#101310"], ["#pref-message-bg", p.messageBackground || "#181c19"], ["#pref-reader-bg", p.readerBackground || "#181c19"]].forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
const customizationLocked = Boolean(fixedStyle);
[
"#pref-color-preset", "#pref-accent", "#pref-background", "#pref-panel", "#pref-text-color",
"#pref-density", "#pref-preview", "#pref-scale", "#pref-font-family", "#pref-font-size",
"#pref-row-height", "#pref-folder-width", "#pref-message-width", "#pref-radius", "#pref-contrast",
"#pref-icon-size", "#pref-toolbar-density", "#pref-sidebar-height", "#pref-reader-width", "#pref-composer-width",
"#pref-selection-color", "#pref-unread-weight", "#pref-unread-badge", "#pref-scrollbar-density",
"#pref-compact-headers", "#pref-show-borders", "#pref-monochrome",
"#pref-folder-bg-enabled", "#pref-folder-bg", "#pref-message-bg-enabled", "#pref-message-bg", "#pref-reader-bg-enabled", "#pref-reader-bg",
].forEach((selector) => { const node = $(selector); if (node) node.disabled = customizationLocked; });
const skinNote = $("#pref-interface-skin-note");
if (skinNote) skinNote.textContent = fixedStyle
? `${fixedStyle.label} is a fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.`
: "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.";
}
function normalizeServerPreferences(data) {
if (!data) return;
state.prefs = {
...state.prefs,
theme: data.theme || "system", interfaceSkin: data.interface_skin || "rustbox", density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false,
scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage",
accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13,
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 || "",
};
}
async function loadPreferences() {
try { normalizeServerPreferences(await request("/settings/display", { method: "GET" })); }
catch (error) { console.warn("Could not load display preferences", error); }
applyPreferences();
await loadTranslations(state.prefs.language);
}
async function loadMailPreferences() {
try {
const prefs = await request("/settings/mailbox", { method: "GET" });
state.mailPrefs = { ...state.mailPrefs, ...prefs };
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
const values = {
"#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",
"#mail-pref-preferred-body": state.mailPrefs.preferred_body || "auto",
"#mail-pref-thread-mode": state.mailPrefs.thread_mode || "threaded",
"#mail-pref-sort": state.mailPrefs.default_sort || "date_desc",
};
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
const messageSort = $("#message-sort");
if (messageSort) messageSort.value = state.mailPrefs.default_sort || "date_desc";
const collapse = $("#mail-pref-collapse-quotes");
if (collapse) collapse.checked = state.mailPrefs.collapse_quotes !== false;
const snippets = $("#mail-pref-show-snippets");
if (snippets) snippets.checked = state.mailPrefs.show_snippets !== false;
const flaggedTop = $("#mail-pref-flagged-top");
if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top);
const syncKeywords = $("#mail-pref-sync-keywords");
if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords);
} catch (error) { console.warn("Could not load mailbox preferences", error); }
}
async function saveMailPreferences() {
const payload = {
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,
preferred_body: $("#mail-pref-preferred-body").value,
thread_mode: $("#mail-pref-thread-mode").value,
collapse_quotes: $("#mail-pref-collapse-quotes").checked,
show_snippets: $("#mail-pref-show-snippets").checked,
default_sort: $("#mail-pref-sort").value,
flagged_on_top: $("#mail-pref-flagged-top").checked,
sync_imap_keywords: $("#mail-pref-sync-keywords").checked,
};
try {
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
toast("Mailbox settings saved");
if (state.currentDetail) renderMessageDetail(state.currentDetail);
await loadMessages(true);
} catch (error) { toast(error.message, "error"); }
}
async function reconcileImapLabels() {
const button = $("#mail-pref-reconcile-labels");
const status = $("#mail-pref-reconcile-status");
if (!$("#mail-pref-sync-keywords")?.checked) {
toast("Enable IMAP keyword synchronization first", "warning");
return;
}
if (button) button.disabled = true;
if (status) status.textContent = "Reconciling…";
try {
const result = await request("/tags/reconcile", { method: "POST", body: "{}" });
const added = Number(result.labels_added || 0);
const removed = Number(result.labels_removed || 0);
const folders = Number(result.folders_checked || 0);
const skipped = Number(result.skipped_folders || 0);
if (status) status.textContent = `${folders} folder(s) checked · +${added} / −${removed}${skipped ? ` · ${skipped} skipped` : ""}`;
toast(`Labels reconciled: +${added}, −${removed}`, "success");
await loadTags();
await loadMessages(true);
} catch (error) {
if (status) status.textContent = error.message;
toast(error.message, "error");
} finally {
if (button) button.disabled = false;
}
}
function savePreference(key, value) {
state.prefs[key] = value;
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
applyPreferences();
if (key === "language") loadTranslations(value);
const previousTimer = preferenceTimers.get(key);
if (previousTimer) clearTimeout(previousTimer);
const timer = setTimeout(async () => {
preferenceTimers.delete(key);
const field = preferenceMap[key];
if (!field) return;
const payload = { [field]: state.prefs[key] };
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom";
try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); }
catch (error) { toast(error.message, "error"); }
}, 180);
preferenceTimers.set(key, timer);
}
async function resetAppearance() {
const payload = { theme: "system", interface_skin: "rustbox", 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: "" };
try { normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); }
catch (error) { toast(error.message, "error"); }
}
function formatDate(raw, long = false) {
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw || "";
const timeOptions = state.prefs.timeFormat === "24h" ? { hour: "2-digit", minute: "2-digit", hour12: false } : state.prefs.timeFormat === "12h" ? { hour: "numeric", minute: "2-digit", hour12: true } : { hour: "2-digit", minute: "2-digit" };
if (long) {
if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${date.toLocaleTimeString([], timeOptions)}`;
const locale = state.prefs.dateFormat === "eu" ? "en-GB" : state.prefs.dateFormat === "us" ? "en-US" : undefined;
return `${date.toLocaleDateString(locale)} ${date.toLocaleTimeString([], timeOptions)}`;
}
const now = new Date();
if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString([], timeOptions);
if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
if (state.prefs.dateFormat === "eu") return date.toLocaleDateString("en-GB");
if (state.prefs.dateFormat === "us") return date.toLocaleDateString("en-US");
if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: "short", day: "numeric" });
return date.toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" });
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes < 1) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
function initial(value) {
return (value || "?").trim().charAt(0).toUpperCase() || "?";
}
function showLogin() {
$("#boot").classList.add("hidden");
$("#app-view").classList.add("hidden");
$("#login-view").classList.remove("hidden");
}
function showApp() {
$("#boot").classList.add("hidden");
$("#login-view").classList.add("hidden");
$("#app-view").classList.remove("hidden");
}
async function loadAuthPolicy() {
try {
state.authPolicy = await request("/auth/policy", { method: "GET" });
} catch (_) {
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 };
}
const enabled = state.authPolicy.rememberMeEnabled !== false;
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } });
const days = Number(state.authPolicy.rememberMeDays || 30);
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label = $(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; });
}
async function loadAccounts() {
try {
const data = await request("/auth/accounts", { method: "GET" });
state.accounts = data.accounts || [];
if (!state.accounts.length) {
state.accountId = null;
localStorage.removeItem("rustbox.activeAccount");
state.folders = [];
updateUnreadAppBadge();
showLogin();
return false;
}
if (!state.accountId || !state.accounts.some((account) => account.id === state.accountId)) {
state.accountId = state.accounts[0].id;
localStorage.setItem("rustbox.activeAccount", state.accountId);
}
const account = state.accounts.find((item) => item.id === state.accountId) || state.accounts[0];
$("#account-email").textContent = account.email;
showApp();
return true;
} catch (error) {
console.error(error);
showLogin();
return false;
}
}
function renderAccountSettings() {
const list = $("#account-settings-list");
if (!list) return;
list.replaceChildren();
if (!state.accounts.length) {
list.append(el("div", "empty-list", "No active accounts."));
return;
}
for (const account of state.accounts) {
const row = el("div", `account-settings-row ${account.id === state.accountId ? "active" : ""}`);
const email = el("span", "account-email", account.email || "Account");
const use = el("button", "button quiet", account.id === state.accountId ? "Current" : "Switch");
use.type = "button";
use.disabled = account.id === state.accountId;
use.addEventListener("click", () => switchAccount(account.id));
const remove = el("button", "button quiet danger", "Remove");
remove.type = "button";
remove.addEventListener("click", () => removeBrowserAccount(account.id));
row.append(email, use, remove);
list.append(row);
}
}
async function loadActiveSessions() {
const list = $("#account-session-list");
if (!list) return;
list.replaceChildren(el("div", "empty-list", "Loading sessions…"));
try {
const data = await request("/auth/sessions", { method: "GET" });
const sessions = data.sessions || [];
list.replaceChildren();
if (!sessions.length) {
list.append(el("div", "empty-list", "No active sessions."));
return;
}
for (const session of sessions) {
const row = el("div", `account-settings-row ${session.current ? "active" : ""}`);
const details = el("div", "account-session-details");
const expiry = Number(session.expiresAt || 0);
const expiryText = expiry ? ` · expires ${new Date(expiry * 1000).toLocaleString()}` : "";
const rememberedText = session.remembered ? " · remembered" : "";
details.append(
el("strong", "account-email", session.current ? `${session.email} · current` : session.email),
el("span", "muted", `Created ${new Date(session.createdAt * 1000).toLocaleString()} · active ${new Date(session.lastActive * 1000).toLocaleString()}${expiryText}${rememberedText}`),
);
const client = el("span", "muted", session.clientName || "Unknown device");
if (session.userAgent) client.title = session.userAgent;
details.append(client);
const action = el("button", "button quiet danger", session.current ? "Current" : "Revoke");
action.type = "button";
action.disabled = Boolean(session.current);
action.addEventListener("click", () => revokeActiveSession(session.id));
row.append(details, action);
list.append(row);
}
} catch (error) {
list.replaceChildren(el("div", "empty-list", error.message));
}
}
async function revokeActiveSession(id) {
if (!await appConfirm("Revoke this RustBox session?", { title: "Revoke session", confirmLabel: "Revoke", danger: true })) return;
try {
await request(`/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
toast("Session revoked");
await loadActiveSessions();
} catch (error) { toast(error.message, "error"); }
}
async function revokeOtherSessions() {
if (!await appConfirm("Revoke every other active RustBox session for this account?", { title: "Revoke other sessions", confirmLabel: "Revoke sessions", danger: true })) return;
try {
const result = await request("/auth/sessions/revoke-others", { method: "POST", body: "{}" });
toast(`Revoked ${result.count || 0} session(s)`);
await loadActiveSessions();
} catch (error) { toast(error.message, "error"); }
}
async function reloadActiveAccount() {
setFolderDrawerOpen(false);
state.folder = "INBOX";
state.selected = null;
state.currentDetail = null;
state.selectedMessages.clear();
state.selectionMode = false;
closeReader();
await loadPreferences();
await loadMailPreferences();
await loadOpenPgp();
await loadIdentities();
await loadFolders(true);
await loadSpecialFolders();
await loadTags();
await loadNotificationPreferences();
await loadTrustedSenders();
startCalendarReminderWatcher();
renderAccountSettings();
}
async function switchAccount(accountId) {
if (!state.accounts.some((account) => account.id === accountId) || accountId === state.accountId) return;
state.accountId = accountId;
localStorage.setItem("rustbox.activeAccount", accountId);
const account = state.accounts.find((item) => item.id === accountId);
if (account) $("#account-email").textContent = account.email;
try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); }
}
async function removeBrowserAccount(accountId) {
const account = state.accounts.find((item) => item.id === accountId);
if (!account || !await appConfirm(`Remove ${account.email} from this browser session?`, { title: "Remove account", confirmLabel: "Remove", danger: true })) return;
try {
await request(`/auth/accounts/${encodeURIComponent(accountId)}`, { method: "DELETE" });
const removingCurrent = accountId === state.accountId;
await loadAccounts();
if (!state.accounts.length) return;
if (removingCurrent) await reloadActiveAccount();
else renderAccountSettings();
} catch (error) { toast(error.message, "error"); }
}
async function addBrowserAccount(event) {
event.preventDefault();
const errorBox = $("#add-account-error");
const submit = $("#add-account-form button[type=submit]");
errorBox.classList.add("hidden");
setBusy(submit, true);
try {
const data = await request("/auth/login", { method: "POST", body: JSON.stringify({ email: $("#add-account-email").value.trim(), password: $("#add-account-password").value, remember: $("#add-account-remember").checked }) });
state.accountId = data.account.id;
localStorage.setItem("rustbox.activeAccount", state.accountId);
$("#add-account-dialog").close();
$("#add-account-form").reset();
await loadAccounts();
await reloadActiveAccount();
disconnectRealtime();
connectRealtime();
toast("Account added");
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
} finally { setBusy(submit, false); }
}
async function login(event) {
event.preventDefault();
const submit = $("#login-form button[type=submit]");
const errorBox = $("#login-error");
errorBox.classList.add("hidden");
setBusy(submit, true);
try {
const data = await request("/auth/login", {
method: "POST",
body: JSON.stringify({
email: $("#login-email").value.trim(),
password: $("#login-password").value,
remember: $("#login-remember").checked,
}),
});
state.accountId = data.account.id;
localStorage.setItem("rustbox.activeAccount", state.accountId);
await loadAccounts();
await loadPreferences();
await loadMailPreferences();
await loadOpenPgp();
await loadIdentities();
await loadFolders(true);
await loadSpecialFolders();
await loadTags();
await loadNotificationPreferences();
await loadTrustedSenders();
startCalendarReminderWatcher();
connectRealtime();
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
} finally {
setBusy(submit, false);
}
}
async function logout() {
disconnectRealtime();
if (state.calendarReminderTimer) { clearInterval(state.calendarReminderTimer); state.calendarReminderTimer = null; }
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { }
state.accountId = null;
state.accounts = [];
localStorage.removeItem("rustbox.activeAccount");
showLogin();
}
+453
View File
@@ -0,0 +1,453 @@
/* RustBox UI module: 30-folders-tags.js */
function folderIconName(folder) {
const attrs = (folder.attributes || []).join(" ").toLowerCase();
const name = String(folder.name || "").toLowerCase();
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
});
}
function folderDepth(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return 0;
return Math.min(8, folder.name.split(delimiter).length - 1);
}
function folderLeafName(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
}
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function setFolderDrawerOpen(open) {
const shouldOpen = Boolean(open) && innerWidth <= 820;
$("#folder-pane")?.classList.toggle("open", shouldOpen);
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
}
function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
});
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
event.preventDefault();
button.classList.remove("drop-target");
try {
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
} catch (error) { toast(error.message || "Could not move message", "error"); }
});
button.addEventListener("click", async () => {
saveMessageListState();
state.folder = folder.name;
state.selected = null;
state.selectedMessages.clear();
clearActiveSearchContext();
state.currentDetail = null;
renderFolders();
closeReader();
setFolderDrawerOpen(false);
await loadMessages(true, { restoreState: true });
});
row.append(expander, button);
list.append(row);
}
renderMoveOptions();
}
function updateUnreadAppBadge() {
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
try {
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
} catch (_) { }
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true, { restoreState: restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function renderMoveOptions() {
const selects = [
["#move-select", "Move to…"],
["#copy-select", "Copy to…"],
["#bulk-move-select", "Move to…"],
["#bulk-copy-select", "Copy to…"],
];
for (const [selector, placeholder] of selects) {
const select = $(selector);
if (!select) continue;
select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
}
}
function messageKey(message) {
return `${message.folder || state.folder}\u0000${message.uid}`;
}
function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
}
const query = (params.get("q") || "").trim();
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
for (const token of tokens) {
const cleaned = token.replace(/^"|"$/g, "");
const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
}
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
function renderHighlightedText(node, value, terms) {
const text = String(value || "");
node.replaceChildren();
if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method: "GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method: "GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
state.selectedMessages.clear();
clearActiveSearchContext();
renderMessages();
$("#folder-title").textContent = `Label: ${tag.name}`;
$("#message-count").textContent = `${state.total} results`;
} catch (error) { toast(error.message, "error"); }
}
function openTagDialog(tag = null) {
$("#tag-id").value = tag?.id || "";
$("#tag-name").value = tag?.name || "";
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
$("#tag-delete").disabled = !tag;
$("#tag-dialog").showModal();
setTimeout(() => $("#tag-name").focus(), 20);
}
async function saveTag(event) {
event.preventDefault();
const id = $("#tag-id").value;
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
if (!payload.name) return;
try {
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#tag-dialog").close();
await loadTags();
toast("Label saved");
} catch (error) { toast(error.message, "error"); }
}
async function deleteTag() {
const id = $("#tag-id").value;
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function promptTag(label, tags = state.tags) {
if (!tags.length) { toast("Create a label first", "error"); return null; }
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
return tags.find((tag) => String(tag.id) === String(answer)) || null;
}
async function addTagToMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
}
async function removeTagFromMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
renderMessages();
await loadTags();
}
async function bulkAddTag(tagId) {
const items = selectedMessages();
const tag = state.tags.find((item) => item.id === tagId);
if (!items.length || !tag) return;
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
toast(`Label “${tag.name}” added`);
} catch (error) { toast(error.message, "error"); }
}
+719
View File
@@ -0,0 +1,719 @@
/* RustBox UI module: 40-messages-reader.js */
async function loadMessages(reset = false, options = {}) {
const list = $("#message-list");
const restoreState = Boolean(options.restoreState);
const savedState = restoreState ? readMessageListState(state.folder) : null;
const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null;
if (reset) {
state.page = 0;
state.messages = [];
state.selectedMessages.clear();
clearActiveSearchContext();
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
}
$("#folder-title").textContent = state.folder;
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];
let loaded = [];
let totalCount = 0;
let flaggedOnTop = state.currentFolderFlaggedOnTop;
let lastLoadedPage = state.page;
for (const page of pages) {
const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" });
totalCount = data.total_count || 0;
flaggedOnTop = Boolean(data.flagged_on_top);
loaded.push(...(data.messages || []));
lastLoadedPage = page;
if (loaded.length >= totalCount) break;
}
state.page = reset ? lastLoadedPage : state.page;
state.total = totalCount;
state.currentFolderFlaggedOnTop = flaggedOnTop;
state.messages = reset ? loaded : [...state.messages, ...loaded];
$("#connection-label").textContent = "Connected";
renderMessages();
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
if (Number.isFinite(desiredScroll) && desiredScroll > 0) {
requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); });
}
saveMessageListState();
} 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";
}
}
async function quickUpdateMessageFlag(message, flag, add, successMessage) {
const folder = message.folder || state.folder;
try {
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean));
add ? flags.add(flag) : flags.delete(flag);
message.flags = [...flags].join(" ");
if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) {
const detailFlags = new Set(state.currentDetail.flags || []);
add ? detailFlags.add(flag) : detailFlags.delete(flag);
state.currentDetail.flags = [...detailFlags];
renderMessageDetail(state.currentDetail);
}
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true);
else renderMessages();
toast(successMessage, "success");
} catch (error) { toast(error.message, "error"); }
}
function renderMessages() {
$("#folder-title").textContent = state.folder;
$("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`;
const list = $("#message-list");
const highlightTerms = activeSearchHighlightTerms();
list.classList.toggle("selection-mode", state.selectionMode);
list.replaceChildren();
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${state.searchContext ? "No matching messages." : "No messages in this folder."}</div>`;
for (const message of state.messages) {
const folder = message.folder || state.folder;
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || "");
const unread = !flags.includes("\\Seen");
const flagged = flags.includes("\\Flagged");
const checked = state.selectedMessages.has(messageKey(message));
const active = state.selected?.uid === message.uid && state.selected?.folder === folder;
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`);
row.tabIndex = 0;
row.setAttribute("role", "button");
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "message-select";
checkbox.checked = checked;
checkbox.disabled = !state.selectionMode;
checkbox.setAttribute("aria-label", "Select message");
checkbox.addEventListener("click", (event) => { event.stopPropagation(); });
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms);
renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
const icons = row.querySelector(".message-icons");
if (message.has_attachments) {
const attachmentIcon = el("span", "message-static-icon attachment-indicator");
attachmentIcon.innerHTML = svgIcon("paperclip");
attachmentIcon.title = "Has attachments";
icons.append(attachmentIcon);
}
const quick = el("span", "message-quick-actions");
const readButton = el("button", "message-quick-action");
readButton.type = "button";
readButton.title = unread ? "Mark read" : "Mark unread";
readButton.setAttribute("aria-label", readButton.title);
readButton.innerHTML = svgIcon("mail");
readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); });
const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`);
flagButton.type = "button";
flagButton.title = flagged ? "Unflag" : "Flag";
flagButton.setAttribute("aria-label", flagButton.title);
flagButton.innerHTML = svgIcon("star");
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
quick.append(readButton, flagButton);
icons.append(quick);
if (Array.isArray(message.tags) && message.tags.length) {
const wrap = el("span", "message-tags");
for (const tag of message.tags) {
const chip = el("span", "message-tag", tag.name);
chip.style.setProperty("--tag-color", tag.color || "#6b7280");
wrap.append(chip);
}
row.querySelector(".message-subject").after(wrap);
}
row.prepend(checkbox);
row.draggable = true;
row.addEventListener("dragstart", (event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid }));
row.classList.add("dragging");
});
row.addEventListener("dragend", () => row.classList.remove("dragging"));
row.addEventListener("click", (event) => {
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
openMessage(folder, message.uid);
});
row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } });
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
list.append(row);
}
$("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total);
renderBulkToolbar();
}
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);
}
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;
}
async function senderIsSavedContact(email) {
if (!email) return false;
try {
const data = await request(`/contacts?q=${encodeURIComponent(email)}&limit=10`, { method: "GET" });
const wanted = String(email).trim().toLowerCase();
return (data.contacts || []).some((contact) => {
const addresses = [contact.email, ...(contact.emails || [])].filter(Boolean).map((value) => String(value).trim().toLowerCase());
return addresses.includes(wanted);
});
} catch (_) {
return false;
}
}
async function openMessage(folder, uid) {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
renderMessages();
updateMessageNavigation();
$("#reader-empty").classList.add("hidden");
$("#reader-content").classList.remove("hidden");
$("#reader-subject").textContent = "Loading…";
$("#message-body").replaceChildren();
$("#reader").classList.add("mobile-open");
try {
const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" });
if (state.mailPrefs.remote_images === "contacts" && detail.from_address) {
detail._senderInContacts = await senderIsSavedContact(detail.from_address);
}
if (detail.from_address) detail._senderTrusted = state.trustedSenders.has(String(detail.from_address).trim().toLowerCase());
state.currentDetail = detail;
renderMessageDetail(detail);
renderMoveOptions();
scheduleMarkRead(detail);
} catch (error) {
$("#reader-subject").textContent = "Unable to open message";
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function scheduleMarkRead(detail) {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
if (!detail || (detail.flags || []).includes("\\Seen")) return;
const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0));
const run = async () => {
if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return;
try {
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) });
detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])];
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
if (item) {
const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean);
item.flags = [...new Set([...flags, "\\Seen"])].join(" ");
}
renderMessages();
} catch (_) { }
};
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
}
function renderThread(detail) {
const thread = detail.thread || [];
const section = $("#thread-section");
const list = $("#thread-list");
list.replaceChildren();
const enabled = state.mailPrefs.thread_mode !== "flat";
section.classList.toggle("hidden", !enabled || thread.length <= 1);
if (!enabled || thread.length <= 1) return;
$("#thread-title").textContent = `Thread (${thread.length})`;
list.classList.toggle("hidden", !state.threadExpanded);
$("#thread-toggle").classList.toggle("expanded", state.threadExpanded);
$("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded));
for (const item of thread) {
const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`);
button.type = "button";
button.innerHTML = `<span>${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
button.addEventListener("click", () => openMessage(item.folder, item.uid));
list.append(button);
}
}
function addressLabel(entry) {
if (!entry?.address) return "";
return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
}
function renderReaderRecipients(detail) {
const host = $("#reader-to");
host.replaceChildren();
const groups = [
["to", detail.to_addresses || []],
["cc", detail.cc_addresses || []],
];
let added = 0;
for (const [prefix, entries] of groups) {
for (const entry of entries) {
if (!entry?.address) continue;
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
added += 1;
}
}
if (!added) host.append(el("div", "recipient-line", "to me"));
}
function renderMessageDetail(detail) {
$("#reader-subject").textContent = detail.subject || "(no subject)";
$("#reader-from").textContent = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address;
$("#reader-from").title = detail.from_address ? "Sender actions" : "";
$("#reader-from").tabIndex = detail.from_address ? 0 : -1;
if (detail.from_address) $("#reader-from").setAttribute("role", "button");
else $("#reader-from").removeAttribute("role");
renderReaderRecipients(detail);
$("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address);
$("#reader-date").textContent = formatDate(detail.date, true);
$("#reader-avatar").textContent = initial(detail.from_name || detail.from_address);
$("#flag-btn").classList.toggle("active", (detail.flags || []).includes("\\Flagged"));
const spamButton = $("#spam-btn");
const inJunk = isJunkFolder(detail.folder);
spamButton.classList.toggle("active", inJunk);
spamButton.title = inJunk ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam");
spamButton.setAttribute("aria-label", spamButton.title);
renderThread(detail);
renderPgpReaderState(detail);
const attachments = $("#attachment-list");
attachments.replaceChildren();
attachments.classList.toggle("hidden", !(detail.attachments || []).length);
for (const attachment of detail.attachments || []) {
const item = el("div", "attachment-item");
const button = el("button", "attachment-button");
button.type = "button";
button.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
button.addEventListener("click", () => attachmentCanPreview(attachment) ? openAttachment(detail.folder, detail.uid, attachment) : downloadAttachment(detail.folder, detail.uid, attachment));
button.addEventListener("contextmenu", (event) => {
event.preventDefault();
const filename = String(attachment.filename || "").toLowerCase();
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
const actions = [
{ label: "Open", action: () => openAttachment(detail.folder, detail.uid, attachment) },
{ label: "Download", action: () => downloadAttachment(detail.folder, detail.uid, attachment) },
];
if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label: "Add to calendar", action: () => importCalendarAttachment(detail.folder, detail.uid, attachment) });
if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label: "Import contact", action: () => importVcardAttachment(detail.folder, detail.uid, attachment) });
if ((detail.attachments || []).length > 1) actions.push("separator", { label: "Download all as ZIP", action: () => downloadAllAttachmentsZip(detail) });
showContextMenu(event.clientX, event.clientY, actions);
});
item.append(button);
const filename = String(attachment.filename || "").toLowerCase();
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
if (contentType.includes("text/calendar") || filename.endsWith(".ics")) {
const add = el("button", "icon-button small");
add.innerHTML = svgIcon("calendar-days");
add.type = "button";
add.title = "Add to calendar";
add.setAttribute("aria-label", "Add calendar attachment");
add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment));
item.append(add);
}
if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) {
const add = el("button", "icon-button small");
add.innerHTML = svgIcon("user-round");
add.type = "button";
add.title = "Import contact";
add.setAttribute("aria-label", "Import vCard attachment");
add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment));
item.append(add);
}
attachments.append(item);
}
if ((detail.attachments || []).length > 1) {
const all = el("button", "attachment-download-all");
all.type = "button";
all.innerHTML = `${svgIcon("package")}<span>Download all as ZIP</span>`;
all.addEventListener("click", () => downloadAllAttachmentsZip(detail));
attachments.prepend(all);
}
renderMessageBody(detail);
}
function pgpMessageKind(detail) {
const headers = String(detail?.raw_headers || "").toLowerCase();
const text = String(detail?.text || "").toLowerCase();
const encrypted = (headers.includes("multipart/encrypted") && headers.includes("application/pgp-encrypted")) || text.includes("-----begin pgp message-----");
const signed = (headers.includes("multipart/signed") && headers.includes("application/pgp-signature")) || text.includes("-----begin pgp signed message-----");
return { pgp: encrypted || signed, encrypted, signed };
}
function renderPgpReaderState(detail) {
const bar = $("#pgp-reader-bar");
const status = $("#pgp-reader-status");
const title = $("#pgp-reader-title");
const find = $("#pgp-reader-discover");
const kind = pgpMessageKind(detail);
const configured = state.pgpKeys.length > 0;
bar.classList.toggle("hidden", !kind.pgp || !configured);
find.classList.add("hidden");
if (!kind.pgp || !configured) return;
const processed = detail?._pgpProcessed;
if (!processed) {
title.textContent = kind.encrypted ? "OpenPGP encrypted message" : "OpenPGP signed message";
status.textContent = kind.encrypted ? "Decrypt to read and verify the protected MIME payload." : "Signature has not been verified yet.";
$("#pgp-reader-process").textContent = kind.encrypted ? "Decrypt / verify" : "Verify signature";
return;
}
const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : "";
const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : "";
const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : "";
if (processed.encrypted && processed.verified) { title.textContent = "OpenPGP decrypted + verified"; status.textContent = `Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; }
else if (processed.encrypted) { title.textContent = "OpenPGP decrypted"; status.textContent = processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; }
else if (processed.verified) { title.textContent = "OpenPGP signature verified"; status.textContent = `Valid signature${signer}${signerIdentity}${trust}`; }
else {
title.textContent = "OpenPGP signature not verified";
status.textContent = "No matching public key verified this signature.";
if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden");
}
$("#pgp-reader-process").textContent = "Reprocess";
}
async function processCurrentPgpMessage() {
const detail = state.currentDetail;
if (!detail) return;
const kind = pgpMessageKind(detail);
let passphrase = "";
if (kind.encrypted) {
const value = await appPrompt("Decrypt OpenPGP message", { message: "Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.", type: "password", confirmLabel: "Decrypt" });
if (value === null) return;
passphrase = value;
}
const button = $("#pgp-reader-process"); setBusy(button, true);
try {
const result = await request(`/pgp/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/process`, { method: "POST", body: JSON.stringify({ passphrase }) });
detail._pgpProcessed = result;
renderPgpReaderState(detail);
if (result.text != null || result.html != null) renderMessageBody({ ...detail, text: result.text, html: result.html });
renderPgpProcessedAttachments(result.attachments || []);
if (result.verified && result.signerTrust === "untrusted") toast("OpenPGP signature is cryptographically valid, but this key is marked untrusted", "warning");
else if (result.verified) toast("OpenPGP signature verified", "success");
else if (result.decrypted) toast("OpenPGP message decrypted", "success");
else if (result.signed) toast("OpenPGP signature could not be verified", "warning");
} catch (error) { toast(error.message, "error"); }
finally { setBusy(button, false); }
}
function renderPgpProcessedAttachments(items) {
if (!items.length) return;
const list = $("#attachment-list");
list.replaceChildren(); list.classList.remove("hidden");
for (const attachment of items) {
const item = el("div", "attachment-item");
const button = el("button", "attachment-button"); button.type = "button";
button.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
button.addEventListener("click", () => {
try {
const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: attachment.contentType || "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = attachment.filename || "attachment"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 30000);
} catch (_) { toast("Unable to decode decrypted attachment", "error"); }
});
item.append(button); list.append(item);
}
}
async function discoverCurrentPgpSigner() {
const email = state.currentDetail?.from_address;
if (!email) return;
const key = await discoverPgpEmail(email, false);
if (key) await processCurrentPgpMessage();
}
function renderMessageBody(detail, forceRemote = false) {
const body = $("#message-body");
const remoteBar = $("#remote-image-bar");
body.replaceChildren();
remoteBar.classList.add("hidden");
const preferredBody = state.mailPrefs.preferred_body || "auto";
if (preferredBody === "text" || !detail?.html) {
body.append(el("pre", "", cleanPlainMessageText(detail?.text || "(empty message)")));
return;
}
const hasRemote = /(?:src|background)\s*=\s*["']https?:\/\//i.test(detail.html) || /url\(\s*["']?https?:\/\//i.test(detail.html);
const policy = state.mailPrefs.remote_images || "ask";
const allowRemote = forceRemote || policy === "always" || (policy !== "never" && detail?._senderTrusted === true) || (policy === "contacts" && detail?._senderInContacts === true);
if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden");
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
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 = sanitizeMessageHtml(detail.html);
iframe.srcdoc = `<!doctype html><html><head>${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>`;
body.append(iframe);
}
async function downloadAttachment(folder, uid, attachment) {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers });
if (!response.ok) throw new Error(`Download failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = attachment.filename || "attachment";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
toast(`Downloading ${attachment.filename || "attachment"}`, "info");
} catch (error) { toast(error.message, "error"); }
}
async function fetchAttachmentBlob(folder, uid, attachment) {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers });
if (!response.ok) throw new Error(`Attachment request failed (${response.status})`);
return response.blob();
}
function attachmentMime(attachment, blob = null) {
return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
}
function attachmentCanPreview(attachment) {
const mime = attachmentMime(attachment);
const filename = String(attachment?.filename || "").toLowerCase();
return mime.startsWith("image/")
|| mime === "application/pdf"
|| mime.startsWith("text/")
|| mime.startsWith("video/")
|| /\.(pdf|png|jpe?g|gif|webp|txt|log|md|csv|html?|xhtml|mp4|webm|ogv|mov)$/i.test(filename);
}
async function openAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
const mime = attachmentMime(attachment, blob);
const filename = String(attachment?.filename || "").toLowerCase();
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename);
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename);
const safe = attachmentCanPreview({ ...attachment, content_type: mime });
if (!safe) return toast("Preview is not available for this attachment type", "warning");
if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning");
const dialog = el("dialog", "dialog attachment-preview-dialog");
const card = el("div", "dialog-card attachment-preview-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", attachment.filename || "Attachment preview"));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", "Close preview");
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "attachment-preview-body");
let objectUrl = null;
if (isImage) {
objectUrl = URL.createObjectURL(blob);
const image = document.createElement("img");
image.src = objectUrl;
image.alt = attachment.filename || "Attachment image";
body.append(image);
} else if (mime === "application/pdf" || filename.endsWith(".pdf")) {
objectUrl = URL.createObjectURL(blob);
const frame = document.createElement("iframe");
frame.src = objectUrl;
frame.title = attachment.filename || "PDF preview";
frame.referrerPolicy = "no-referrer";
body.append(frame);
} else if (isVideo) {
objectUrl = URL.createObjectURL(blob);
const video = document.createElement("video");
video.src = objectUrl;
video.controls = true;
video.playsInline = true;
video.preload = "metadata";
body.append(video);
} else if (isHtml) {
if (blob.size > 5 * 1024 * 1024) return toast("HTML attachment is too large for inline preview", "warning");
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "");
frame.referrerPolicy = "no-referrer";
const safeHtml = sanitizeAttachmentHtml(await blob.text());
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
body.append(frame);
} else {
if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning");
const pre = document.createElement("pre");
pre.textContent = await blob.text();
body.append(pre);
}
const footer = el("footer", "dialog-actions");
const download = el("button", "button quiet", "Download");
download.type = "button";
download.addEventListener("click", () => downloadAttachment(folder, uid, attachment));
const done = el("button", "button primary", "Close");
done.type = "button";
const dismiss = () => { if (dialog.open) dialog.close(); };
close.addEventListener("click", dismiss);
done.addEventListener("click", dismiss);
footer.append(download, done);
card.append(header, body, footer);
dialog.append(card);
document.body.append(dialog);
dialog.addEventListener("close", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); dialog.remove(); }, { once: true });
dialog.showModal();
} catch (error) { toast(error.message, "error"); }
}
function safeZipFilename(value, index) {
const cleaned = String(value || `attachment-${index + 1}`)
.replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_")
.replace(/^\.+$/, "attachment")
.slice(0, 180);
return cleaned || `attachment-${index + 1}`;
}
let crcTable = null;
function crc32(bytes) {
if (!crcTable) {
crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[n] = c >>> 0;
}
}
let crc = 0xffffffff;
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function zipDosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear());
const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((Math.floor(date.getSeconds() / 2)) & 31);
const dosDate = (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31);
return { dosTime, dosDate };
}
function zipRecord(size, fill) {
const bytes = new Uint8Array(size);
const view = new DataView(bytes.buffer);
fill(view);
return bytes;
}
function concatBytes(parts) {
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) { out.set(part, offset); offset += part.byteLength; }
return out;
}
async function downloadAllAttachmentsZip(detail = state.currentDetail) {
const attachments = detail?.attachments || [];
if (!attachments.length) return toast("This message has no attachments", "warning");
if (attachments.length > 100) return toast("Too many attachments for a browser ZIP", "warning");
try {
toast(`Preparing ZIP with ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}…`, "info");
const encoder = new TextEncoder();
const localParts = [];
const centralParts = [];
let localOffset = 0;
let totalPayload = 0;
for (let index = 0; index < attachments.length; index += 1) {
const attachment = attachments[index];
const blob = await fetchAttachmentBlob(detail.folder, detail.uid, attachment);
totalPayload += blob.size;
if (totalPayload > 120 * 1024 * 1024) throw new Error("Attachments exceed the 120 MB browser ZIP limit");
const data = new Uint8Array(await blob.arrayBuffer());
const name = encoder.encode(safeZipFilename(attachment.filename, index));
const checksum = crc32(data);
const { dosTime, dosDate } = zipDosDateTime();
const local = zipRecord(30, (view) => {
view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true);
view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true);
view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); view.setUint16(26, name.length, true); view.setUint16(28, 0, true);
});
const central = zipRecord(46, (view) => {
view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true);
view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, checksum, true);
view.setUint32(20, data.length, true); view.setUint32(24, data.length, true); view.setUint16(28, name.length, true); view.setUint16(30, 0, true);
view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, localOffset, true);
});
localParts.push(local, name, data);
centralParts.push(central, name);
localOffset += local.byteLength + name.byteLength + data.byteLength;
}
const centralDirectory = concatBytes(centralParts);
const eocd = zipRecord(22, (view) => {
view.setUint32(0, 0x06054b50, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true);
view.setUint16(8, attachments.length, true); view.setUint16(10, attachments.length, true);
view.setUint32(12, centralDirectory.byteLength, true); view.setUint32(16, localOffset, true); view.setUint16(20, 0, true);
});
const zip = new Blob([...localParts, centralDirectory, eocd], { type: "application/zip" });
const url = URL.createObjectURL(zip);
const link = document.createElement("a");
const stem = String(detail.subject || "attachments").replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_").slice(0, 80) || "attachments";
link.href = url; link.download = `${stem}-attachments.zip`; document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
toast(`Downloaded ${attachments.length} attachment${attachments.length === 1 ? "" : "s"} as ZIP`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function importCalendarAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
if (blob.size > 10 * 1024 * 1024) throw new Error("ICS attachment is too large");
const text = await blob.text();
const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" });
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data?.error?.message || `Calendar import failed (${response.status})`);
toast(`Calendar: ${data.created || 0} event(s) imported`);
} catch (error) { toast(error.message, "error"); }
}
async function importVcardAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
if (blob.size > 10 * 1024 * 1024) throw new Error("vCard attachment is too large");
const form = new FormData();
form.append("file", blob, attachment.filename || "contact.vcf");
const result = await request("/contacts/import", { method: "POST", body: form });
toast(`Contacts: ${result.created || 0} created, ${result.updated || 0} updated`);
} catch (error) { toast(error.message, "error"); }
}
function closeReader() {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
$("#reader").classList.remove("mobile-open");
$("#reader-empty").classList.remove("hidden");
$("#reader-content").classList.add("hidden");
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+294
View File
@@ -0,0 +1,294 @@
/* RustBox UI module: 70-contacts.js */
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
async function loadContactGroups() {
try {
const data = await request("/contact-groups", { method: "GET" });
state.contactGroups = data.groups || [];
renderContactGroupFilter();
renderContactGroupMembership($("#contact-id").value || null, []);
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupFilter() {
const select = $("#contact-group-filter");
const current = select.value;
select.replaceChildren(new Option("All contacts", ""));
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
if ([...select.options].some((option) => option.value === current)) select.value = current;
const hasGroup = Boolean(select.value);
$("#contact-group-rename").disabled = !hasGroup;
$("#contact-group-delete").disabled = !hasGroup;
}
async function downloadContactExport() {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
if (!response.ok) throw new Error(`Export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "contacts.vcf";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Calendar exported", "success");
} catch (error) { toast(error.message, "error"); }
}
async function importContactVcard(files) {
const file = files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
const form = new FormData();
form.append("file", file, file.name || "contacts.vcf");
try {
const data = await request("/contacts/import", { method: "POST", body: form });
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
await loadContacts($("#contact-search").value.trim());
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-import-file").value = ""; }
}
async function showContactDuplicates() {
try {
const data = await request("/contacts/duplicates", { method: "GET" });
const groups = data.duplicates || [];
const list = $("#contact-duplicate-list");
list.replaceChildren();
if (!groups.length) {
list.append(el("div", "empty-list", "No duplicate contacts found"));
}
for (const group of groups) {
const card = el("section", "duplicate-contact-group");
card.append(el("strong", "", group.email));
const rows = el("div", "duplicate-contact-options");
for (const contact of group.contacts || []) {
const row = el("div", "duplicate-contact-row");
const summary = el("div", "duplicate-contact-summary");
summary.append(el("strong", "", contact.name || contact.email));
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
const keep = el("button", "button quiet", "Keep this");
keep.type = "button";
keep.addEventListener("click", async () => {
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
if (!sources.length) return;
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
try {
let target = contact;
for (const source of sources) {
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
}
toast("Contacts merged");
await loadContacts($("#contact-search").value.trim());
await showContactDuplicates();
editContact(target);
} catch (error) { toast(error.message, "error"); }
});
row.append(summary, keep); rows.append(row);
}
card.append(rows); list.append(card);
}
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
} catch (error) { toast(error.message, "error"); }
}
async function loadContacts(query = "") {
try {
const groupId = $("#contact-group-filter")?.value || "";
let contacts;
if (groupId) {
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
contacts = data.members || [];
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
} else {
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
contacts = data.contacts || [];
}
state.contacts = contacts;
renderContacts();
} catch (error) { toast(error.message, "error"); }
}
async function createContactGroup() {
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
if (!name?.trim()) return;
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
catch (error) { toast(error.message, "error"); }
}
async function renameContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group) return;
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
if (!name?.trim() || name.trim() === group.name) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function loadContactMembership(contactId) {
if (!contactId) return renderContactGroupMembership(null, []);
try {
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupMembership(contactId, selectedIds) {
const list = $("#contact-group-membership");
if (!list) return;
const selected = new Set(selectedIds || []);
list.replaceChildren();
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
for (const group of state.contactGroups) {
const label = el("label", "checkbox-row");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
input.addEventListener("change", async () => {
try {
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
await loadContactGroups();
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
});
label.append(input, el("span", "", group.name)); list.append(label);
}
}
function renderContacts() {
const list = $("#contact-list");
list.replaceChildren();
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
for (const contact of state.contacts) {
const button = el("button", "contact-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
button.addEventListener("click", () => editContact(contact));
list.append(button);
}
}
function editContact(contact = null) {
$("#contact-id").value = contact?.id || "";
$("#contact-name").value = contact?.name || "";
$("#contact-email").value = contact?.email || "";
$("#contact-emails").value = (contact?.emails || []).join("\n");
$("#contact-company").value = contact?.company || "";
$("#contact-phone").value = contact?.phone || "";
$("#contact-phones").value = (contact?.phones || []).join("\n");
$("#contact-job-title").value = contact?.job_title || "";
$("#contact-website").value = contact?.website || "";
$("#contact-birthday").value = contact?.birthday || "";
$("#contact-address").value = contact?.address || "";
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
$("#contact-notes").value = contact?.notes || "";
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
$("#contact-delete").disabled = !contact;
$("#contact-compose").disabled = !contact?.email;
$("#contact-photo-upload").disabled = !contact?.id;
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
renderContactPhoto(contact);
loadContactMembership(contact?.id || null);
}
function clearContactPhotoObjectUrl() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
state.contactPhotoUrl = null;
}
async function renderContactPhoto(contact) {
clearContactPhotoObjectUrl();
const image = $("#contact-photo-preview");
const placeholder = $("#contact-photo-placeholder");
image.classList.add("hidden");
image.removeAttribute("src");
placeholder.classList.remove("hidden");
if (!contact?.id || !contact?.has_photo) return;
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
if (!response.ok) return;
const blob = await response.blob();
state.contactPhotoUrl = URL.createObjectURL(blob);
image.src = state.contactPhotoUrl;
image.classList.remove("hidden");
placeholder.classList.add("hidden");
} catch (_) { }
}
async function uploadContactPhoto(files) {
const id = $("#contact-id").value;
const file = files?.[0];
if (!id || !file) return;
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
const form = new FormData();
form.append("file", file, file.name || "photo");
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = true;
$("#contact-photo-delete").disabled = false;
await renderContactPhoto({ id, has_photo: true });
toast("Contact photo saved");
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-photo-file").value = ""; }
}
async function deleteContactPhoto() {
const id = $("#contact-id").value;
if (!id) return;
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = false;
$("#contact-photo-delete").disabled = true;
await renderContactPhoto({ id, has_photo: false });
toast("Contact photo removed");
} catch (error) { toast(error.message, "error"); }
}
async function saveContact(event) {
event.preventDefault();
const payload = {
id: $("#contact-id").value || null,
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
is_favorite: $("#contact-favorite").checked, source: "manual",
};
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContact() {
const id = $("#contact-id").value;
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
catch (error) { toast(error.message, "error"); }
}
function composeToContact() {
const email = $("#contact-email").value.trim();
if (!email) return;
$("#contacts-dialog").close();
openCompose("new");
$("#compose-to").value = email;
}
+269
View File
@@ -0,0 +1,269 @@
/* RustBox UI module: 71-calendar.js */
function startOfCalendarDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addCalendarDays(date, count) {
const next = new Date(date);
next.setDate(next.getDate() + count);
return next;
}
function startOfCalendarWeek(date) {
const start = startOfCalendarDay(date);
const day = start.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
return addCalendarDays(start, mondayOffset);
}
function calendarRange(date, view = state.calendarView) {
if (view === "week") {
const start = startOfCalendarWeek(date);
return { start, end: addCalendarDays(start, 7) };
}
if (view === "day") {
const start = startOfCalendarDay(date);
return { start, end: addCalendarDays(start, 1) };
}
if (view === "agenda") {
const start = startOfCalendarDay(date);
return { start, end: addCalendarDays(start, 30) };
}
const first = new Date(date.getFullYear(), date.getMonth(), 1);
const start = startOfCalendarWeek(first);
return { start, end: addCalendarDays(start, 42) };
}
function calendarDayKey(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function calendarRangeLabel() {
const cursor = state.calendarCursor;
if (state.calendarView === "month") return cursor.toLocaleDateString([], { month: "long", year: "numeric" });
if (state.calendarView === "day") return cursor.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const { start, end } = calendarRange(cursor);
const last = addCalendarDays(end, -1);
if (state.calendarView === "agenda") return `${start.toLocaleDateString()} – ${last.toLocaleDateString()}`;
return `${start.toLocaleDateString([], { month: "short", day: "numeric" })} – ${last.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" })}`;
}
function shiftCalendarCursor(direction) {
const cursor = new Date(state.calendarCursor);
if (state.calendarView === "month") cursor.setMonth(cursor.getMonth() + direction);
else if (state.calendarView === "week") cursor.setDate(cursor.getDate() + (7 * direction));
else if (state.calendarView === "agenda") cursor.setDate(cursor.getDate() + (30 * direction));
else cursor.setDate(cursor.getDate() + direction);
state.calendarCursor = cursor;
}
function toLocalInput(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
async function importCalendarIcs(files) {
const file = files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("ICS file is too large", "error");
try {
const text = await file.text();
const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" });
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data?.error?.message || `ICS import failed (${response.status})`);
toast(`ICS import: ${data.created || 0} created, ${data.skipped || 0} skipped`);
await loadCalendar();
} catch (error) { toast(error.message, "error"); }
finally { $("#calendar-import-file").value = ""; }
}
async function exportCalendarIcs() {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/calendar/events/export-ics"), { credentials: "include", headers });
if (!response.ok) throw new Error(`Calendar export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "rustbox-calendar.ics";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) { toast(error.message, "error"); }
}
async function openCalendar() {
$("#calendar-dialog").showModal();
await loadCalendar();
}
async function loadCalendar() {
const { start, end } = calendarRange(state.calendarCursor);
$("#calendar-view").value = state.calendarView;
$("#calendar-range").textContent = calendarRangeLabel();
try {
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
state.calendarEvents = data.events || [];
renderCalendarEvents();
} catch (error) { toast(error.message, "error"); }
}
function createCalendarEventButton(event, compact = false) {
const button = el("button", compact ? "calendar-event-chip" : "calendar-event");
button.type = "button";
const color = /^#[0-9a-f]{6}$/i.test(String(event.color || "")) ? event.color : "var(--accent)";
button.style.setProperty("--event-color", color);
if (compact) {
const time = event.all_day ? "All day" : new Date(event.start_time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
button.innerHTML = `<span>${escapeHtml(time)}</span><strong>${escapeHtml(event.title)}</strong>`;
} else {
button.innerHTML = `<time>${escapeHtml(formatDate(event.start_time, true))}</time><div><strong>${escapeHtml(event.title)}</strong><small>${escapeHtml(event.location || "")}</small></div><span>${escapeHtml(event.status || "")}</span>`;
}
button.addEventListener("click", () => editCalendarEvent(event));
return button;
}
function renderCalendarMonthOrWeek(view) {
const host = $("#calendar-events");
const { start, end } = calendarRange(state.calendarCursor, view);
const days = view === "week" ? 7 : 42;
const grid = el("div", `calendar-grid calendar-${view}-grid`);
for (const label of ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) grid.append(el("div", "calendar-weekday", label));
const byDay = new Map();
for (const event of state.calendarEvents) {
const key = calendarDayKey(event.start_time);
if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key).push(event);
}
for (let i = 0; i < days; i++) {
const date = addCalendarDays(start, i);
const cell = el("section", "calendar-day-cell");
if (view === "month" && date.getMonth() !== state.calendarCursor.getMonth()) cell.classList.add("outside-month");
if (calendarDayKey(date) === calendarDayKey(new Date())) cell.classList.add("today");
const heading = el("button", "calendar-day-number", String(date.getDate()));
heading.type = "button";
heading.title = "Create event on this day";
heading.addEventListener("click", () => {
const startDate = new Date(date); startDate.setHours(9, 0, 0, 0);
const endDate = new Date(startDate.getTime() + 3600000);
editCalendarEvent(null);
$("#calendar-start").value = toLocalInput(startDate);
$("#calendar-end").value = toLocalInput(endDate);
});
cell.append(heading);
const dayEvents = (byDay.get(calendarDayKey(date)) || []).sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
for (const event of dayEvents.slice(0, view === "month" ? 4 : 12)) cell.append(createCalendarEventButton(event, true));
if (view === "month" && dayEvents.length > 4) cell.append(el("span", "calendar-more", `+${dayEvents.length - 4} more`));
grid.append(cell);
}
host.append(grid);
}
function renderCalendarAgenda() {
const host = $("#calendar-events");
const groups = new Map();
for (const event of state.calendarEvents) {
const key = calendarDayKey(event.start_time);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(event);
}
if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; }
for (const [key, events] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const date = new Date(`${key}T12:00:00`);
const section = el("section", "calendar-agenda-day");
section.append(el("h4", "", date.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric", year: "numeric" })));
for (const event of events.sort((a, b) => new Date(a.start_time) - new Date(b.start_time))) section.append(createCalendarEventButton(event));
host.append(section);
}
}
function renderCalendarEvents() {
const list = $("#calendar-events");
list.replaceChildren();
if (state.calendarView === "month" || state.calendarView === "week") {
renderCalendarMonthOrWeek(state.calendarView);
return;
}
if (!state.calendarEvents.length) {
list.append(el("div", "empty-list", state.calendarView === "day" ? "No events today" : "No events in this range"));
return;
}
renderCalendarAgenda();
}
function updateCalendarRecurrenceUi() {
$("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom");
}
function editCalendarEvent(event = null) {
$("#calendar-form").classList.remove("hidden");
$("#calendar-event-id").value = event?.id || "";
$("#calendar-title").value = event?.title || "";
$("#calendar-start").value = event ? toLocalInput(event.start_time) : toLocalInput(new Date());
$("#calendar-end").value = event ? toLocalInput(event.end_time) : toLocalInput(new Date(Date.now() + 3600000));
$("#calendar-all-day").checked = Boolean(event?.all_day);
const rule = event?.recurrence_rule || "";
const commonRules = new Set(["", "FREQ=DAILY", "FREQ=WEEKLY", "FREQ=MONTHLY", "FREQ=YEARLY"]);
$("#calendar-recurrence").value = commonRules.has(rule) ? rule : "custom";
$("#calendar-rrule").value = commonRules.has(rule) ? "" : rule;
updateCalendarRecurrenceUi();
let attendees = [];
try { attendees = JSON.parse(event?.attendees || "[]"); } catch (_) { }
$("#calendar-attendees").value = Array.isArray(attendees) ? attendees.join(", ") : "";
$("#calendar-organizer").value = event?.organizer || state.accounts.find((item) => item.id === state.accountId)?.email || "";
$("#calendar-status").value = String(event?.status || "confirmed").toLowerCase();
$("#calendar-location").value = event?.location || "";
$("#calendar-meeting-url").value = event?.meeting_url || "";
$("#calendar-description").value = event?.description || "";
const color = String(event?.color || "");
$("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280";
const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes);
$("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : "";
$("#calendar-delete").disabled = !event;
}
async function saveCalendarEvent(event) {
event.preventDefault();
const id = $("#calendar-event-id").value;
const recurrenceChoice = $("#calendar-recurrence").value;
const recurrenceRule = recurrenceChoice === "custom" ? $("#calendar-rrule").value.trim() : recurrenceChoice;
const attendees = $("#calendar-attendees").value.split(",").map((value) => value.trim()).filter(Boolean);
const startValue = $("#calendar-start").value;
const endValue = $("#calendar-end").value;
if (!startValue || !endValue) return toast("Start and end are required", "error");
const base = {
title: $("#calendar-title").value.trim(),
description: $("#calendar-description").value,
location: $("#calendar-location").value.trim(),
start_time: new Date(startValue).toISOString(),
end_time: new Date(endValue).toISOString(),
all_day: $("#calendar-all-day").checked,
recurrence_rule: recurrenceRule || null,
attendees: JSON.stringify(attendees),
organizer: $("#calendar-organizer").value.trim(),
status: $("#calendar-status").value || "confirmed",
meeting_url: $("#calendar-meeting-url").value.trim() || null,
color: $("#calendar-color").value || null,
reminder_minutes: $("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value),
};
const payload = id ? base : { ...base, source: "manual", source_uid: null, source_message_uid: null, source_message_folder: null };
try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteCalendarEvent() {
const id = $("#calendar-event-id").value;
if (!id || !await appConfirm("Delete this event?", { title: "Delete event", confirmLabel: "Delete", danger: true })) return;
try { await request(`/calendar/events/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event deleted"); }
catch (error) { toast(error.message, "error"); }
}
+557
View File
@@ -0,0 +1,557 @@
/* RustBox UI module: 72-openpgp-identities.js */
async function loadOpenPgp() {
try {
const [keys, prefs] = await Promise.all([
request("/pgp/keys", { method: "GET" }),
request("/pgp/preferences", { method: "GET" }),
]);
state.pgpKeys = Array.isArray(keys) ? keys : [];
state.pgpPrefs = { ...state.pgpPrefs, ...(prefs || {}) };
renderOpenPgp();
renderIdentityPgpOptions();
updateComposePgpUi();
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
} catch (error) {
state.pgpKeys = [];
updateComposePgpUi();
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
toast(error.message, "error");
}
}
function pgpFingerprintLabel(value) {
const fp = String(value || "").replace(/\s+/g, "").toUpperCase();
return fp ? fp.replace(/(.{4})/g, "$1 ").trim() : "";
}
function renderOpenPgp() {
if (!$("#pgp-key-list")) return;
$("#pgp-pref-wkd").checked = Boolean(state.pgpPrefs.wkdEnabled);
$("#pgp-pref-hkp").checked = Boolean(state.pgpPrefs.hkpEnabled);
$("#pgp-pref-trusted").checked = Boolean(state.pgpPrefs.requireTrustedEncryption);
const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled);
if ($("#pgp-discover")) {
$("#pgp-discover").disabled = !discoveryEnabled;
$("#pgp-discover").title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server first";
}
$("#pgp-key-summary").textContent = `${state.pgpKeys.length} key${state.pgpKeys.length === 1 ? "" : "s"} · ${state.pgpKeys.filter((key) => key.hasSecret).length} private`;
const list = $("#pgp-key-list");
list.replaceChildren();
if (!state.pgpKeys.length) list.append(el("div", "empty-list", "No OpenPGP keys yet"));
for (const key of state.pgpKeys) {
const card = el("article", "pgp-key-card");
const head = el("div", "pgp-key-head");
const title = el("div");
title.innerHTML = `<strong>${escapeHtml(key.userId || key.email || key.keyId)}</strong><small>${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}</small>`;
const trust = document.createElement("select");
trust.setAttribute("aria-label", `Trust for ${key.email || key.keyId}`);
for (const value of ["unknown", "verified", "trusted", "untrusted"]) trust.add(new Option(value[0].toUpperCase() + value.slice(1), value));
trust.value = key.trust || "unknown";
trust.addEventListener("change", async () => {
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/trust`, { method: "PATCH", body: JSON.stringify({ trust: trust.value }) }); await loadOpenPgp(); toast("OpenPGP trust updated", "success"); }
catch (error) { toast(error.message, "error"); }
});
head.append(title, trust);
const fp = el("code", "pgp-fingerprint", pgpFingerprintLabel(key.fingerprint));
fp.title = "Fingerprint";
const actions = el("div", "inline-actions");
const pub = el("button", "button quiet small", "Export public"); pub.type = "button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub);
if (key.hasSecret) {
const test = el("button", "button quiet small", "Test passphrase"); test.type = "button"; test.addEventListener("click", async () => {
const passphrase = await appPrompt("Test OpenPGP passphrase", { message: "Enter the passphrase for this private key. It is used only for this test and is not stored.", type: "password", confirmLabel: "Test" });
if (passphrase === null) return;
setBusy(test, true);
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/test-passphrase`, { method: "POST", body: JSON.stringify({ passphrase }) }); toast("OpenPGP private key unlocked successfully", "success"); }
catch (error) { toast(error.message, "error"); } finally { setBusy(test, false); }
}); actions.append(test);
const change = el("button", "button quiet small", "Change passphrase"); change.type = "button"; change.addEventListener("click", async () => {
const currentPassphrase = await appPrompt("Current OpenPGP passphrase", { message: "Enter the current passphrase. Leave it empty if the key is currently unprotected.", type: "password", confirmLabel: "Continue" });
if (currentPassphrase === null) return;
const newPassphrase = await appPrompt("New OpenPGP passphrase", { message: "Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.", type: "password", confirmLabel: "Change" });
if (newPassphrase === null) return;
if (newPassphrase === "" && !await appConfirm("Remove passphrase protection from this private key? The key file will still be protected by server filesystem permissions, but anyone who obtains it could use it immediately.", { title: "Remove OpenPGP passphrase", confirmLabel: "Remove passphrase", danger: true })) return;
setBusy(change, true);
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/change-passphrase`, { method: "POST", body: JSON.stringify({ currentPassphrase, newPassphrase }) }); toast("OpenPGP passphrase changed", "success"); }
catch (error) { toast(error.message, "error"); } finally { setBusy(change, false); }
}); actions.append(change);
const sec = el("button", "button quiet small", "Export private"); sec.type = "button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title: "Export private key", confirmLabel: "Export" })) downloadPgpKey(key, true); }); actions.append(sec);
}
const del = el("button", "button quiet small danger", "Delete"); del.type = "button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title: "Delete OpenPGP key", confirmLabel: "Delete", danger: true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method: "DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message, "error"); } }); actions.append(del);
card.append(head, fp, actions);
list.append(card);
}
}
async function downloadPgpKey(key, secret) {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/export?secret=${secret ? "true" : "false"}`), { credentials: "include", headers });
if (!response.ok) throw new Error(await response.text() || `Export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url; a.download = `openpgp-${key.keyId}-${secret ? "private" : "public"}.asc`; a.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
} catch (error) { toast(error.message, "error"); }
}
async function savePgpPreferences() {
try {
state.pgpPrefs = await request("/pgp/preferences", { method: "PUT", body: JSON.stringify({ wkdEnabled: $("#pgp-pref-wkd").checked, hkpEnabled: $("#pgp-pref-hkp").checked, requireTrustedEncryption: $("#pgp-pref-trusted").checked }) });
renderOpenPgp();
updateComposePgpUi();
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
toast("OpenPGP policy saved", "success");
} catch (error) { toast(error.message, "error"); }
}
async function generatePgpKey() {
const button = $("#pgp-generate"); setBusy(button, true);
try {
const key = await request("/pgp/keys/generate", { method: "POST", body: JSON.stringify({ name: $("#pgp-generate-name").value.trim(), email: $("#pgp-generate-email").value.trim(), passphrase: $("#pgp-generate-passphrase").value }) });
$("#pgp-generate-passphrase").value = ""; await loadOpenPgp(); toast(`Generated OpenPGP key ${key.keyId}`, "success");
} catch (error) { toast(error.message, "error"); } finally { setBusy(button, false); }
}
async function importPgpText(text = $("#pgp-import-data").value) {
if (!String(text || "").trim()) return toast("Paste or choose an OpenPGP key first", "warning");
try { const key = await request("/pgp/keys", { method: "POST", body: JSON.stringify({ keyData: String(text), trust: $("#pgp-import-trust").value || "unknown" }) }); $("#pgp-import-data").value = ""; await loadOpenPgp(); toast(`Imported OpenPGP key ${key.keyId}`, "success"); }
catch (error) { toast(error.message, "error"); }
}
function bytesToBase64(bytes) {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)));
}
return btoa(binary);
}
async function importPgpFile(file) {
if (!file) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
if (!bytes.length) return toast("The selected OpenPGP key file is empty", "warning");
const key = await request("/pgp/keys", {
method: "POST",
body: JSON.stringify({ keyDataBase64: bytesToBase64(bytes), trust: $("#pgp-import-trust").value || "unknown" }),
});
$("#pgp-import-data").value = "";
await loadOpenPgp();
toast(`Imported OpenPGP key ${key.keyId}`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function discoverPgpEmail(email = $("#pgp-discover-email").value.trim(), quiet = false) {
if (!email) return null;
try {
const result = await request("/pgp/discover", { method: "POST", body: JSON.stringify({ email }) });
if (result.found) { await loadOpenPgp(); if (!quiet) toast(`OpenPGP key discovered via ${(result.source || "discovery").toUpperCase()}`, "success"); return result.key; }
if (!quiet) toast(`No OpenPGP key found for ${email}`, "warning");
return null;
} catch (error) { if (!quiet) toast(error.message, "error"); return null; }
}
function renderIdentityPgpOptions(selectedFingerprint = null) {
const select = $("#identity-pgp-key");
if (!select) return;
const value = selectedFingerprint ?? select.value;
select.replaceChildren(new Option("Automatic / none", ""));
for (const key of state.pgpKeys.filter((item) => item.hasSecret)) {
select.add(new Option(`${key.email || key.userId || key.keyId} · ${key.keyId}`, key.fingerprint));
}
select.value = value || "";
}
function pgpModeForIdentity(identity) {
if (!identity) return "none";
if (identity.pgp_sign_default && identity.pgp_encrypt_default) return "sign-encrypt";
if (identity.pgp_encrypt_default) return "encrypt";
if (identity.pgp_sign_default) return "sign";
return "none";
}
function updateComposePgpUi(applyDefaults = false) {
const modeSelect = $("#compose-pgp-mode");
const panel = $("#compose-pgp-panel");
const option = $("#compose-pgp-option");
if (!modeSelect || !panel) return;
const configured = state.pgpKeys.length > 0;
option?.classList.toggle("hidden", !configured);
panel.classList.toggle("hidden", !configured);
if (!configured) {
panel.open = false;
modeSelect.value = "none";
$("#compose-pgp-inline").checked = false;
$("#compose-pgp-attach-key").checked = false;
$("#compose-pgp-attach-recipient-keys").checked = false;
state.pgpRecipientStatus = [];
return;
}
const identity = selectedIdentity();
const accountEmail = state.accounts.find((item) => item.id === state.accountId)?.email || "";
const senderEmail = identity?.email || accountEmail;
const selectedKey = identity?.pgp_fingerprint ? state.pgpKeys.find((key) => key.fingerprint === identity.pgp_fingerprint) : null;
const senderPublicKey = selectedKey || state.pgpKeys.find((key) => key.email?.toLowerCase() === senderEmail.toLowerCase());
const signingKey = selectedKey?.hasSecret ? selectedKey : state.pgpKeys.find((key) => key.hasSecret && key.email?.toLowerCase() === senderEmail.toLowerCase());
if (applyDefaults) {
modeSelect.value = pgpModeForIdentity(identity);
$("#compose-pgp-inline").checked = Boolean(identity?.pgp_inline_default);
panel.open = false;
}
const hasSigningKey = Boolean(signingKey?.hasSecret);
for (const value of ["sign", "sign-encrypt"]) {
const modeOption = [...modeSelect.options].find((item) => item.value === value);
if (modeOption) modeOption.disabled = !hasSigningKey;
}
if (!hasSigningKey && ["sign", "sign-encrypt"].includes(modeSelect.value)) modeSelect.value = modeSelect.value === "sign-encrypt" ? "encrypt" : "none";
const activeMode = modeSelect.value || "none";
const needsSigning = activeMode === "sign" || activeMode === "sign-encrypt";
$("#compose-pgp-passphrase-row").classList.toggle("hidden", !needsSigning);
const inline = $("#compose-pgp-inline");
if (inline) inline.disabled = activeMode === "none" || Boolean(state.composeAttachments.length);
if (inline?.disabled) inline.checked = false;
const senderRow = $("#compose-pgp-attach-sender-row");
senderRow?.classList.toggle("hidden", !senderPublicKey);
if (!senderPublicKey) $("#compose-pgp-attach-key").checked = false;
const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled);
const discoverButton = $("#compose-pgp-discover");
if (discoverButton) {
discoverButton.disabled = !discoveryEnabled;
discoverButton.title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server in OpenPGP settings first";
}
const recipientAttach = $("#compose-pgp-attach-recipient-keys");
const parts = [];
if (needsSigning) parts.push(`Signing key ${signingKey.keyId}`);
if (activeMode.includes("encrypt")) parts.push(state.pgpRecipientStatus.length ? recipientPgpSummary(state.pgpRecipientStatus) : "Recipient keys not checked yet");
if (recipientAttach?.checked) {
const available = state.pgpRecipientStatus.filter((item) => item.hasKey).length;
parts.push(state.pgpRecipientStatus.length ? `${available} visible recipient key${available === 1 ? "" : "s"} will be attached` : "Recipient keys to attach not checked yet");
}
if (!parts.length) parts.push(senderPublicKey ? `OpenPGP ready · ${senderPublicKey.keyId}` : "OpenPGP public keys available");
$("#compose-pgp-status").textContent = parts.join(" · ");
}
function recipientPgpSummary(statuses) {
const missing = statuses.filter((item) => !item.hasKey);
const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted");
const notTrusted = state.pgpPrefs.requireTrustedEncryption
? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust))
: [];
if (missing.length) return `${missing.length} recipient key${missing.length === 1 ? "" : "s"} missing`;
if (untrusted.length) return `${untrusted.length} recipient key${untrusted.length === 1 ? "" : "s"} marked untrusted`;
if (notTrusted.length) return `${notTrusted.length} recipient key${notTrusted.length === 1 ? " is" : "s are"} not trusted by policy`;
return `Keys available for ${statuses.length} recipient${statuses.length === 1 ? "" : "s"}`;
}
function pgpRecipientStatusHasWarning(item) {
if (!item.hasKey || item.trust === "untrusted") return true;
return Boolean(state.pgpPrefs.requireTrustedEncryption && !["trusted", "verified"].includes(item.trust));
}
function composePgpVisibleRecipients() {
return [...parseRecipients($("#compose-to").value), ...parseRecipients($("#compose-cc").value)];
}
function composePgpRecipients() {
return [...composePgpVisibleRecipients(), ...parseRecipients($("#compose-bcc").value)];
}
async function checkComposePgpRecipients(showToast = true, emails = composePgpRecipients()) {
if (!emails.length) { state.pgpRecipientStatus = []; updateComposePgpUi(); return []; }
try {
const statuses = await request("/pgp/recipients", { method: "POST", body: JSON.stringify({ emails }) });
state.pgpRecipientStatus = statuses || [];
updateComposePgpUi();
if (showToast) toast(recipientPgpSummary(state.pgpRecipientStatus), state.pgpRecipientStatus.some(pgpRecipientStatusHasWarning) ? "warning" : "success");
return state.pgpRecipientStatus;
} catch (error) { if (showToast) toast(error.message, "error"); return []; }
}
async function discoverComposePgpRecipients() {
const statuses = await checkComposePgpRecipients(false);
const missing = statuses.filter((item) => !item.hasKey).map((item) => item.email);
if (!missing.length) return toast("All recipient OpenPGP keys are already available", "success");
let found = 0;
for (const email of missing) if (await discoverPgpEmail(email, true)) found += 1;
await checkComposePgpRecipients(false);
toast(found ? `Discovered ${found} OpenPGP key${found === 1 ? "" : "s"}` : "No missing OpenPGP keys were discovered", found ? "success" : "warning");
}
async function loadIdentities() {
try {
const data = await request("/identities", { method: "GET" });
state.identities = data.identities || [];
renderIdentities();
renderComposeIdentities();
renderIdentityPgpOptions();
updateComposePgpUi();
} catch (error) { toast(error.message, "error"); }
}
function renderComposeIdentities() {
const select = $("#compose-from");
if (!select) return;
select.replaceChildren(new Option("Account default", ""));
for (const identity of state.identities) {
const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email;
const option = new Option(label, String(identity.id));
if (identity.is_default) option.selected = true;
select.add(option);
}
}
function renderIdentities() {
const list = $("#identity-list");
if (!list) return;
list.replaceChildren();
if (!state.identities.length) list.append(el("div", "empty-list", "No identities yet"));
for (const identity of state.identities) {
const button = el("button", "identity-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(identity.display_name || identity.email)}</strong><small>${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}</small>`;
button.addEventListener("click", () => editIdentity(identity));
list.append(button);
}
}
function editIdentity(identity = null) {
$("#identity-id").value = identity?.id || "";
$("#identity-name").value = identity?.display_name || "";
$("#identity-email").value = identity?.email || "";
$("#identity-reply-to").value = identity?.reply_to || "";
$("#identity-bcc").value = identity?.bcc || "";
$("#identity-organization").value = identity?.organization || "";
$("#identity-signature-text").value = identity?.signature_text || "";
$("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || "");
$("#identity-signature-position").value = identity?.signature_position || "above";
renderIdentityPgpOptions(identity?.pgp_fingerprint || "");
$("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default);
$("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default);
$("#identity-pgp-inline").checked = Boolean(identity?.pgp_inline_default);
$("#identity-signature-new").checked = identity?.signature_new !== false;
$("#identity-signature-reply").checked = identity?.signature_reply !== false;
$("#identity-signature-forward").checked = identity?.signature_forward !== false;
$("#identity-default").checked = Boolean(identity?.is_default);
$("#identity-delete").disabled = !identity;
$("#identity-dialog-title").textContent = identity ? `Edit identity · ${identity.email}` : "New identity & signature";
const pgpConfigured = state.pgpKeys.length > 0;
$("#identity-pgp-key-row")?.classList.toggle("hidden", !pgpConfigured);
$$(".identity-pgp-default").forEach((row) => row.classList.toggle("hidden", !pgpConfigured));
const dialog = $("#identity-dialog");
if (!dialog.open) dialog.showModal();
setTimeout(() => (identity ? $("#identity-name") : $("#identity-email"))?.focus(), 20);
}
async function saveIdentity(event) {
event.preventDefault();
const id = $("#identity-id").value;
const signatureHtml = sanitizeMessageHtml($("#identity-signature").innerHTML);
if (new TextEncoder().encode(signatureHtml).length > 3 * 1024 * 1024) return toast("HTML signature is too large. Reduce imported image sizes.", "warning");
const payload = {
display_name: $("#identity-name").value.trim(),
email: $("#identity-email").value.trim(),
reply_to: $("#identity-reply-to").value.trim(),
bcc: $("#identity-bcc").value.trim(),
organization: $("#identity-organization").value.trim(),
signature_text: $("#identity-signature-text").value,
signature_html: signatureHtml,
signature_position: $("#identity-signature-position").value,
pgp_fingerprint: $("#identity-pgp-key").value,
pgp_sign_default: $("#identity-pgp-sign").checked,
pgp_encrypt_default: $("#identity-pgp-encrypt").checked,
pgp_inline_default: $("#identity-pgp-inline").checked,
signature_new: $("#identity-signature-new").checked,
signature_reply: $("#identity-signature-reply").checked,
signature_forward: $("#identity-signature-forward").checked,
is_default: $("#identity-default").checked,
};
try {
await request(id ? `/identities/${id}` : "/identities", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
await loadIdentities();
const saved = state.identities.find((item) => item.email === payload.email);
if (saved) {
$("#identity-id").value = saved.id || "";
}
if ($("#identity-dialog").open) $("#identity-dialog").close();
toast("Identity saved");
} catch (error) { toast(error.message, "error"); }
}
async function deleteIdentity() {
const id = $("#identity-id").value;
if (!id || !await appConfirm("Delete this identity?", { title: "Delete identity", confirmLabel: "Delete", danger: true })) return;
try {
await request(`/identities/${id}`, { method: "DELETE" });
await loadIdentities();
if ($("#identity-dialog").open) $("#identity-dialog").close();
toast("Identity deleted");
} catch (error) { toast(error.message, "error"); }
}
function selectedIdentity() {
const id = Number($("#compose-from")?.value || 0);
return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null;
}
function composeActionFromTitle() {
const title = $("#compose-title").textContent;
if (title === "Reply" || title === "Reply all") return "reply";
if (title === "Forward") return "forward";
return "new";
}
function dataUrlToSignatureFile(src, index = 0) {
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(String(src || ""));
if (!match) return null;
try {
const binary = atob(match[2].replace(/\s+/g, ""));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
const type = match[1].toLowerCase();
const ext = type === "image/jpeg" ? "jpg" : type.slice("image/".length);
return new File([bytes], `signature-${index + 1}.${ext}`, { type });
} catch (_) {
return null;
}
}
async function materializeSignatureImages(signature, generation) {
const images = [...signature.querySelectorAll('img[src^="data:image/"]')];
if (!images.length) return;
const pairs = images.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file);
if (!pairs.length) return;
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false });
if (!uploaded.length) return;
if (generation !== state.composeSignatureGeneration || !signature.isConnected) {
for (const attachment of uploaded) state.inlineAttachmentIds.add(String(attachment.id));
renderComposeAttachments();
scheduleUnusedInlineAttachmentCleanup();
return;
}
const draftId = ensureDraftId();
for (let index = 0; index < Math.min(pairs.length, uploaded.length); index += 1) {
const { image } = pairs[index];
const attachment = uploaded[index];
state.inlineAttachmentIds.add(String(attachment.id));
image.dataset.rustboxCid = String(attachment.id);
image.dataset.rustboxSignatureImage = "true";
image.setAttribute("src", composeAttachmentContentUrl(draftId, attachment.id));
}
renderComposeAttachments();
scheduleDraftSave();
}
function insertIdentitySignature(editor, action = composeActionFromTitle()) {
state.composeSignatureGeneration += 1;
const generation = state.composeSignatureGeneration;
editor.querySelectorAll(".compose-signature").forEach((node) => node.remove());
scheduleUnusedInlineAttachmentCleanup();
const identity = selectedIdentity();
if (!identity) return;
const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false;
if (!enabled) return;
const format = $("#compose-mode").value;
const signature = el("div", "compose-signature");
if (format === "html") {
if (!identity.signature_html) return;
signature.innerHTML = sanitizeMessageHtml(identity.signature_html);
} else {
const text = identity.signature_text || "";
if (!text) return;
signature.textContent = `\n-- \n${text}\n`;
}
const quote = editor.querySelector(".compose-quote");
if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote);
else editor.append(signature);
if (format === "html") void materializeSignatureImages(signature, generation);
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("Could not read image"));
reader.readAsDataURL(file);
});
}
async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) {
state.identitySignatureRange = null;
if (!file) return;
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) return toast("Choose a PNG, JPEG, GIF or WebP image", "warning");
if (file.size > 2 * 1024 * 1024) return toast("Signature image must be 2 MB or smaller", "warning");
const editor = $("#identity-signature");
try {
const src = await readFileAsDataUrl(file);
editor.focus();
restoreEditorRange(range, editor);
const alt = escapeHtml((file.name || "signature image").replace(/\.[^.]+$/, ""));
document.execCommand("insertHTML", false, `<img src="${escapeHtml(src)}" alt="${alt}" style="max-width:320px;height:auto">`);
} catch (error) {
toast(error.message || "Could not import signature image", "error");
}
}
function populateSpecialFolderSelects() {
const roles = ["sent", "drafts", "trash", "junk", "archive"];
for (const role of roles) {
const select = $(`#special-${role}`);
if (!select) continue;
const automaticLabel = role === "junk" ? "Use default / auto-detect" : "Automatic / not set";
select.replaceChildren(new Option(automaticLabel, ""));
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
select.value = state.specialFolders?.[role] || "";
}
populateSpamDefaultFolderSelect();
}
function populateSpamDefaultFolderSelect() {
const select = $("#spam-default-folder");
if (!select) return;
const stored = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim();
select.replaceChildren(new Option("Automatic IMAP Junk/Spam", ""));
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
if (stored && !state.folders.some((folder) => folder.name === stored)) {
const stale = new Option(`${stored} (not available in this account)`, stored);
stale.disabled = true;
select.add(stale);
}
select.value = stored;
}
async function loadSpecialFolders() {
try { state.specialFolders = { ...state.specialFolders, ...(await request("/settings/special-folders", { method: "GET" })) }; }
catch (error) { console.warn("Could not load special folders", error); }
populateSpecialFolderSelects();
}
async function saveSpecialFolders() {
const value = {};
for (const role of ["sent", "drafts", "trash", "junk", "archive"]) value[role] = $(`#special-${role}`).value || null;
const browserSpamDefault = $("#spam-default-folder")?.value || "";
try {
state.specialFolders = await request("/settings/special-folders", { method: "PUT", body: JSON.stringify(value) });
if (browserSpamDefault) localStorage.setItem(SPAM_DEFAULT_FOLDER_KEY, browserSpamDefault);
else localStorage.removeItem(SPAM_DEFAULT_FOLDER_KEY);
populateSpecialFolderSelects();
toast("Folder settings saved");
} catch (error) { toast(error.message, "error"); }
}
async function autoDetectSpecialFolders() {
for (const role of ["sent", "drafts", "trash", "junk", "archive"]) {
const select = $(`#special-${role}`);
if (select) select.value = autoDetectSpecialFolder(role) || "";
}
await saveSpecialFolders();
}
+342
View File
@@ -0,0 +1,342 @@
/* RustBox UI module: 80-settings-realtime.js */
async function loadQuota() {
const summary = $("#quota-summary span");
const bar = $("#quota-bar");
if (!summary || !bar) return;
summary.textContent = "Loading quota…";
bar.style.width = "0%";
try {
const quota = await request("/quota", { method: "GET" });
const used = Number(quota.usage_bytes || 0);
const limit = Number(quota.limit_bytes || 0);
if (limit > 0) {
const percent = Math.max(0, Math.min(100, (used / limit) * 100));
summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
bar.style.width = `${percent}%`;
const quotaBox = $("#quota-summary");
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
quotaBox?.classList.toggle("quota-warning", level === "warning");
quotaBox?.classList.toggle("quota-danger", level === "danger");
if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) {
state.quotaWarningLevel = `${state.accountId}:${level}`;
toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning");
}
} else {
summary.textContent = `${formatBytes(used)} used · server limit unavailable`;
}
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; }
}
function openSettings(tab = "appearance") {
selectSettingsTab(tab);
$("#settings-dialog").showModal();
if (tab === "sieve") loadSieve();
if (tab === "identities") { loadIdentities(); loadOpenPgp(); }
if (tab === "openpgp") loadOpenPgp();
if (tab === "notifications") loadNotificationPreferences();
if (tab === "mailbox") loadMailPreferences();
if (tab === "diagnostics") loadDiagnostics();
if (tab === "account") { renderAccountSettings(); loadActiveSessions(); }
if (tab === "folders") { loadSpecialFolders(); loadQuota(); }
}
function selectSettingsTab(name) {
$$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name));
}
function updateNotificationPermissionStatus() {
const status = $("#notify-permission-status");
if (!status) return;
status.textContent = !("Notification" in window) ? "Browser permission: unsupported" : `Browser permission: ${Notification.permission}`;
}
async function loadNotificationPreferences() {
try {
const prefs = await request("/settings/notifications", { method: "GET" });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
$("#notify-enabled").checked = Boolean(prefs.enabled);
$("#notify-sound").checked = Boolean(prefs.sound);
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
renderNotificationFolders(prefs.folders || ["INBOX"]);
updateNotificationPermissionStatus();
} catch (error) { toast(error.message, "error"); }
}
function renderNotificationFolders(selected) {
const set = new Set(selected || []);
const list = $("#notify-folders");
list.replaceChildren();
for (const folder of orderedFolders()) {
const label = el("label", "checkbox-row");
const input = document.createElement("input");
input.type = "checkbox";
input.value = folder.name;
input.checked = set.has(folder.name);
label.append(input, el("span", "", folder.name));
list.append(label);
}
}
async function saveNotificationPreferences() {
const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value);
try {
if ($("#notify-enabled").checked && "Notification" in window && Notification.permission === "default") await requestNotificationPermission();
const prefs = await request("/settings/notifications", { method: "PUT", body: JSON.stringify({ enabled: $("#notify-enabled").checked, sound: $("#notify-sound").checked, folders, dnd_enabled: $("#notify-dnd-enabled").checked, dnd_start: $("#notify-dnd-start").value || "22:00", dnd_end: $("#notify-dnd-end").value || "07:00" }) });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
updateNotificationPermissionStatus();
toast("Notification settings saved");
} catch (error) { toast(error.message, "error"); }
}
async function requestNotificationPermission() {
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
const result = await Notification.requestPermission();
updateNotificationPermissionStatus();
toast(`Notification permission: ${result}`, result === "granted" ? "success" : "warning");
return result;
}
async function testNotification() {
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
let permission = Notification.permission;
if (permission === "default") permission = await requestNotificationPermission();
if (permission !== "granted") return toast("Browser notifications are not allowed", "warning");
const notification = new Notification("RustBox notification test", { body: "Browser notifications are working.", tag: "rustbox-test" });
notification.onclick = () => { window.focus(); notification.close(); };
if ($("#notify-sound")?.checked) playNotificationSound();
toast("Test notification sent", "success");
}
function dndActive(prefs = state.notificationPrefs) {
if (!prefs?.dnd_enabled) return false;
const toMinutes = (value) => {
const [h, m] = String(value || "00:00").split(":").map(Number);
return h * 60 + m;
};
const now = new Date();
const current = now.getHours() * 60 + now.getMinutes();
const start = toMinutes(prefs.dnd_start);
const end = toMinutes(prefs.dnd_end);
if (start === end) return true;
return start < end ? current >= start && current < end : current >= start || current < end;
}
function playNotificationSound() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 620;
gain.gain.setValueAtTime(0.0001, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16);
oscillator.connect(gain); gain.connect(context.destination);
oscillator.start(); oscillator.stop(context.currentTime + 0.17);
oscillator.onended = () => context.close().catch(() => { });
} catch (_) { }
}
function notifyNewMail(data, accountId) {
const prefs = state.notificationPrefs || {};
if (!prefs.enabled || dndActive(prefs)) return;
const folders = prefs.folders || [];
if (folders.length && !folders.includes(data.folder)) return;
if ("Notification" in window && Notification.permission === "granted") {
const account = state.accounts.find((item) => item.id === accountId);
const title = data.latest_sender || account?.email || "New mail";
const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`;
const notification = new Notification(title, { body, tag: `rustbox-${accountId}-${data.folder}`, renotify: true });
notification.onclick = () => { window.focus(); notification.close(); };
}
if (prefs.sound) playNotificationSound();
}
function calendarReminderStorageKey(event) {
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}`;
}
function fireCalendarReminder(event) {
const title = event.title || "Calendar reminder";
const location = event.location ? ` · ${event.location}` : "";
const when = formatDate(event.start_time, true);
toast(`${title} — ${when}${location}`, "info", 12000);
if (!dndActive() && state.notificationPrefs?.enabled && "Notification" in window && Notification.permission === "granted") {
const notification = new Notification(title, { body: `${when}${location}`, tag: `rustbox-calendar-${event.id}-${event.start_time}`, renotify: true });
notification.onclick = () => { window.focus(); notification.close(); openCalendar(); };
}
if (!dndActive() && state.notificationPrefs?.sound) playNotificationSound();
}
async function checkCalendarReminders() {
if (!state.accountId) return;
const now = Date.now();
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
const end = new Date(now + 2 * 24 * 60 * 60 * 1000);
try {
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
for (const event of data.events || []) {
if (event.reminder_minutes == null || String(event.status || "").toLowerCase() === "cancelled") continue;
const startMs = new Date(event.start_time).getTime();
if (!Number.isFinite(startMs)) continue;
const trigger = startMs - Math.max(0, Number(event.reminder_minutes) || 0) * 60000;
if (trigger > now || now - trigger > 10 * 60 * 1000) continue;
const key = calendarReminderStorageKey(event);
if (localStorage.getItem(key)) continue;
localStorage.setItem(key, String(now));
fireCalendarReminder(event);
}
} catch (_) { }
}
function startCalendarReminderWatcher() {
if (state.calendarReminderTimer) clearInterval(state.calendarReminderTimer);
void checkCalendarReminders();
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
}
function scheduleRealtimeRefresh(event, accountId) {
if (accountId !== state.accountId) return;
clearTimeout(state.realtimeRefreshTimer);
state.realtimeRefreshTimer = setTimeout(async () => {
await refreshFolderListOnly();
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
}
}, 250);
}
function connectRealtime() {
if (!state.accounts.length) return;
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
clearTimeout(state.wsRetryTimer);
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
state.ws = socket;
socket.onmessage = (message) => {
try {
const payload = JSON.parse(message.data);
const event = payload.event || {};
if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId);
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
} catch (error) { console.warn("Invalid realtime event", error); }
};
socket.onclose = () => {
if (state.ws === socket) state.ws = null;
if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500);
};
socket.onerror = () => socket.close();
}
function disconnectRealtime() {
clearTimeout(state.wsRetryTimer);
clearTimeout(state.realtimeRefreshTimer);
state.wsRetryTimer = null;
state.realtimeRefreshTimer = null;
const socket = state.ws;
state.ws = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
}
function capabilityBadge(label, supported) {
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
}
async function loadDiagnostics() {
const imapStatus = $("#diagnostic-imap-status");
const smtpStatus = $("#diagnostic-smtp-status");
const sieveStatus = $("#diagnostic-sieve-status");
const wsStatus = $("#diagnostic-ws-status");
const syncStatus = $("#diagnostic-sync-status");
const searchStatus = $("#diagnostic-search-status");
imapStatus.textContent = "Checking";
smtpStatus.textContent = "Checking";
sieveStatus.textContent = "Checking";
syncStatus.textContent = "Checking";
searchStatus.textContent = "Checking";
wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected";
try {
const info = await request("/diagnostics/imap", { method: "GET" });
imapStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-imap-summary").innerHTML = [
["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore],
["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace],
["SPECIAL-USE", info.specialUse], ["QUOTA", info.quota], ["ACL", info.acl], ["LIST-EXTENDED", info.listExtended], ["UTF8=ACCEPT", info.utf8Accept], ["CUSTOM KEYWORDS", info.customKeywords],
].map(([label, supported]) => capabilityBadge(label, supported)).join("");
$("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported";
} catch (error) {
imapStatus.textContent = "Unavailable";
$("#diagnostic-imap-summary").textContent = error.message;
$("#diagnostic-imap-caps").textContent = "";
}
try {
const info = await request("/diagnostics/smtp", { method: "GET" });
smtpStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable";
} catch (error) {
smtpStatus.textContent = "Unavailable";
$("#diagnostic-smtp-summary").textContent = error.message;
}
try {
const info = await request("/sieve/status", { method: "GET" });
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
} catch (error) {
sieveStatus.textContent = "Unavailable";
$("#diagnostic-sieve-summary").textContent = error.message;
}
try {
const info = await request("/diagnostics/sync", { method: "GET" });
syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready";
const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never";
const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never";
$("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`;
} catch (error) {
syncStatus.textContent = "Unavailable";
$("#diagnostic-sync-summary").textContent = error.message;
}
try {
const info = await request("/diagnostics/search", { method: "GET" });
searchStatus.textContent = "Ready";
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
} catch (error) {
searchStatus.textContent = "Unavailable";
$("#diagnostic-search-summary").textContent = error.message;
}
}
async function reconnectImap() {
const button = $("#diagnostic-imap-reconnect");
if (button) button.disabled = true;
try {
const result = await request("/diagnostics/imap/reconnect", { method: "POST", body: "{}" });
toast(`IMAP reconnected · ${result.capabilityCount || 0} capabilities`);
await loadDiagnostics();
await loadFolders(false);
} catch (error) {
toast(error.message, "error");
} finally {
if (button) button.disabled = false;
}
}
async function rebuildSearchIndex() {
const button = $("#diagnostic-search-rebuild");
if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return;
button.disabled = true;
try {
const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" });
toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
await loadDiagnostics();
} catch (error) {
toast(error.message, "error");
} finally {
button.disabled = false;
}
}
File diff suppressed because it is too large. Load diff
+454
View File
@@ -0,0 +1,454 @@
/* RustBox UI module: 95-bindings-boot.js */
function bindEditor() {
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
$("#compose-editor").focus();
document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);
}));
$("#editor-link").addEventListener("click", async () => {
const url = await appPrompt("Insert link", { value: "https://", type: "url", confirmLabel: "Insert link", required: true });
if (!url) return;
$("#compose-editor").focus();
document.execCommand("createLink", false, url);
});
$("#editor-font").addEventListener("change", (event) => {
if (!event.target.value) return;
$("#compose-editor").focus();
document.execCommand("fontName", false, event.target.value);
scheduleDraftSave();
});
$("#editor-size").addEventListener("change", (event) => {
if (!event.target.value) return;
$("#compose-editor").focus();
document.execCommand("fontSize", false, event.target.value);
scheduleDraftSave();
});
$("#editor-color").addEventListener("input", (event) => {
$("#compose-editor").focus();
document.execCommand("foreColor", false, event.target.value);
scheduleDraftSave();
});
}
function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
const handle = $(selector);
if (!handle) return;
handle.addEventListener("pointerdown", (event) => {
if (innerWidth <= 820 || event.button !== 0) return;
event.preventDefault();
const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1;
const startX = event.clientX;
const startRendered = Number(state.prefs[preferenceKey] || minPx) * scaleFactor;
handle.setPointerCapture?.(event.pointerId);
document.body.classList.add("resizing-pane");
const move = (moveEvent) => {
const rendered = Math.max(minPx * scaleFactor, Math.min(maxPx * scaleFactor, startRendered + (moveEvent.clientX - startX)));
const base = Math.round(rendered / scaleFactor);
state.prefs[preferenceKey] = base;
const cssName = preferenceKey === "folderPaneWidth" ? "--folder-pane-width" : "--message-pane-width";
root.style.setProperty(cssName, `${Math.round(rendered)}px`);
handle.setAttribute("aria-valuenow", String(base));
};
const finish = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", finish);
document.body.classList.remove("resizing-pane");
savePreference(preferenceKey, state.prefs[preferenceKey]);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", finish);
});
}
function bindEvents() {
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
bindRecipientAutocomplete("#compose-to");
bindRecipientAutocomplete("#compose-cc");
bindRecipientAutocomplete("#compose-bcc");
$("#login-form").addEventListener("submit", login);
$("#logout-btn").addEventListener("click", logout);
$("#compose-btn").addEventListener("click", () => openCompose("new"));
$("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen);
$("#compose-form").addEventListener("submit", sendMessage);
$("#compose-attach").addEventListener("click", () => $("#compose-files").click());
$("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; });
$("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); });
$("#editor-image-edit").addEventListener("click", () => openInlineImageEditor());
$("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; });
$("#image-edit-form").addEventListener("submit", applyInlineImageEditor);
$("#image-edit-reset").addEventListener("click", resetImageCropControls);
["#image-crop-top", "#image-crop-right", "#image-crop-bottom", "#image-crop-left"].forEach((selector) => $(selector).addEventListener("input", drawInlineImageCropPreview));
$("#image-edit-width").addEventListener("input", updateImageEditOutputs);
$("#image-edit-dialog").addEventListener("close", () => { state.imageEditorBitmap?.close?.(); state.imageEditorBitmap = null; state.imageEditorBlob = null; });
$("#compose-recover").addEventListener("click", recoverDraft);
$("#compose-mode").addEventListener("change", async (event) => {
const editor = $("#compose-editor");
const previous = editor.dataset.mode || "html";
const next = event.target.value;
const richContent = previous === "html" && next !== "html" && Boolean(editor.querySelector("img,a,strong,b,em,i,u,s,ul,ol,li,blockquote,[style]"));
if (richContent && !await appConfirm("Switching away from HTML can hide or discard formatting when the message is sent. Continue?", { title: "Change compose format", confirmLabel: "Switch format" })) {
event.target.value = previous;
return;
}
setComposeMode(next, true);
insertIdentitySignature(editor);
scheduleDraftSave();
});
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); });
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
state.pgpRecipientStatus = [];
updateComposePgpUi();
scheduleDraftSave();
}));
$("#compose-subject").addEventListener("input", scheduleDraftSave);
["#compose-priority", "#compose-mdn", "#compose-pgp-mode", "#compose-pgp-inline", "#compose-pgp-attach-key", "#compose-pgp-attach-recipient-keys"].forEach((selector) => $(selector).addEventListener("change", async () => {
if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients());
updateComposePgpUi();
scheduleDraftSave();
}));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); });
$("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null);
});
$("#compose-editor").addEventListener("dblclick", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
if (image) { event.preventDefault(); openInlineImageEditor(image); }
});
$("#compose-editor").addEventListener("paste", async (event) => {
const images = [...(event.clipboardData?.items || [])]
.filter((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!images.length) return;
event.preventDefault();
if ($("#compose-mode").value !== "html") return toast("Switch to HTML mode to paste inline images", "warning");
const range = captureEditorRange();
await uploadInlineImage(images, range);
});
$("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); });
$("#compose-editor").addEventListener("drop", async (event) => {
const files = [...event.dataTransfer.files];
if (!files.length) return;
event.preventDefault();
const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type));
if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY));
else await uploadComposeFiles(files);
});
$("#reply-btn").addEventListener("click", () => openCompose("reply"));
$("#reply-all-btn").addEventListener("click", () => openCompose("reply-all"));
$("#forward-btn").addEventListener("click", () => openCompose("forward"));
$("#delete-btn").addEventListener("click", deleteCurrent);
$("#unread-btn").addEventListener("click", toggleUnread);
$("#flag-btn").addEventListener("click", toggleFlagged);
$("#archive-btn").addEventListener("click", archiveCurrent);
$("#spam-btn").addEventListener("click", junkCurrent);
$("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); });
$("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); });
$("#bulk-select-all").addEventListener("change", (event) => { const source = state.searchAllResults || state.messages; for (const message of source) { const key = messageKey(message); event.target.checked ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); } renderMessages(); });
$("#bulk-read").addEventListener("click", () => performBulk("read"));
$("#bulk-unread").addEventListener("click", () => performBulk("unread"));
$("#bulk-flag").addEventListener("click", () => performBulk("flag"));
$("#bulk-unflag").addEventListener("click", () => performBulk("unflag"));
$("#bulk-archive").addEventListener("click", () => performBulk("archive"));
$("#bulk-select-search").addEventListener("click", selectAllSearchResults);
$("#bulk-junk").addEventListener("click", () => performBulk("junk"));
$("#bulk-delete").addEventListener("click", () => performBulk("delete"));
$("#bulk-move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await performBulk("move", target); });
$("#bulk-copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await performBulk("copy", target); });
$("#bulk-tag-select").addEventListener("change", async (event) => { const tagId = event.target.value; event.target.value = ""; if (tagId) await bulkAddTag(tagId); });
$("#select-mode-btn").addEventListener("click", () => setSelectionMode(!state.selectionMode));
$("#selection-done").addEventListener("click", () => setSelectionMode(false));
$("#refresh-btn").addEventListener("click", () => loadFolders(false));
$("#message-sort").addEventListener("change", async (event) => {
const value = event.target.value;
state.mailPrefs.default_sort = value;
$("#mail-pref-sort").value = value;
try {
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ default_sort: value }) });
state.page = 0;
await loadMessages(true);
toast("Message sort updated", "success");
} catch (error) { toast(error.message, "error"); }
});
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true });
window.addEventListener("offline", () => setConnectivityState(false));
window.addEventListener("online", async () => {
setConnectivityState(true);
disconnectRealtime();
connectRealtime();
if (state.accounts.length) {
await refreshFolderListOnly();
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
}
});
$("#search-form").addEventListener("submit", searchMail);
$("#advanced-search-btn").addEventListener("click", openAdvancedSearch);
$("#advanced-search-form").addEventListener("submit", submitAdvancedSearch);
$("#search-reset-btn").addEventListener("click", resetAdvancedSearch);
$("#search-save-btn").addEventListener("click", saveAdvancedSearch);
$("#contacts-btn").addEventListener("click", openContacts);
$("#contact-group-filter").addEventListener("change", async () => { renderContactGroupFilter(); await loadContacts($("#contact-search").value.trim()); });
$("#contact-group-new").addEventListener("click", createContactGroup);
$("#contact-group-rename").addEventListener("click", renameContactGroup);
$("#contact-group-delete").addEventListener("click", deleteContactGroup);
$("#tag-add-btn").addEventListener("click", () => openTagDialog());
$("#tag-form").addEventListener("submit", saveTag);
$("#tag-delete").addEventListener("click", deleteTag);
$("#calendar-btn").addEventListener("click", openCalendar);
$("#settings-btn").addEventListener("click", () => openSettings("appearance"));
$("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal());
$("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); });
$("#account-revoke-others").addEventListener("click", revokeOtherSessions);
$("#add-account-form").addEventListener("submit", addBrowserAccount);
$("#mobile-folders").addEventListener("click", toggleFolderDrawer);
$("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));
$("#folder-drawer-scrim").addEventListener("click", () => setFolderDrawerOpen(false));
$("#reader-back").addEventListener("click", closeReader);
$("#reader-prev").addEventListener("click", () => navigateMessage(-1));
$("#reader-next").addEventListener("click", () => navigateMessage(1));
$("#thread-toggle").addEventListener("click", () => {
state.threadExpanded = !state.threadExpanded;
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
if (state.currentDetail) renderThread(state.currentDetail);
});
$("#folder-manage-btn").addEventListener("click", openFolderDialog);
$("#create-folder-btn").addEventListener("click", createFolder);
$("#rename-folder-btn").addEventListener("click", renameFolder);
$("#delete-folder-btn").addEventListener("click", deleteFolder);
$("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription);
$("#headers-btn").addEventListener("click", showHeaders);
$("#source-btn").addEventListener("click", showMessageSource);
$("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender"));
$("#reader-from").addEventListener("click", (event) => { const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); });
$("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } });
$("#print-btn").addEventListener("click", printCurrentMessage);
$("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage);
$("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner);
$("#copy-headers-btn").addEventListener("click", copyHeaders);
$$(".close-dialog").forEach((button) => button.addEventListener("click", async () => {
const dialog = button.closest("dialog");
if (dialog?.id === "compose-dialog") await closeComposer();
else dialog?.close();
}));
$("#compose-dialog").addEventListener("cancel", async (event) => { event.preventDefault(); await closeComposer(); });
window.addEventListener("beforeunload", () => { if (state.composeDirty && draftHasContent()) storeLocalDraftSnapshot(); });
$("#compose-pgp-check").addEventListener("click", () => checkComposePgpRecipients(true));
$("#compose-pgp-discover").addEventListener("click", discoverComposePgpRecipients);
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("change", () => { state.pgpRecipientStatus = []; updateComposePgpUi(); }));
$("#pgp-refresh").addEventListener("click", loadOpenPgp);
$("#pgp-pref-save").addEventListener("click", savePgpPreferences);
$("#pgp-generate").addEventListener("click", generatePgpKey);
$("#pgp-import").addEventListener("click", () => importPgpText());
$("#pgp-import-file").addEventListener("click", () => $("#pgp-import-file-input").click());
$("#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(); loadOpenPgp(); } if (button.dataset.tab === "openpgp") 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-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
$("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value));
$("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value));
$("#pref-language").addEventListener("change", (event) => savePreference("language", event.target.value));
$("#pref-color-preset").addEventListener("change", (event) => savePreference("colorPreset", event.target.value));
$("#pref-accent").addEventListener("input", (event) => { $("#pref-accent-value").textContent = event.target.value; savePreference("accentColor", event.target.value); });
[["#pref-background", "#pref-background-value", "backgroundColor"], ["#pref-panel", "#pref-panel-value", "panelColor"], ["#pref-text-color", "#pref-text-color-value", "textColor"]].forEach(([input, output, key]) => $(input).addEventListener("input", (event) => { $(output).textContent = event.target.value; savePreference(key, event.target.value); }));
$("#pref-font-family").addEventListener("change", (event) => savePreference("fontFamily", event.target.value));
[["#pref-font-size", "fontSize"], ["#pref-row-height", "rowHeight"], ["#pref-folder-width", "folderPaneWidth"], ["#pref-message-width", "messagePaneWidth"], ["#pref-radius", "borderRadius"]].forEach(([selector, key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value))));
$("#pref-contrast").addEventListener("change", (event) => savePreference("contrast", event.target.value));
$("#pref-date-format").addEventListener("change", (event) => { savePreference("dateFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
$("#pref-time-format").addEventListener("change", (event) => { savePreference("timeFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
$("#pref-avatars").addEventListener("change", (event) => savePreference("showAvatars", event.target.checked));
$("#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-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));
$("#pref-selection-color").addEventListener("input", (event) => { $("#pref-selection-color-value").textContent = event.target.value; savePreference("selectionColor", event.target.value); });
$("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value));
$("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value));
$("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked));
$("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked));
$("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked));
[["folder", "folderBackground", "#101310"], ["message", "messageBackground", "#181c19"], ["reader", "readerBackground", "#181c19"]].forEach(([name, key, fallback]) => {
const enabled = $(`#pref-${name}-bg-enabled`); const color = $(`#pref-${name}-bg`);
enabled.addEventListener("change", () => savePreference(key, enabled.checked ? color.value : ""));
color.addEventListener("input", () => { if (enabled.checked) savePreference(key, color.value); });
});
$("#response-template-new").addEventListener("click", () => editResponseTemplate());
$("#compose-response-template").addEventListener("change", (event) => { $("#compose-response-insert").disabled = !event.target.value; });
$("#compose-response-insert").addEventListener("click", insertSavedResponse);
$("#compose-pref-format").addEventListener("change", (event) => savePreference("composeFormat", event.target.value));
$("#compose-pref-autosave").addEventListener("change", (event) => savePreference("composeAutosaveMs", Number(event.target.value)));
$("#compose-pref-priority").addEventListener("change", (event) => savePreference("defaultPriority", event.target.value));
$("#compose-pref-mdn").addEventListener("change", (event) => savePreference("requestMdnDefault", event.target.checked));
$("#compose-pref-attachment-reminder").addEventListener("change", (event) => savePreference("attachmentReminder", event.target.checked));
$("#compose-pref-font").addEventListener("change", (event) => savePreference("composeFontFamily", event.target.value));
$("#compose-pref-reply-position").addEventListener("change", (event) => savePreference("replyPosition", event.target.value));
$("#compose-pref-quote-style").addEventListener("change", (event) => savePreference("quoteStyle", event.target.value));
$("#compose-pref-forward-mode").addEventListener("change", (event) => savePreference("forwardMode", event.target.value));
$("#compose-pref-spellcheck").addEventListener("change", (event) => savePreference("spellcheckEnabled", event.target.checked));
$("#compose-markdown-preview").addEventListener("click", previewMarkdown);
$("#pref-reset").addEventListener("click", resetAppearance);
$("#special-folders-save").addEventListener("click", saveSpecialFolders);
$("#special-folders-auto").addEventListener("click", autoDetectSpecialFolders);
$("#identity-form").addEventListener("submit", saveIdentity);
$("#identity-new").addEventListener("click", () => editIdentity(null));
$("#identity-delete").addEventListener("click", deleteIdentity);
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
const editor = $("#identity-signature");
editor.focus();
document.execCommand(button.dataset.signatureCommand, false, null);
}));
$("#identity-signature-link").addEventListener("click", async () => {
const editor = $("#identity-signature");
const range = captureEditorRange(editor);
const url = await appPrompt("Insert signature link", { placeholder: "https://example.com", type: "url", confirmLabel: "Insert" });
if (!url) return;
editor.focus();
restoreEditorRange(range, editor);
document.execCommand("createLink", false, url);
});
$("#identity-signature-image-btn").addEventListener("click", () => { state.identitySignatureRange = captureEditorRange($("#identity-signature")); $("#identity-signature-image").click(); });
$("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; });
$("#identity-signature").addEventListener("paste", async (event) => {
const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile();
if (!file) return;
event.preventDefault();
await insertIdentitySignatureImage(file, captureEditorRange($("#identity-signature")));
});
$("#contact-form").addEventListener("submit", saveContact);
$("#contact-new").addEventListener("click", () => editContact(null));
$("#contact-import").addEventListener("click", () => $("#contact-import-file").click());
$("#contact-import-file").addEventListener("change", (event) => importContactVcard(event.target.files));
$("#contact-export").addEventListener("click", downloadContactExport);
$("#contact-duplicates").addEventListener("click", showContactDuplicates);
$("#contact-photo-upload").addEventListener("click", () => $("#contact-photo-file").click());
$("#contact-photo-file").addEventListener("change", (event) => uploadContactPhoto(event.target.files));
$("#contact-photo-delete").addEventListener("click", deleteContactPhoto);
$("#contact-delete").addEventListener("click", deleteContact);
$("#contact-compose").addEventListener("click", composeToContact);
$("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); });
$("#calendar-form").addEventListener("submit", saveCalendarEvent);
$("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click());
$("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files));
$("#calendar-export").addEventListener("click", exportCalendarIcs);
$("#calendar-recurrence").addEventListener("change", updateCalendarRecurrenceUi);
$("#calendar-new").addEventListener("click", () => editCalendarEvent(null));
$("#calendar-cancel").addEventListener("click", () => $("#calendar-form").classList.add("hidden"));
$("#calendar-delete").addEventListener("click", deleteCalendarEvent);
$("#calendar-view").addEventListener("change", async (event) => { state.calendarView = event.target.value; localStorage.setItem("rustbox.calendarView", state.calendarView); await loadCalendar(); });
$("#calendar-prev").addEventListener("click", async () => { shiftCalendarCursor(-1); await loadCalendar(); });
$("#calendar-next").addEventListener("click", async () => { shiftCalendarCursor(1); await loadCalendar(); });
$("#calendar-today").addEventListener("click", async () => { state.calendarCursor = new Date(); await loadCalendar(); });
$("#notify-save").addEventListener("click", saveNotificationPreferences);
$("#notify-permission").addEventListener("click", requestNotificationPermission);
$("#notify-test").addEventListener("click", testNotification);
$("#notify-enabled").addEventListener("change", (event) => { if (event.target.checked && "Notification" in window && Notification.permission === "default") requestNotificationPermission(); });
$("#mail-pref-save").addEventListener("click", saveMailPreferences);
$("#mail-pref-reconcile-labels")?.addEventListener("click", reconcileImapLabels);
$("#diagnostics-refresh").addEventListener("click", loadDiagnostics);
$("#diagnostic-imap-reconnect").addEventListener("click", reconnectImap);
$("#diagnostic-search-rebuild").addEventListener("click", rebuildSearchIndex);
$("#remote-image-load").addEventListener("click", () => { if (state.currentDetail) renderMessageBody(state.currentDetail, true); });
$("#remote-image-trust").addEventListener("click", trustCurrentSender);
$("#reader-add-sender-contact").addEventListener("click", (event) => { event.stopPropagation(); addSenderToContacts(); });
const sieveBuilderFields = [
"#sieve-builder-name", "#sieve-builder-enabled", "#sieve-builder-stop",
"#sieve-builder-condition", "#sieve-builder-match", "#sieve-builder-header", "#sieve-builder-value", "#sieve-builder-negate",
"#sieve-builder-condition-2", "#sieve-builder-match-2", "#sieve-builder-header-2", "#sieve-builder-value-2", "#sieve-builder-negate-2", "#sieve-builder-logic",
"#sieve-builder-condition-3", "#sieve-builder-match-3", "#sieve-builder-header-3", "#sieve-builder-value-3", "#sieve-builder-negate-3", "#sieve-builder-logic-3",
"#sieve-builder-action", "#sieve-builder-target", "#sieve-builder-action-2", "#sieve-builder-target-2",
];
sieveBuilderFields.forEach((selector) => {
const node = $(selector);
if (!node) return;
node.addEventListener("input", updateSieveBuilder);
node.addEventListener("change", updateSieveBuilder);
});
$("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog);
$("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); });
$("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); });
$("#sieve-vacation-start").addEventListener("change", updateSieveBuilder);
$("#sieve-vacation-end").addEventListener("change", updateSieveBuilder);
$("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false));
$("#sieve-builder-replace").addEventListener("click", () => applySieveVisualRule(true));
$("#sieve-builder-clear").addEventListener("click", clearSieveRuleBuilder);
$("#sieve-editor").addEventListener("input", () => {
state.sieveEditingRuleId = null;
renderManagedSieveRules();
updateSieveBuilder();
});
$("#sieve-vacation-append").addEventListener("click", appendSieveVacation);
$("#sieve-backup").addEventListener("click", backupSieveScripts);
$("#sieve-restore").addEventListener("click", () => $("#sieve-restore-file").click());
$("#sieve-restore-file").addEventListener("change", (event) => restoreSieveBackup(event.target.files?.[0]));
$("#sieve-refresh").addEventListener("click", loadSieve);
$("#sieve-new").addEventListener("click", newSieveScript);
$("#sieve-check").addEventListener("click", validateSieve);
$("#sieve-save").addEventListener("click", saveSieve);
$("#sieve-activate").addEventListener("click", activateSieve);
$("#sieve-deactivate").addEventListener("click", deactivateSieve);
$("#sieve-delete").addEventListener("click", deleteSieve);
bindEditor();
document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); });
window.addEventListener("blur", hideContextMenu);
document.addEventListener("scroll", hideContextMenu, true);
document.addEventListener("keydown", (event) => {
const tag = document.activeElement?.tagName || "";
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
event.preventDefault(); $("#search-input").focus();
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); }
if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) {
event.preventDefault();
if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal();
}
const dialogOpen = Boolean(document.querySelector("dialog[open]"));
const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable;
if (keyboardIdle) {
const globalKey = event.key.toLowerCase();
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
}
if (keyboardIdle && state.currentDetail) {
const key = event.key.toLowerCase();
if (key === "x") {
const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder);
if (current) { event.preventDefault(); setMessageSelected(current, !state.selectedMessages.has(messageKey(current))); }
}
else if (key === "j") { event.preventDefault(); navigateMessage(1); }
else if (key === "k") { event.preventDefault(); navigateMessage(-1); }
else if (key === "r") { event.preventDefault(); openCompose("reply"); }
else if (key === "a") { event.preventDefault(); openCompose("reply-all"); }
else if (key === "f") { event.preventDefault(); openCompose("forward"); }
else if (key === "e") { event.preventDefault(); archiveCurrent(); }
else if (key === "m") { event.preventDefault(); toggleUnread(); }
else if (key === "s") { event.preventDefault(); toggleFlagged(); }
else if (event.key === "!") { event.preventDefault(); junkCurrent(); }
else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); }
}
if (event.key === "Escape" && innerWidth <= 820) {
if ($("#folder-pane")?.classList.contains("open")) setFolderDrawerOpen(false);
else closeReader();
}
});
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
}
async function boot() {
applyPreferences();
bindEvents();
await loadAuthPolicy();
const authenticated = await loadAccounts();
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
}
boot();
}) ();
+13
View File
@@ -0,0 +1,13 @@
00-bootstrap.js
10-core.js
20-preferences-auth.js
30-folders-tags.js
40-messages-reader.js
50-compose.js
60-mail-actions-search.js
70-contacts.js
71-calendar.js
72-openpgp-identities.js
80-settings-realtime.js
90-sieve.js
95-bindings-boot.js