1 parent
a238ff5e23
commit
93762fe9ef
65 files changed
+5420
-2792
No files matched your search
@@ -22,11 +22,12 @@ pub async fn validate_imap_credentials(
|
||||
host: &str,
|
||||
port: u16,
|
||||
tls: bool,
|
||||
tls_verify: bool,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> AuthResult {
|
||||
if tls {
|
||||
validate_tls(host, port, email, password).await
|
||||
validate_tls(host, port, tls_verify, email, password).await
|
||||
} else {
|
||||
validate_plain(host, port, email, password).await
|
||||
}
|
||||
@@ -34,7 +35,7 @@ pub async fn validate_imap_credentials(
|
||||
|
||||
/// TLS path: connect via `tokio::net::TcpStream`, upgrade with
|
||||
/// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`.
|
||||
async fn validate_tls(host: &str, port: u16, email: &str, password: &str) -> AuthResult {
|
||||
async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, password: &str) -> AuthResult {
|
||||
let addr = format!("{host}:{port}");
|
||||
|
||||
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
|
||||
@@ -42,7 +43,9 @@ async fn validate_tls(host: &str, port: u16, email: &str, password: &str) -> Aut
|
||||
Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
};
|
||||
|
||||
let tls_connector = async_native_tls::TlsConnector::new();
|
||||
let tls_connector = async_native_tls::TlsConnector::new()
|
||||
.danger_accept_invalid_certs(!tls_verify)
|
||||
.danger_accept_invalid_hostnames(!tls_verify);
|
||||
let tls_stream = match tls_connector.connect(host, tcp_stream).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
@@ -120,7 +123,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn unreachable_server_returns_server_unreachable() {
|
||||
let result =
|
||||
validate_imap_credentials("127.0.0.1", 19999, false, "user@test.com", "pass").await;
|
||||
validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass").await;
|
||||
|
||||
assert!(
|
||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||
@@ -132,7 +135,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn unreachable_server_tls_returns_server_unreachable() {
|
||||
let result =
|
||||
validate_imap_credentials("127.0.0.1", 19999, true, "user@test.com", "pass").await;
|
||||
validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass").await;
|
||||
|
||||
assert!(
|
||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||
|
||||
@@ -23,11 +23,13 @@ pub struct AccountSession {
|
||||
pub imap_host: String,
|
||||
pub imap_port: u16,
|
||||
pub imap_tls: bool,
|
||||
pub imap_tls_verify: bool,
|
||||
pub smtp_host: String,
|
||||
#[allow(dead_code)]
|
||||
pub smtp_port: u16,
|
||||
#[allow(dead_code)]
|
||||
pub smtp_tls: bool,
|
||||
pub smtp_tls_verify: bool,
|
||||
pub last_accessed: Instant,
|
||||
pub timeout_override: Option<Duration>,
|
||||
pub created_at_unix: u64,
|
||||
@@ -49,6 +51,7 @@ pub struct SessionStore {
|
||||
account_to_session: Arc<DashMap<AccountId, SessionId>>,
|
||||
account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
|
||||
timeout: Duration,
|
||||
tls_verify_override: Option<bool>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
@@ -60,15 +63,28 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
Self::new_with_tls_verify(timeout, None)
|
||||
}
|
||||
|
||||
pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
browsers: Arc::new(DashMap::new()),
|
||||
account_to_session: Arc::new(DashMap::new()),
|
||||
account_to_browser: Arc::new(DashMap::new()),
|
||||
timeout,
|
||||
tls_verify_override,
|
||||
}
|
||||
}
|
||||
|
||||
fn tls_verify_for_host(&self, host: &str) -> bool {
|
||||
if let Some(value) = self.tls_verify_override {
|
||||
return value;
|
||||
}
|
||||
let normalized = host.trim().trim_matches(|ch| ch == '[' || ch == ']').to_ascii_lowercase();
|
||||
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
|
||||
pub fn generate_token() -> SessionId {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
@@ -126,6 +142,8 @@ impl SessionStore {
|
||||
let account_id = Self::generate_id();
|
||||
|
||||
let now_unix = Self::unix_now();
|
||||
let imap_tls_verify = self.tls_verify_for_host(&imap_host);
|
||||
let smtp_tls_verify = self.tls_verify_for_host(&smtp_host);
|
||||
let session = AccountSession {
|
||||
account_id: account_id.clone(),
|
||||
email,
|
||||
@@ -134,9 +152,11 @@ impl SessionStore {
|
||||
imap_host,
|
||||
imap_port,
|
||||
imap_tls,
|
||||
imap_tls_verify,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
smtp_tls,
|
||||
smtp_tls_verify,
|
||||
last_accessed: Instant::now(),
|
||||
timeout_override,
|
||||
created_at_unix: now_unix,
|
||||
@@ -333,9 +353,11 @@ impl SessionStore {
|
||||
imap_host: String::new(),
|
||||
imap_port: 993,
|
||||
imap_tls: true,
|
||||
imap_tls_verify: true,
|
||||
smtp_host: String::new(),
|
||||
smtp_port: 587,
|
||||
smtp_tls: true,
|
||||
smtp_tls_verify: true,
|
||||
last_accessed: Instant::now(),
|
||||
timeout_override,
|
||||
created_at_unix: now_unix,
|
||||
@@ -505,9 +527,11 @@ mod tests {
|
||||
imap_host: "imap.test.com".to_string(),
|
||||
imap_port: 993,
|
||||
imap_tls: true,
|
||||
imap_tls_verify: true,
|
||||
smtp_host: "smtp.test.com".to_string(),
|
||||
smtp_port: 587,
|
||||
smtp_tls: true,
|
||||
smtp_tls_verify: true,
|
||||
last_accessed: Instant::now(),
|
||||
timeout_override: None,
|
||||
created_at_unix: SessionStore::unix_now(),
|
||||
|
||||
+150
-4
@@ -24,8 +24,8 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
/// IMAP server hostname. Required at startup.
|
||||
#[serde(default)]
|
||||
/// IMAP server hostname. Defaults to localhost when omitted.
|
||||
#[serde(default = "default_imap_host")]
|
||||
pub imap_host: Option<String>,
|
||||
|
||||
/// IMAP server port.
|
||||
@@ -44,6 +44,20 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_tls_enabled")]
|
||||
pub tls_enabled: bool,
|
||||
|
||||
/// Optional certificate/hostname verification override for IMAP and SMTP.
|
||||
/// When omitted, verification is disabled only for localhost/loopback endpoints
|
||||
/// and enabled for remote hosts.
|
||||
#[serde(default)]
|
||||
pub tls_verify: Option<bool>,
|
||||
|
||||
/// Maximum size of one uploaded compose attachment, in MiB.
|
||||
#[serde(default = "default_max_attachment_size_mb")]
|
||||
pub max_attachment_size_mb: usize,
|
||||
|
||||
/// Maximum size of the complete RFC822/MIME message submitted to the MTA, in MiB.
|
||||
#[serde(default = "default_max_message_size_mb")]
|
||||
pub max_message_size_mb: usize,
|
||||
|
||||
/// Directory for persistent data storage.
|
||||
#[serde(default = "default_data_dir")]
|
||||
pub data_dir: String,
|
||||
@@ -64,6 +78,16 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_environment")]
|
||||
pub environment: String,
|
||||
|
||||
/// Server-wide light/dark theme used before authentication (including the login screen).
|
||||
/// Authenticated per-user display preferences still take precedence.
|
||||
#[serde(default = "default_theme")]
|
||||
pub default_theme: String,
|
||||
|
||||
/// Server-wide interface style used on the login screen and by users whose
|
||||
/// interface style is set to `default`. Examples: `gmail`, `proton`, `zoho`.
|
||||
#[serde(default = "default_interface_skin")]
|
||||
pub default_interface_skin: String,
|
||||
|
||||
/// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath.
|
||||
#[serde(default)]
|
||||
pub base_path: Option<String>,
|
||||
@@ -158,6 +182,10 @@ fn default_port() -> u16 {
|
||||
3001
|
||||
}
|
||||
|
||||
fn default_imap_host() -> Option<String> {
|
||||
Some("localhost".to_string())
|
||||
}
|
||||
|
||||
fn default_imap_port() -> u16 {
|
||||
993
|
||||
}
|
||||
@@ -170,6 +198,14 @@ fn default_tls_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_max_attachment_size_mb() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_max_message_size_mb() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_data_dir() -> String {
|
||||
"/data".to_string()
|
||||
}
|
||||
@@ -189,6 +225,14 @@ fn default_environment() -> String {
|
||||
"development".to_string()
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"system".to_string()
|
||||
}
|
||||
|
||||
fn default_interface_skin() -> String {
|
||||
"rustbox".to_string()
|
||||
}
|
||||
|
||||
fn default_sieve_port() -> u16 {
|
||||
4190
|
||||
}
|
||||
@@ -219,16 +263,21 @@ impl Default for AppConfig {
|
||||
Self {
|
||||
host: default_host(),
|
||||
port: default_port(),
|
||||
imap_host: None,
|
||||
imap_host: default_imap_host(),
|
||||
imap_port: default_imap_port(),
|
||||
smtp_host: None,
|
||||
smtp_port: default_smtp_port(),
|
||||
tls_enabled: default_tls_enabled(),
|
||||
tls_verify: None,
|
||||
max_attachment_size_mb: default_max_attachment_size_mb(),
|
||||
max_message_size_mb: default_max_message_size_mb(),
|
||||
data_dir: default_data_dir(),
|
||||
session_timeout_hours: default_session_timeout_hours(),
|
||||
remember_me_enabled: default_remember_me_enabled(),
|
||||
remember_me_days: default_remember_me_days(),
|
||||
environment: default_environment(),
|
||||
default_theme: default_theme(),
|
||||
default_interface_skin: default_interface_skin(),
|
||||
base_path: None,
|
||||
sieve_host: None,
|
||||
sieve_port: default_sieve_port(),
|
||||
@@ -261,6 +310,27 @@ impl AppConfig {
|
||||
.unwrap_or("/")
|
||||
}
|
||||
|
||||
/// Certificate verification policy for a configured mail host. Explicit
|
||||
/// `tls_verify` wins. Without it, only localhost/loopback skips validation.
|
||||
pub fn tls_verify_for_host(&self, host: &str) -> bool {
|
||||
if let Some(value) = self.tls_verify {
|
||||
return value;
|
||||
}
|
||||
let normalized = host
|
||||
.trim()
|
||||
.trim_matches(|ch| ch == '[' || ch == ']')
|
||||
.to_ascii_lowercase();
|
||||
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
|
||||
pub fn max_attachment_size_bytes(&self) -> usize {
|
||||
self.max_attachment_size_mb.saturating_mul(1024 * 1024)
|
||||
}
|
||||
|
||||
pub fn max_message_size_bytes(&self) -> usize {
|
||||
self.max_message_size_mb.saturating_mul(1024 * 1024)
|
||||
}
|
||||
|
||||
/// Parse the `trusted_proxies` field into IP or CIDR entries.
|
||||
/// Logs warnings for unparseable entries.
|
||||
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
|
||||
@@ -370,12 +440,46 @@ impl AppConfig {
|
||||
if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 {
|
||||
return Err("network ports must be greater than zero".to_string());
|
||||
}
|
||||
if self.max_attachment_size_mb == 0 || self.max_attachment_size_mb > 512 {
|
||||
return Err("MAX_ATTACHMENT_SIZE_MB must be between 1 and 512".to_string());
|
||||
}
|
||||
if self.max_message_size_mb == 0 || self.max_message_size_mb > 1024 {
|
||||
return Err("MAX_MESSAGE_SIZE_MB must be between 1 and 1024".to_string());
|
||||
}
|
||||
if self.max_attachment_size_mb > self.max_message_size_mb {
|
||||
return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string());
|
||||
}
|
||||
if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 {
|
||||
return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string());
|
||||
}
|
||||
if self.remember_me_days == 0 || self.remember_me_days > 365 {
|
||||
return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string());
|
||||
}
|
||||
if !matches!(self.default_theme.as_str(), "system" | "dark" | "light") {
|
||||
return Err("DEFAULT_THEME must be one of: system, dark, light".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
self.default_interface_skin.as_str(),
|
||||
"rustbox"
|
||||
| "roundcube-classic"
|
||||
| "roundcube-larry"
|
||||
| "roundcube-elastic"
|
||||
| "clean"
|
||||
| "gmail"
|
||||
| "proton"
|
||||
| "zoho"
|
||||
| "outlook"
|
||||
| "minimal"
|
||||
| "graphite"
|
||||
| "high-contrast"
|
||||
| "fastmail"
|
||||
| "apple-mail"
|
||||
| "yahoo"
|
||||
| "hey"
|
||||
| "nord"
|
||||
) {
|
||||
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
self.sieve_tls_mode.as_str(),
|
||||
"starttls" | "implicit" | "none"
|
||||
@@ -417,16 +521,23 @@ mod tests {
|
||||
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 3001);
|
||||
assert!(config.imap_host.is_none());
|
||||
assert_eq!(config.imap_host.as_deref(), Some("localhost"));
|
||||
assert_eq!(config.imap_port, 993);
|
||||
assert!(config.smtp_host.is_none());
|
||||
assert_eq!(config.smtp_port, 587);
|
||||
assert!(config.tls_enabled);
|
||||
assert_eq!(config.tls_verify, None);
|
||||
assert!(!config.tls_verify_for_host("localhost"));
|
||||
assert!(config.tls_verify_for_host("mail.example.com"));
|
||||
assert_eq!(config.max_attachment_size_mb, 10);
|
||||
assert_eq!(config.max_message_size_mb, 50);
|
||||
assert_eq!(config.data_dir, "/data");
|
||||
assert_eq!(config.session_timeout_hours, 24);
|
||||
assert!(config.remember_me_enabled);
|
||||
assert_eq!(config.remember_me_days, 30);
|
||||
assert_eq!(config.environment, "development");
|
||||
assert_eq!(config.default_theme, "system");
|
||||
assert_eq!(config.default_interface_skin, "rustbox");
|
||||
assert!(config.sieve_host.is_none());
|
||||
assert_eq!(config.sieve_port, 4190);
|
||||
assert_eq!(config.sieve_tls_mode, "starttls");
|
||||
@@ -454,11 +565,16 @@ mod tests {
|
||||
.merge(("smtp_host", "smtp.example.com"))
|
||||
.merge(("smtp_port", 465u16))
|
||||
.merge(("tls_enabled", false))
|
||||
.merge(("tls_verify", true))
|
||||
.merge(("max_attachment_size_mb", 40usize))
|
||||
.merge(("max_message_size_mb", 80usize))
|
||||
.merge(("data_dir", "/var/rustbox"))
|
||||
.merge(("session_timeout_hours", 48u64))
|
||||
.merge(("remember_me_enabled", false))
|
||||
.merge(("remember_me_days", 14u64))
|
||||
.merge(("environment", "production"))
|
||||
.merge(("default_theme", "light"))
|
||||
.merge(("default_interface_skin", "gmail"))
|
||||
.merge(("sieve_host", "sieve.example.com"))
|
||||
.merge(("sieve_port", 4191u16))
|
||||
.merge(("sieve_tls_mode", "implicit"))
|
||||
@@ -475,11 +591,16 @@ mod tests {
|
||||
assert_eq!(config.smtp_host.as_deref(), Some("smtp.example.com"));
|
||||
assert_eq!(config.smtp_port, 465);
|
||||
assert!(!config.tls_enabled);
|
||||
assert_eq!(config.tls_verify, Some(true));
|
||||
assert_eq!(config.max_attachment_size_mb, 40);
|
||||
assert_eq!(config.max_message_size_mb, 80);
|
||||
assert_eq!(config.data_dir, "/var/rustbox");
|
||||
assert_eq!(config.session_timeout_hours, 48);
|
||||
assert!(!config.remember_me_enabled);
|
||||
assert_eq!(config.remember_me_days, 14);
|
||||
assert_eq!(config.environment, "production");
|
||||
assert_eq!(config.default_theme, "light");
|
||||
assert_eq!(config.default_interface_skin, "gmail");
|
||||
assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com"));
|
||||
assert_eq!(config.sieve_port, 4191);
|
||||
assert_eq!(config.sieve_tls_mode, "implicit");
|
||||
@@ -511,6 +632,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_default_theme_is_rejected() {
|
||||
let config = AppConfig {
|
||||
imap_host: Some("imap.example.com".to_string()),
|
||||
default_theme: "sepia".to_string(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
assert!(config.validate().unwrap_err().contains("DEFAULT_THEME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_default_interface_skin_is_rejected() {
|
||||
let config = AppConfig {
|
||||
imap_host: Some("imap.example.com".to_string()),
|
||||
default_interface_skin: "hotmail-1999".to_string(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
assert!(
|
||||
config
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.contains("DEFAULT_INTERFACE_SKIN")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_headers_are_parsed_and_structural_headers_rejected() {
|
||||
let config = AppConfig {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct DisplayPreferences {
|
||||
pub forward_mode: String,
|
||||
pub spellcheck_enabled: bool,
|
||||
pub interface_skin: String,
|
||||
pub preset_scale_unlocked: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -111,6 +112,7 @@ pub struct UpdateDisplayPreferences {
|
||||
pub forward_mode: Option<String>,
|
||||
pub spellcheck_enabled: Option<bool>,
|
||||
pub interface_skin: Option<String>,
|
||||
pub preset_scale_unlocked: Option<bool>,
|
||||
}
|
||||
|
||||
fn deserialize_animation_mode_field<'de, D>(
|
||||
@@ -175,14 +177,15 @@ fn defaults() -> DisplayPreferences {
|
||||
quote_style: "standard".into(),
|
||||
forward_mode: "inline".into(),
|
||||
spellcheck_enabled: true,
|
||||
interface_skin: "rustbox".into(),
|
||||
interface_skin: "default".into(),
|
||||
preset_scale_unlocked: false,
|
||||
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, interface_skin, 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, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| Ok(DisplayPreferences {
|
||||
density: row.get(0)?, theme: row.get(1)?, language: row.get(2)?, compose_format: row.get(3)?,
|
||||
@@ -195,7 +198,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, interface_skin: row.get(50)?, updated_at: row.get(51)?,
|
||||
scrollbar_density: row.get(40)?, monochrome: row.get::<_, i32>(41)? != 0, folder_background: row.get(42)?, message_background: row.get(43)?, reader_background: row.get(44)?, compose_font_family: row.get(45)?, reply_position: row.get(46)?, quote_style: row.get(47)?, forward_mode: row.get(48)?, spellcheck_enabled: row.get::<_, i32>(49)? != 0, interface_skin: row.get(50)?, preset_scale_unlocked: row.get::<_, i32>(51)? != 0, updated_at: row.get(52)?,
|
||||
}),
|
||||
);
|
||||
match result {
|
||||
@@ -340,13 +343,24 @@ pub fn update_preferences(
|
||||
"interface_skin",
|
||||
v,
|
||||
&[
|
||||
"default",
|
||||
"rustbox",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"clean",
|
||||
"gmail",
|
||||
"proton",
|
||||
"zoho",
|
||||
"outlook",
|
||||
"minimal",
|
||||
"graphite",
|
||||
"high-contrast",
|
||||
"fastmail",
|
||||
"apple-mail",
|
||||
"yahoo",
|
||||
"hey",
|
||||
"nord",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -479,6 +493,7 @@ pub fn update_preferences(
|
||||
set_text!(forward_mode, "forward_mode");
|
||||
set_bool!(spellcheck_enabled, "spellcheck_enabled");
|
||||
set_text!(interface_skin, "interface_skin");
|
||||
set_bool!(preset_scale_unlocked, "preset_scale_unlocked");
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_preferences(conn);
|
||||
@@ -509,7 +524,7 @@ mod tests {
|
||||
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
|
||||
"theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699",
|
||||
"font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480,
|
||||
"show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic"
|
||||
"show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic", "preset_scale_unlocked":true
|
||||
}))
|
||||
.unwrap();
|
||||
let prefs = update_preferences(&conn, &update).unwrap();
|
||||
@@ -518,18 +533,30 @@ mod tests {
|
||||
assert_eq!(prefs.font_size, 15);
|
||||
assert!(!prefs.show_avatars);
|
||||
assert_eq!(prefs.interface_skin, "roundcube-elastic");
|
||||
assert!(prefs.preset_scale_unlocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_interface_skins_are_accepted() {
|
||||
for skin in [
|
||||
"default",
|
||||
"rustbox",
|
||||
"roundcube-classic",
|
||||
"roundcube-larry",
|
||||
"roundcube-elastic",
|
||||
"clean",
|
||||
"gmail",
|
||||
"proton",
|
||||
"zoho",
|
||||
"outlook",
|
||||
"minimal",
|
||||
"graphite",
|
||||
"high-contrast",
|
||||
"fastmail",
|
||||
"apple-mail",
|
||||
"yahoo",
|
||||
"hey",
|
||||
"nord",
|
||||
] {
|
||||
let conn = open_test_db();
|
||||
let update: UpdateDisplayPreferences =
|
||||
|
||||
@@ -193,6 +193,10 @@ const MIGRATIONS: &[(u32, &str)] = &[
|
||||
45,
|
||||
include_str!("../../migrations/V045__response_template_format.sql"),
|
||||
),
|
||||
(
|
||||
46,
|
||||
include_str!("../../migrations/V046__preset_scale_unlock.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Run any pending migrations based on SQLite's `user_version` PRAGMA.
|
||||
|
||||
+15
-1
@@ -33,6 +33,8 @@ pub enum AppError {
|
||||
Forbidden(String),
|
||||
/// Service unavailable (503).
|
||||
ServiceUnavailable(String),
|
||||
/// Request payload is larger than the configured mail limit (413).
|
||||
PayloadTooLarge(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -45,6 +47,7 @@ impl AppError {
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
AppError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +60,7 @@ impl AppError {
|
||||
AppError::BadRequest(_) => "BAD_REQUEST",
|
||||
AppError::Forbidden(_) => "FORBIDDEN",
|
||||
AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE",
|
||||
AppError::PayloadTooLarge(_) => "PAYLOAD_TOO_LARGE",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +72,8 @@ impl AppError {
|
||||
| AppError::Unauthorized(msg)
|
||||
| AppError::BadRequest(msg)
|
||||
| AppError::Forbidden(msg)
|
||||
| AppError::ServiceUnavailable(msg) => msg,
|
||||
| AppError::ServiceUnavailable(msg)
|
||||
| AppError::PayloadTooLarge(msg) => msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +167,15 @@ mod tests {
|
||||
assert_eq!(json["error"]["status"], 403);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payload_too_large_returns_413() {
|
||||
let (status, json) =
|
||||
error_to_json(AppError::PayloadTooLarge("message too large".into())).await;
|
||||
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert_eq!(json["error"]["code"], "PAYLOAD_TOO_LARGE");
|
||||
assert_eq!(json["error"]["status"], 413);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_unavailable_returns_503() {
|
||||
let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await;
|
||||
|
||||
@@ -43,7 +43,9 @@ pub(crate) async fn connect(
|
||||
.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
if creds.tls {
|
||||
let tls = async_native_tls::TlsConnector::new();
|
||||
let tls = async_native_tls::TlsConnector::new()
|
||||
.danger_accept_invalid_certs(!creds.tls_verify)
|
||||
.danger_accept_invalid_hostnames(!creds.tls_verify);
|
||||
let tls_stream = tls
|
||||
.connect(&creds.host, tcp)
|
||||
.await
|
||||
|
||||
@@ -452,6 +452,7 @@ mod tests {
|
||||
host: "imap.example.com".to_string(),
|
||||
port: 993,
|
||||
tls: true,
|
||||
tls_verify: true,
|
||||
email: "user@example.com".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
}
|
||||
@@ -785,6 +786,7 @@ mod tests {
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
tls_verify: true,
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
|
||||
creds.host.as_bytes(),
|
||||
&creds.port.to_be_bytes(),
|
||||
&[u8::from(creds.tls)],
|
||||
&[u8::from(creds.tls_verify)],
|
||||
creds.email.as_bytes(),
|
||||
creds.password.as_bytes(),
|
||||
] {
|
||||
@@ -264,6 +265,7 @@ mod tests {
|
||||
host: "127.0.0.1".into(),
|
||||
port: listener.local_addr().unwrap().port(),
|
||||
tls: false,
|
||||
tls_verify: false,
|
||||
email: "test@example.com".into(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
@@ -450,6 +452,7 @@ mod tests {
|
||||
host: std::env::var("TEST_IMAP_HOST").expect("TEST_IMAP_HOST required"),
|
||||
port: 993,
|
||||
tls: true,
|
||||
tls_verify: true,
|
||||
email: std::env::var("TEST_IMAP_EMAIL").expect("TEST_IMAP_EMAIL required"),
|
||||
password: std::env::var("TEST_IMAP_PASSWORD").expect("TEST_IMAP_PASSWORD required"),
|
||||
};
|
||||
|
||||
@@ -88,6 +88,7 @@ pub struct ImapCredentials {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub tls: bool,
|
||||
pub tls_verify: bool,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
+4
-3
@@ -54,9 +54,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = Arc::new(loaded_config);
|
||||
|
||||
// Create the in-memory session store with the configured timeout.
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(
|
||||
config.session_timeout_hours * 3600,
|
||||
)));
|
||||
let store = Arc::new(SessionStore::new_with_tls_verify(
|
||||
Duration::from_secs(config.session_timeout_hours * 3600),
|
||||
config.tls_verify,
|
||||
));
|
||||
|
||||
// Spawn a background task that periodically purges expired sessions.
|
||||
{
|
||||
|
||||
@@ -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.2\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.3\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
|
||||
|
||||
@@ -927,6 +927,7 @@ mod tests {
|
||||
host: "localhost".into(),
|
||||
port: 143,
|
||||
tls: false,
|
||||
tls_verify: false,
|
||||
email: "test@example.com".into(),
|
||||
password: "test".into(),
|
||||
};
|
||||
|
||||
@@ -116,6 +116,7 @@ async fn handle_socket_multi_account(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
@@ -14,8 +14,6 @@ use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Maximum attachment file size: 25 MB.
|
||||
const MAX_ATTACHMENT_SIZE: usize = 25 * 1024 * 1024;
|
||||
const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
async fn cleanup_stale_upload_parts(dir: &Path) {
|
||||
@@ -92,6 +90,7 @@ pub async fn upload_attachment(
|
||||
cleanup_stale_upload_parts(&att_dir).await;
|
||||
|
||||
let mut uploaded = Vec::new();
|
||||
let max_attachment_size = config.max_attachment_size_bytes();
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
@@ -124,12 +123,12 @@ pub async fn upload_attachment(
|
||||
};
|
||||
let Some(chunk) = chunk else { break };
|
||||
size = size.saturating_add(chunk.len());
|
||||
if size > MAX_ATTACHMENT_SIZE {
|
||||
if size > max_attachment_size {
|
||||
drop(file);
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"File '{}' exceeds maximum size of 25 MB",
|
||||
filename
|
||||
"File '{}' exceeds maximum size of {} MiB",
|
||||
filename, config.max_attachment_size_mb
|
||||
)));
|
||||
}
|
||||
if let Err(e) = file.write_all(&chunk).await {
|
||||
|
||||
@@ -197,6 +197,7 @@ pub async fn login(
|
||||
|
||||
let imap_port = config.imap_port;
|
||||
let imap_tls = config.tls_enabled;
|
||||
let imap_tls_verify = config.tls_verify_for_host(&imap_host);
|
||||
let smtp_port = config.smtp_port;
|
||||
let smtp_tls = config.tls_enabled;
|
||||
|
||||
@@ -204,6 +205,7 @@ pub async fn login(
|
||||
&imap_host,
|
||||
imap_port,
|
||||
imap_tls,
|
||||
imap_tls_verify,
|
||||
&body.email,
|
||||
&body.password,
|
||||
)
|
||||
|
||||
@@ -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.2//EN\r\nCALSCALE:GREGORIAN\r\n",
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.3//EN\r\nCALSCALE:GREGORIAN\r\n",
|
||||
);
|
||||
for event in &events {
|
||||
append_ics_event(&mut body, event);
|
||||
|
||||
@@ -82,6 +82,7 @@ pub async fn imap_diagnostics(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
@@ -139,6 +140,7 @@ pub async fn smtp_diagnostics(
|
||||
host: session.smtp_host.clone(),
|
||||
port: session.smtp_port,
|
||||
tls: session.smtp_tls,
|
||||
tls_verify: session.smtp_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
@@ -169,6 +171,7 @@ pub async fn reconnect_imap(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
@@ -89,9 +89,11 @@ mod tests {
|
||||
imap_host: "imap.example.com".to_string(),
|
||||
imap_port: 993,
|
||||
imap_tls: true,
|
||||
imap_tls_verify: true,
|
||||
smtp_host: "smtp.example.com".to_string(),
|
||||
smtp_port: 587,
|
||||
smtp_tls: true,
|
||||
smtp_tls_verify: true,
|
||||
last_accessed: Instant::now(),
|
||||
timeout_override: None,
|
||||
created_at_unix: 0,
|
||||
|
||||
@@ -245,6 +245,7 @@ fn session_imap_credentials(session: &SessionState) -> ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ pub async fn list_folders(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
})
|
||||
@@ -1490,6 +1491,7 @@ mod sync_tests {
|
||||
host: "localhost".into(),
|
||||
port: 143,
|
||||
tls: false,
|
||||
tls_verify: false,
|
||||
email: "test@example.com".into(),
|
||||
password: "test".into(),
|
||||
};
|
||||
@@ -1506,9 +1508,11 @@ mod sync_tests {
|
||||
imap_host: creds.host.clone(),
|
||||
imap_port: creds.port,
|
||||
imap_tls: false,
|
||||
imap_tls_verify: false,
|
||||
smtp_host: "localhost".into(),
|
||||
smtp_port: 587,
|
||||
smtp_tls: false,
|
||||
smtp_tls_verify: false,
|
||||
last_accessed: std::time::Instant::now(),
|
||||
timeout_override: None,
|
||||
created_at_unix: 0,
|
||||
@@ -1668,6 +1672,7 @@ mod sync_tests {
|
||||
host: "localhost".into(),
|
||||
port: 143,
|
||||
tls: false,
|
||||
tls_verify: false,
|
||||
email: "test@example.com".into(),
|
||||
password: "test".into(),
|
||||
};
|
||||
@@ -1684,9 +1689,11 @@ mod sync_tests {
|
||||
imap_host: creds.host.clone(),
|
||||
imap_port: 143,
|
||||
imap_tls: false,
|
||||
imap_tls_verify: false,
|
||||
smtp_host: "localhost".into(),
|
||||
smtp_port: 587,
|
||||
smtp_tls: false,
|
||||
smtp_tls_verify: false,
|
||||
last_accessed: std::time::Instant::now(),
|
||||
timeout_override: None,
|
||||
created_at_unix: 0,
|
||||
@@ -1886,6 +1893,7 @@ mod sync_tests {
|
||||
host: "localhost".into(),
|
||||
port: 143,
|
||||
tls: false,
|
||||
tls_verify: false,
|
||||
email: "test@example.com".into(),
|
||||
password: "test".into(),
|
||||
},
|
||||
@@ -1955,6 +1963,7 @@ mod sync_tests {
|
||||
host: "localhost".into(),
|
||||
port: 143,
|
||||
tls: false,
|
||||
tls_verify: false,
|
||||
email: "test@example.com".into(),
|
||||
password: "test".into(),
|
||||
},
|
||||
|
||||
+3
-1
@@ -182,6 +182,8 @@ pub fn create_router(
|
||||
.merge(protected_auth);
|
||||
|
||||
// Protected data routes (auth_guard + CSRF).
|
||||
// Multipart framing needs a small allowance on top of the configured per-file limit.
|
||||
let attachment_body_limit = config.max_attachment_size_bytes().saturating_add(1024 * 1024);
|
||||
let protected_data = Router::new()
|
||||
.route(
|
||||
"/folders",
|
||||
@@ -238,7 +240,7 @@ pub fn create_router(
|
||||
)
|
||||
.route(
|
||||
"/drafts/{draft_id}/attachments",
|
||||
post(attachments::upload_attachment).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
|
||||
post(attachments::upload_attachment).layer(DefaultBodyLimit::max(attachment_body_limit)),
|
||||
)
|
||||
.route(
|
||||
"/drafts/{draft_id}/attachments/{attachment_id}",
|
||||
|
||||
@@ -146,6 +146,7 @@ pub async fn process_message(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ pub async fn get_quota(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
+13
-1
@@ -128,6 +128,7 @@ pub async fn send_message_handler(
|
||||
host: session.smtp_host.clone(),
|
||||
port: session.smtp_port,
|
||||
tls: session.smtp_tls,
|
||||
tls_verify: session.smtp_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
@@ -302,6 +303,7 @@ pub async fn send_message_handler(
|
||||
.map_err(AppError::InternalError)?,
|
||||
attachments,
|
||||
pgp,
|
||||
max_message_size_bytes: config.max_message_size_bytes(),
|
||||
};
|
||||
|
||||
// Send via SMTP. The exact RFC822 bytes returned here are also appended
|
||||
@@ -309,7 +311,16 @@ pub async fn send_message_handler(
|
||||
let sent = smtp_client
|
||||
.send_message(&smtp_creds, &message)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("Failed to send email: {e}")))?;
|
||||
.map_err(|e| match e {
|
||||
crate::smtp::client::SmtpError::MessageTooLarge { actual, limit } => {
|
||||
AppError::PayloadTooLarge(format!(
|
||||
"Message is {:.2} MiB after MIME encoding; configured limit is {:.2} MiB",
|
||||
actual as f64 / 1024.0 / 1024.0,
|
||||
limit as f64 / 1024.0 / 1024.0
|
||||
))
|
||||
}
|
||||
other => AppError::ServiceUnavailable(format!("Failed to send email: {other}")),
|
||||
})?;
|
||||
let message_id = sent.message_id.clone();
|
||||
|
||||
// Best-effort: append a copy to the "Sent" folder via the account's IMAP server.
|
||||
@@ -318,6 +329,7 @@ pub async fn send_message_handler(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
@@ -110,6 +110,7 @@ fn imap_credentials(session: &SessionState) -> ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ async fn load_raw_headers(
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
@@ -229,7 +230,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.2\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.3\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
@@ -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.2 size limit"),
|
||||
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.3 size limit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-43
@@ -43,6 +43,34 @@ pub trait SmtpClient: Send + Sync {
|
||||
/// performs the operation, and disconnects.
|
||||
pub struct RealSmtpClient;
|
||||
|
||||
fn smtp_transport(
|
||||
creds: &SmtpCredentials,
|
||||
) -> Result<lettre::AsyncSmtpTransport<lettre::Tokio1Executor>, SmtpError> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{AsyncSmtpTransport, Tokio1Executor};
|
||||
|
||||
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds);
|
||||
if creds.tls {
|
||||
let parameters = TlsParameters::builder(creds.host.clone())
|
||||
.dangerous_accept_invalid_certs(!creds.tls_verify)
|
||||
.dangerous_accept_invalid_hostnames(!creds.tls_verify)
|
||||
.build()
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?;
|
||||
builder = builder.tls(if creds.port == 587 {
|
||||
Tls::Required(parameters)
|
||||
} else {
|
||||
Tls::Wrapper(parameters)
|
||||
});
|
||||
} else {
|
||||
builder = builder.tls(Tls::None);
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SmtpClient for RealSmtpClient {
|
||||
async fn send_message(
|
||||
@@ -50,8 +78,7 @@ impl SmtpClient for RealSmtpClient {
|
||||
creds: &SmtpCredentials,
|
||||
message: &SendableMessage,
|
||||
) -> Result<SmtpSendResult, SmtpError> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use lettre::AsyncTransport;
|
||||
|
||||
let message_id = format!(
|
||||
"<{}.{}@{}>",
|
||||
@@ -62,26 +89,14 @@ impl SmtpClient for RealSmtpClient {
|
||||
let email = crate::smtp::builder::build_message(message, &message_id)
|
||||
.map_err(SmtpError::SendFailed)?;
|
||||
let rfc822 = email.formatted();
|
||||
if message.max_message_size_bytes > 0 && rfc822.len() > message.max_message_size_bytes {
|
||||
return Err(SmtpError::MessageTooLarge {
|
||||
actual: rfc822.len(),
|
||||
limit: message.max_message_size_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
|
||||
let transport: AsyncSmtpTransport<Tokio1Executor> = if creds.tls && creds.port == 587 {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else if creds.tls {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
};
|
||||
let transport = smtp_transport(creds)?;
|
||||
|
||||
transport.send(email).await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
@@ -107,28 +122,7 @@ impl SmtpClient for RealSmtpClient {
|
||||
}
|
||||
|
||||
async fn check_connection(&self, creds: &SmtpCredentials) -> Result<(), SmtpError> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, Tokio1Executor};
|
||||
|
||||
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
|
||||
let transport: AsyncSmtpTransport<Tokio1Executor> = if creds.tls && creds.port == 587 {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else if creds.tls {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
};
|
||||
let transport = smtp_transport(creds)?;
|
||||
|
||||
let accepted = transport.test_connection().await.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
|
||||
@@ -12,6 +12,8 @@ pub enum SmtpError {
|
||||
AuthenticationFailed,
|
||||
/// The message could not be sent.
|
||||
SendFailed(String),
|
||||
/// The fully rendered RFC822/MIME payload exceeds the configured MTA limit.
|
||||
MessageTooLarge { actual: usize, limit: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for SmtpError {
|
||||
@@ -20,6 +22,10 @@ impl fmt::Display for SmtpError {
|
||||
SmtpError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"),
|
||||
SmtpError::AuthenticationFailed => write!(f, "Authentication failed"),
|
||||
SmtpError::SendFailed(msg) => write!(f, "Send failed: {msg}"),
|
||||
SmtpError::MessageTooLarge { actual, limit } => write!(
|
||||
f,
|
||||
"Message is too large after MIME encoding ({actual} bytes; limit {limit} bytes)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -55,6 +55,10 @@ fn clone_error(err: &SmtpError) -> SmtpError {
|
||||
SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()),
|
||||
SmtpError::AuthenticationFailed => SmtpError::AuthenticationFailed,
|
||||
SmtpError::SendFailed(msg) => SmtpError::SendFailed(msg.clone()),
|
||||
SmtpError::MessageTooLarge { actual, limit } => SmtpError::MessageTooLarge {
|
||||
actual: *actual,
|
||||
limit: *limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +72,16 @@ impl SmtpClient for MockSmtpClient {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
self.sent_messages.lock().unwrap().push(message.clone());
|
||||
let message_id = format!("<mock-{}>", uuid::Uuid::new_v4());
|
||||
let rfc822 = crate::smtp::builder::build_rfc822(message, &message_id)
|
||||
.map_err(SmtpError::SendFailed)?;
|
||||
if message.max_message_size_bytes > 0 && rfc822.len() > message.max_message_size_bytes {
|
||||
return Err(SmtpError::MessageTooLarge {
|
||||
actual: rfc822.len(),
|
||||
limit: message.max_message_size_bytes,
|
||||
});
|
||||
}
|
||||
self.sent_messages.lock().unwrap().push(message.clone());
|
||||
Ok(SmtpSendResult { message_id, rfc822 })
|
||||
}
|
||||
}
|
||||
@@ -90,6 +100,7 @@ mod tests {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
tls: true,
|
||||
tls_verify: true,
|
||||
email: "user@example.com".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
}
|
||||
@@ -114,6 +125,7 @@ mod tests {
|
||||
extra_headers: vec![],
|
||||
attachments: vec![],
|
||||
pgp: None,
|
||||
max_message_size_bytes: 50 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +139,16 @@ mod tests {
|
||||
assert!(!result.rfc822.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_rejects_message_larger_than_final_mime_limit() {
|
||||
let mock = MockSmtpClient::new();
|
||||
let mut msg = test_message();
|
||||
msg.max_message_size_bytes = 1;
|
||||
let err = mock.send_message(&test_creds(), &msg).await.unwrap_err();
|
||||
assert!(matches!(err, SmtpError::MessageTooLarge { .. }));
|
||||
assert_eq!(mock.sent_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_send_captures_message() {
|
||||
let mock = MockSmtpClient::new();
|
||||
@@ -152,6 +174,7 @@ mod tests {
|
||||
content_id: None,
|
||||
}],
|
||||
pgp: None,
|
||||
max_message_size_bytes: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
let result = mock.send_message(&test_creds(), &msg).await;
|
||||
@@ -205,6 +228,7 @@ mod tests {
|
||||
host: "invalid.host.test".to_string(),
|
||||
port: 587,
|
||||
tls: true,
|
||||
tls_verify: true,
|
||||
email: "user@invalid.host.test".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct SmtpCredentials {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub tls: bool,
|
||||
pub tls_verify: bool,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
@@ -52,6 +53,8 @@ pub struct SendableMessage {
|
||||
pub attachments: Vec<AttachmentData>,
|
||||
/// Optional OpenPGP transformation applied to the MIME entity.
|
||||
pub pgp: Option<PgpSendConfig>,
|
||||
/// Hard limit for the final RFC822/MIME payload submitted to SMTP.
|
||||
pub max_message_size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -95,6 +95,12 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
||||
.replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix)
|
||||
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
|
||||
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
|
||||
.replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)
|
||||
.replace("__RUSTBOX_DEFAULT_INTERFACE_SKIN__", &config.default_interface_skin)
|
||||
.replace("__RUSTBOX_MAX_ATTACHMENT_BYTES__", &config.max_attachment_size_bytes().to_string())
|
||||
.replace("__RUSTBOX_MAX_ATTACHMENT_MB__", &config.max_attachment_size_mb.to_string())
|
||||
.replace("__RUSTBOX_MAX_MESSAGE_BYTES__", &config.max_message_size_bytes().to_string())
|
||||
.replace("__RUSTBOX_MAX_MESSAGE_MB__", &config.max_message_size_mb.to_string())
|
||||
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
|
||||
(
|
||||
StatusCode::OK,
|
||||
|
||||
+157
-28
@@ -37,6 +37,7 @@
|
||||
--field-pad-x: 8px;
|
||||
--field-pad-y: 5px;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f2f4f1;
|
||||
@@ -61,32 +62,137 @@ html[data-theme="light"] {
|
||||
--shadow: rgba(31, 39, 33, .16);
|
||||
}
|
||||
|
||||
/* User personalization */
|
||||
:root {
|
||||
--folder-pane-width: 228px;
|
||||
--message-pane-width: 420px;
|
||||
}
|
||||
html[data-color-preset="blue"] { --accent: #6f96c9; --accent-strong: #86a9d5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
|
||||
html[data-color-preset="slate"] { --accent: #8e9aa8; --accent-strong: #aab4bf; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
|
||||
html[data-color-preset="amber"] { --accent: #c69a55; --accent-strong: #d7ad6b; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
|
||||
html[data-color-preset="violet"] { --accent: #9b88c9; --accent-strong: #ad9bd5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
|
||||
html[data-color-preset="rose"] { --accent: #c98591; --accent-strong: #d79aa5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
|
||||
html[data-color-preset="custom"] { --bg: var(--user-bg, #121513); --panel: var(--user-panel, #181c19); --panel-2: color-mix(in srgb, var(--panel) 88%, var(--text)); --panel-3: color-mix(in srgb, var(--panel) 80%, var(--text)); --sidebar: color-mix(in srgb, var(--bg) 88%, black); --text: var(--user-text, #edf0ec); --muted: color-mix(in srgb, var(--text) 66%, var(--panel)); --subtle: color-mix(in srgb, var(--text) 52%, var(--panel)); --field: color-mix(in srgb, var(--bg) 82%, var(--panel)); --line: color-mix(in srgb, var(--text) 18%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 30%, var(--panel)); --hover: color-mix(in srgb, var(--text) 8%, var(--panel)); --accent: var(--user-accent, #6f9c86); --accent-strong: color-mix(in srgb, var(--accent) 82%, white); --selected: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
||||
html[data-font-family="inter"] { --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||
html[data-font-family="humanist"] { --font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif; }
|
||||
html[data-font-family="mono"] { --font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }
|
||||
html[data-contrast="soft"] { --line: color-mix(in srgb, var(--line) 70%, transparent); --line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent); }
|
||||
html[data-contrast="high"] { --line: color-mix(in srgb, var(--text) 28%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 48%, var(--panel)); --muted: color-mix(in srgb, var(--text) 74%, var(--panel)); }
|
||||
html[data-hide-avatars="true"] .avatar { display: none !important; }
|
||||
html[data-hide-attachments="true"] .message-attachment, html[data-hide-attachments="true"] .attachment-indicator { display: none !important; }
|
||||
html[data-hide-flags="true"] .message-flag, html[data-hide-flags="true"] .flag-indicator { display: none !important; }
|
||||
html[data-reduce-motion="true"] * { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||
html[data-color-preset="teal"] { --accent:#4f9d93; --accent-strong:#68b0a5; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); }
|
||||
html[data-color-preset="cyan"] { --accent:#4c9fba; --accent-strong:#68b4cb; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); }
|
||||
html[data-color-preset="indigo"] { --accent:#7184c5; --accent-strong:#8b9bd2; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); }
|
||||
html[data-color-preset="graphite"] { --accent:#9aa19c; --accent-strong:#b0b6b2; --selected:color-mix(in srgb,var(--accent) 18%,var(--panel)); }
|
||||
html[data-color-preset="orange"] { --accent:#c9824e; --accent-strong:#d69a6d; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); }
|
||||
html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); }
|
||||
|
||||
html[data-color-preset="blue"] {
|
||||
--accent: #6f96c9;
|
||||
--accent-strong: #86a9d5;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="slate"] {
|
||||
--accent: #8e9aa8;
|
||||
--accent-strong: #aab4bf;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="amber"] {
|
||||
--accent: #c69a55;
|
||||
--accent-strong: #d7ad6b;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="violet"] {
|
||||
--accent: #9b88c9;
|
||||
--accent-strong: #ad9bd5;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="rose"] {
|
||||
--accent: #c98591;
|
||||
--accent-strong: #d79aa5;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="custom"] {
|
||||
--bg: var(--user-bg, #121513);
|
||||
--panel: var(--user-panel, #181c19);
|
||||
--panel-2: color-mix(in srgb, var(--panel) 88%, var(--text));
|
||||
--panel-3: color-mix(in srgb, var(--panel) 80%, var(--text));
|
||||
--sidebar: color-mix(in srgb, var(--bg) 88%, black);
|
||||
--text: var(--user-text, #edf0ec);
|
||||
--muted: color-mix(in srgb, var(--text) 66%, var(--panel));
|
||||
--subtle: color-mix(in srgb, var(--text) 52%, var(--panel));
|
||||
--field: color-mix(in srgb, var(--bg) 82%, var(--panel));
|
||||
--line: color-mix(in srgb, var(--text) 18%, var(--panel));
|
||||
--line-strong: color-mix(in srgb, var(--text) 30%, var(--panel));
|
||||
--hover: color-mix(in srgb, var(--text) 8%, var(--panel));
|
||||
--accent: var(--user-accent, #6f9c86);
|
||||
--accent-strong: color-mix(in srgb, var(--accent) 82%, white);
|
||||
--selected: color-mix(in srgb, var(--accent) 22%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-font-family="inter"] {
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[data-font-family="humanist"] {
|
||||
--font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[data-font-family="mono"] {
|
||||
--font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
html[data-contrast="soft"] {
|
||||
--line: color-mix(in srgb, var(--line) 70%, transparent);
|
||||
--line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent);
|
||||
}
|
||||
|
||||
html[data-contrast="high"] {
|
||||
--line: color-mix(in srgb, var(--text) 28%, var(--panel));
|
||||
--line-strong: color-mix(in srgb, var(--text) 48%, var(--panel));
|
||||
--muted: color-mix(in srgb, var(--text) 74%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-hide-avatars="true"] .avatar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-hide-attachments="true"] .message-attachment,
|
||||
html[data-hide-attachments="true"] .attachment-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-hide-flags="true"] .message-flag,
|
||||
html[data-hide-flags="true"] .flag-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-reduce-motion="true"] * {
|
||||
scroll-behavior: auto !important;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
html[data-color-preset="teal"] {
|
||||
--accent: #4f9d93;
|
||||
--accent-strong: #68b0a5;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="cyan"] {
|
||||
--accent: #4c9fba;
|
||||
--accent-strong: #68b4cb;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="indigo"] {
|
||||
--accent: #7184c5;
|
||||
--accent-strong: #8b9bd2;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="graphite"] {
|
||||
--accent: #9aa19c;
|
||||
--accent-strong: #b0b6b2;
|
||||
--selected: color-mix(in srgb, var(--accent) 18%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="orange"] {
|
||||
--accent: #c9824e;
|
||||
--accent-strong: #d69a6d;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
html[data-color-preset="red"] {
|
||||
--accent: #bd6662;
|
||||
--accent-strong: #cf7e7a;
|
||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
}
|
||||
|
||||
/* Advanced per-user interface controls. */
|
||||
:root {
|
||||
@@ -99,11 +205,32 @@ html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --sel
|
||||
--message-bg: var(--panel);
|
||||
--reader-bg: var(--panel);
|
||||
}
|
||||
html[data-monochrome="true"] { --accent:#929a95; --accent-strong:#aeb4b0; --danger:#9f7775; --warning:#9b8e75; --success:#879482; }
|
||||
html[data-scrollbar-density="normal"] * { scrollbar-width:auto; }
|
||||
html[data-scrollbar-density="thin"] * { scrollbar-width:thin; }
|
||||
html[data-scrollbar-density="hidden"] * { scrollbar-width:none; }
|
||||
html[data-scrollbar-density="hidden"] *::-webkit-scrollbar { width:0; height:0; }
|
||||
|
||||
html[data-monochrome="true"] {
|
||||
--accent: #929a95;
|
||||
--accent-strong: #aeb4b0;
|
||||
--danger: #9f7775;
|
||||
--warning: #9b8e75;
|
||||
--success: #879482;
|
||||
}
|
||||
|
||||
html[data-scrollbar-density="normal"] * {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
html[data-scrollbar-density="thin"] * {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
html[data-scrollbar-density="hidden"] * {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
html[data-scrollbar-density="hidden"] *::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
html[data-show-borders="false"] .folder-pane,
|
||||
html[data-show-borders="false"] .message-pane,
|
||||
html[data-show-borders="false"] .pane-toolbar,
|
||||
@@ -111,4 +238,6 @@ html[data-show-borders="false"] .reader-toolbar,
|
||||
html[data-show-borders="false"] .message-header,
|
||||
html[data-show-borders="false"] .thread-section,
|
||||
html[data-show-borders="false"] .attachment-list,
|
||||
html[data-show-borders="false"] .message-row { border-color:transparent !important; }
|
||||
html[data-show-borders="false"] .message-row {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
@@ -1130,4 +1130,67 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
|
||||
height: 100%;
|
||||
max-height: 72dvh;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.toolbar-menu {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbar-menu>summary {
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-menu>summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar-menu>summary::after {
|
||||
content: "▾";
|
||||
margin-left: 3px;
|
||||
color: var(--subtle);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.toolbar-menu[open]>summary {
|
||||
border-color: var(--accent);
|
||||
background: var(--selected);
|
||||
}
|
||||
|
||||
.toolbar-menu-popover {
|
||||
position: fixed;
|
||||
z-index: 160;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: 164px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 10px 28px var(--shadow);
|
||||
}
|
||||
|
||||
.toolbar-menu-popover button {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-menu-popover button:hover,
|
||||
.toolbar-menu-popover button:focus-visible {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
#message-transfer-menu .toolbar-menu-popover {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
}
|
||||
|
||||
.compose-editor:empty::before {
|
||||
content: "Write a message";
|
||||
content: attr(data-placeholder);
|
||||
color: var(--subtle);
|
||||
}
|
||||
|
||||
@@ -295,11 +295,54 @@
|
||||
}
|
||||
|
||||
.compose-mode-control {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.compose-mode-control select {
|
||||
min-width: 108px;
|
||||
width: auto;
|
||||
min-width: 122px;
|
||||
}
|
||||
|
||||
.compose-footer {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compose-submit-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.compose-footer #compose-status {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.compose-message-size {
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.compose-message-size.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.compose-message-size.over-limit {
|
||||
color: var(--danger);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.compose-dialog.compose-fullscreen .compose-mode-control {
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
.compose-editor.plain-mode {
|
||||
@@ -386,8 +429,6 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* Inline image editing */
|
||||
.compose-editor img[data-rustbox-cid] {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
@@ -673,7 +714,6 @@
|
||||
}
|
||||
|
||||
|
||||
/* Signature/footer editor */
|
||||
.signature-editor-shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-strong);
|
||||
@@ -800,6 +840,7 @@
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-response-control {
|
||||
min-width: 118px;
|
||||
max-width: 190px;
|
||||
@@ -809,4 +850,4 @@
|
||||
min-height: 28px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1165,7 +1165,6 @@
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Diagnostics and mailbox behavior */
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -1363,7 +1362,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Calendar views */
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1521,7 +1519,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 0.0.1 UI reliability pass: viewport-safe dialogs, toasts and calendar layout */
|
||||
.dialog {
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100dvh - 16px);
|
||||
@@ -1539,9 +1536,6 @@
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
/* Grid dialogs own their internal scroll regions; simple/legacy dialogs fall
|
||||
back to scrolling the complete card so actions can never be trapped below
|
||||
the viewport. */
|
||||
.compose-card,
|
||||
.settings-card,
|
||||
.identity-card,
|
||||
@@ -1830,6 +1824,41 @@
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto 24px;
|
||||
}
|
||||
|
||||
.toast-progress-content {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toast-progress-track {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-3);
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width .12s linear;
|
||||
}
|
||||
|
||||
.toast-progress-value {
|
||||
min-width: 34px;
|
||||
color: var(--muted);
|
||||
font: 10px var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
.contacts-card,
|
||||
@@ -2291,7 +2320,6 @@ kbd {
|
||||
}
|
||||
}
|
||||
|
||||
/* OpenPGP settings */
|
||||
.pgp-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2393,4 +2421,9 @@ kbd {
|
||||
|
||||
.response-template-meta span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.diagnostic-imap-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
/* 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;
|
||||
@@ -360,3 +357,629 @@ html[data-interface-skin="high-contrast"] .button.primary {
|
||||
border-color: #3b4449;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Modern mail-inspired styles ---------- */
|
||||
html[data-interface-skin="gmail"],
|
||||
html[data-interface-skin="proton"],
|
||||
html[data-interface-skin="zoho"],
|
||||
html[data-interface-skin="outlook"],
|
||||
html[data-interface-skin="minimal"] {
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html[data-interface-skin="gmail"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f6f8fc;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f8fafd;
|
||||
--panel-3: #eef3f8;
|
||||
--sidebar: #eef3f8;
|
||||
--line: #e2e6ea;
|
||||
--line-strong: #c7cdd3;
|
||||
--text: #202124;
|
||||
--muted: #5f6368;
|
||||
--subtle: #7a7f85;
|
||||
--accent: #0b57d0;
|
||||
--accent-strong: #0842a0;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #b3261e;
|
||||
--warning: #8a5a00;
|
||||
--success: #1e8e3e;
|
||||
--field: #ffffff;
|
||||
--selected: #d3e3fd;
|
||||
--hover: #edf2fa;
|
||||
--shadow: rgba(60, 64, 67, .18);
|
||||
--folder-bg: #eef3f8;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="gmail"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1f1f1f;
|
||||
--panel: #242424;
|
||||
--panel-2: #2b2b2b;
|
||||
--panel-3: #333333;
|
||||
--sidebar: #202124;
|
||||
--line: #3c4043;
|
||||
--line-strong: #5f6368;
|
||||
--text: #e8eaed;
|
||||
--muted: #bdc1c6;
|
||||
--subtle: #969ba1;
|
||||
--accent: #8ab4f8;
|
||||
--accent-strong: #aecbfa;
|
||||
--accent-ink: #10223e;
|
||||
--danger: #f28b82;
|
||||
--warning: #fdd663;
|
||||
--success: #81c995;
|
||||
--field: #202124;
|
||||
--selected: #2d4668;
|
||||
--hover: #303134;
|
||||
--shadow: rgba(0, 0, 0, .38);
|
||||
--folder-bg: #202124;
|
||||
--message-bg: #242424;
|
||||
--reader-bg: #242424;
|
||||
}
|
||||
|
||||
html[data-interface-skin="proton"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f7f5ff;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f7f5ff;
|
||||
--panel-3: #eeeaff;
|
||||
--sidebar: #f2efff;
|
||||
--line: #e0daf7;
|
||||
--line-strong: #c5bce7;
|
||||
--text: #28253d;
|
||||
--muted: #6d6688;
|
||||
--subtle: #8d86a5;
|
||||
--accent: #6d4aff;
|
||||
--accent-strong: #5637d8;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c43b54;
|
||||
--warning: #8d6714;
|
||||
--success: #23845b;
|
||||
--field: #ffffff;
|
||||
--selected: #e7e1ff;
|
||||
--hover: #f0edff;
|
||||
--shadow: rgba(50, 38, 96, .16);
|
||||
--folder-bg: #f2efff;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="proton"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #171426;
|
||||
--panel: #201b34;
|
||||
--panel-2: #292242;
|
||||
--panel-3: #332b50;
|
||||
--sidebar: #1b172d;
|
||||
--line: #40375e;
|
||||
--line-strong: #62567f;
|
||||
--text: #f2efff;
|
||||
--muted: #beb6d7;
|
||||
--subtle: #948aae;
|
||||
--accent: #9b82ff;
|
||||
--accent-strong: #b5a3ff;
|
||||
--accent-ink: #171027;
|
||||
--danger: #f07a91;
|
||||
--warning: #e1bd62;
|
||||
--success: #75c7a2;
|
||||
--field: #1b172d;
|
||||
--selected: #3d315f;
|
||||
--hover: #2a2342;
|
||||
--shadow: rgba(0, 0, 0, .42);
|
||||
--folder-bg: #1b172d;
|
||||
--message-bg: #201b34;
|
||||
--reader-bg: #201b34;
|
||||
}
|
||||
|
||||
html[data-interface-skin="zoho"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f8f9fc;
|
||||
--panel-3: #edf1f6;
|
||||
--sidebar: #f1f4f8;
|
||||
--line: #dde3eb;
|
||||
--line-strong: #bcc7d4;
|
||||
--text: #202936;
|
||||
--muted: #647182;
|
||||
--subtle: #8390a0;
|
||||
--accent: #2f6fdf;
|
||||
--accent-strong: #2459b5;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #cf4858;
|
||||
--warning: #966d11;
|
||||
--success: #2c8a63;
|
||||
--field: #ffffff;
|
||||
--selected: #e1ebff;
|
||||
--hover: #edf3fd;
|
||||
--shadow: rgba(31, 45, 66, .15);
|
||||
--folder-bg: #f1f4f8;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="zoho"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #151a21;
|
||||
--panel: #1b222c;
|
||||
--panel-2: #222b36;
|
||||
--panel-3: #2a3542;
|
||||
--sidebar: #171e27;
|
||||
--line: #344251;
|
||||
--line-strong: #506175;
|
||||
--text: #eef3f8;
|
||||
--muted: #abb8c5;
|
||||
--subtle: #8594a3;
|
||||
--accent: #73a5ff;
|
||||
--accent-strong: #91b8ff;
|
||||
--accent-ink: #0d1c34;
|
||||
--danger: #ee7b88;
|
||||
--warning: #dab85d;
|
||||
--success: #78bf9c;
|
||||
--field: #171e27;
|
||||
--selected: #293d5c;
|
||||
--hover: #25303c;
|
||||
--shadow: rgba(0, 0, 0, .38);
|
||||
--folder-bg: #171e27;
|
||||
--message-bg: #1b222c;
|
||||
--reader-bg: #1b222c;
|
||||
}
|
||||
|
||||
html[data-interface-skin="outlook"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f5f5;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #faf9f8;
|
||||
--panel-3: #f3f2f1;
|
||||
--sidebar: #f3f2f1;
|
||||
--line: #e1dfdd;
|
||||
--line-strong: #c8c6c4;
|
||||
--text: #242424;
|
||||
--muted: #616161;
|
||||
--subtle: #858585;
|
||||
--accent: #0f6cbd;
|
||||
--accent-strong: #0b5a9e;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #c50f1f;
|
||||
--warning: #8a6a00;
|
||||
--success: #107c10;
|
||||
--field: #ffffff;
|
||||
--selected: #deecf9;
|
||||
--hover: #f3f6fa;
|
||||
--shadow: rgba(0, 0, 0, .16);
|
||||
--folder-bg: #f3f2f1;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="outlook"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1b1a19;
|
||||
--panel: #252423;
|
||||
--panel-2: #2d2c2b;
|
||||
--panel-3: #343332;
|
||||
--sidebar: #201f1e;
|
||||
--line: #3b3a39;
|
||||
--line-strong: #605e5c;
|
||||
--text: #f3f2f1;
|
||||
--muted: #c8c6c4;
|
||||
--subtle: #a19f9d;
|
||||
--accent: #69afe5;
|
||||
--accent-strong: #8bc3ea;
|
||||
--accent-ink: #102334;
|
||||
--danger: #f1707b;
|
||||
--warning: #e2c15d;
|
||||
--success: #7fba7a;
|
||||
--field: #201f1e;
|
||||
--selected: #284764;
|
||||
--hover: #302f2e;
|
||||
--shadow: rgba(0, 0, 0, .42);
|
||||
--folder-bg: #201f1e;
|
||||
--message-bg: #252423;
|
||||
--reader-bg: #252423;
|
||||
}
|
||||
|
||||
html[data-interface-skin="minimal"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f8f8f7;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #fafafa;
|
||||
--panel-3: #f0f0ef;
|
||||
--sidebar: #f7f7f6;
|
||||
--line: #e7e7e5;
|
||||
--line-strong: #d1d1ce;
|
||||
--text: #242424;
|
||||
--muted: #6e6e6b;
|
||||
--subtle: #92928e;
|
||||
--accent: #555b62;
|
||||
--accent-strong: #3d4349;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #b74d4d;
|
||||
--warning: #836b2c;
|
||||
--success: #557a59;
|
||||
--field: #ffffff;
|
||||
--selected: #ececeb;
|
||||
--hover: #f2f2f1;
|
||||
--shadow: rgba(0, 0, 0, .10);
|
||||
--folder-bg: #f7f7f6;
|
||||
--message-bg: #ffffff;
|
||||
--reader-bg: #ffffff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="minimal"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #161616;
|
||||
--panel: #1c1c1c;
|
||||
--panel-2: #222222;
|
||||
--panel-3: #292929;
|
||||
--sidebar: #191919;
|
||||
--line: #303030;
|
||||
--line-strong: #484848;
|
||||
--text: #eeeeec;
|
||||
--muted: #b4b4b0;
|
||||
--subtle: #8a8a87;
|
||||
--accent: #b8bdc2;
|
||||
--accent-strong: #d3d6d9;
|
||||
--accent-ink: #171717;
|
||||
--danger: #de7a7a;
|
||||
--warning: #c5ad6a;
|
||||
--success: #82a985;
|
||||
--field: #181818;
|
||||
--selected: #323232;
|
||||
--hover: #252525;
|
||||
--shadow: rgba(0, 0, 0, .35);
|
||||
--folder-bg: #191919;
|
||||
--message-bg: #1c1c1c;
|
||||
--reader-bg: #1c1c1c;
|
||||
}
|
||||
|
||||
html[data-interface-skin="gmail"] .folder-button.active,
|
||||
html[data-interface-skin="gmail"] .message-row.active,
|
||||
html[data-interface-skin="gmail"] .message-row.selected,
|
||||
html[data-interface-skin="gmail"] .settings-tab.active,
|
||||
html[data-interface-skin="proton"] .folder-button.active,
|
||||
html[data-interface-skin="proton"] .message-row.active,
|
||||
html[data-interface-skin="proton"] .message-row.selected,
|
||||
html[data-interface-skin="proton"] .settings-tab.active,
|
||||
html[data-interface-skin="zoho"] .folder-button.active,
|
||||
html[data-interface-skin="zoho"] .message-row.active,
|
||||
html[data-interface-skin="zoho"] .message-row.selected,
|
||||
html[data-interface-skin="zoho"] .settings-tab.active,
|
||||
html[data-interface-skin="outlook"] .folder-button.active,
|
||||
html[data-interface-skin="outlook"] .message-row.active,
|
||||
html[data-interface-skin="outlook"] .message-row.selected,
|
||||
html[data-interface-skin="outlook"] .settings-tab.active,
|
||||
html[data-interface-skin="minimal"] .folder-button.active,
|
||||
html[data-interface-skin="minimal"] .message-row.active,
|
||||
html[data-interface-skin="minimal"] .message-row.selected,
|
||||
html[data-interface-skin="minimal"] .settings-tab.active {
|
||||
background: var(--selected);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-interface-skin="gmail"] .button.primary,
|
||||
html[data-interface-skin="proton"] .button.primary,
|
||||
html[data-interface-skin="zoho"] .button.primary,
|
||||
html[data-interface-skin="outlook"] .button.primary,
|
||||
html[data-interface-skin="minimal"] .button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* ---------- Additional predefined mail styles ---------- */
|
||||
html[data-interface-skin="fastmail"],
|
||||
html[data-interface-skin="apple-mail"],
|
||||
html[data-interface-skin="yahoo"],
|
||||
html[data-interface-skin="hey"],
|
||||
html[data-interface-skin="nord"] {
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html[data-interface-skin="fastmail"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f8fc;
|
||||
--panel: #fff;
|
||||
--panel-2: #f8faff;
|
||||
--panel-3: #edf3fb;
|
||||
--sidebar: #f1f5fb;
|
||||
--line: #dfe6ef;
|
||||
--line-strong: #bdc9d8;
|
||||
--text: #172033;
|
||||
--muted: #607086;
|
||||
--subtle: #8491a2;
|
||||
--accent: #2563d9;
|
||||
--accent-strong: #194eb8;
|
||||
--accent-ink: #fff;
|
||||
--danger: #c63f4c;
|
||||
--warning: #8d6610;
|
||||
--success: #23805b;
|
||||
--field: #fff;
|
||||
--selected: #d9e8ff;
|
||||
--hover: #edf4ff;
|
||||
--shadow: rgba(35, 55, 90, .14);
|
||||
--folder-bg: #f1f5fb;
|
||||
--message-bg: #fff;
|
||||
--reader-bg: #fff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="fastmail"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #151b25;
|
||||
--panel: #1c2430;
|
||||
--panel-2: #232d3a;
|
||||
--panel-3: #2b3746;
|
||||
--sidebar: #18202b;
|
||||
--line: #334254;
|
||||
--line-strong: #52657d;
|
||||
--text: #eef4fc;
|
||||
--muted: #b4c0cf;
|
||||
--subtle: #8998aa;
|
||||
--accent: #79a8ff;
|
||||
--accent-strong: #9bbdff;
|
||||
--accent-ink: #10203b;
|
||||
--danger: #ef7e89;
|
||||
--warning: #ddb75c;
|
||||
--success: #7ac6a0;
|
||||
--field: #18202b;
|
||||
--selected: #2b466d;
|
||||
--hover: #263343;
|
||||
--shadow: rgba(0, 0, 0, .4);
|
||||
--folder-bg: #18202b;
|
||||
--message-bg: #1c2430;
|
||||
--reader-bg: #1c2430;
|
||||
}
|
||||
|
||||
html[data-interface-skin="apple-mail"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f5f7;
|
||||
--panel: #fff;
|
||||
--panel-2: #fafafa;
|
||||
--panel-3: #f0f1f3;
|
||||
--sidebar: #f3f4f6;
|
||||
--line: #e2e3e6;
|
||||
--line-strong: #c7c9ce;
|
||||
--text: #1d1d1f;
|
||||
--muted: #66666b;
|
||||
--subtle: #8d8d93;
|
||||
--accent: #0a84ff;
|
||||
--accent-strong: #006bd6;
|
||||
--accent-ink: #fff;
|
||||
--danger: #d43b48;
|
||||
--warning: #946b00;
|
||||
--success: #24845d;
|
||||
--field: #fff;
|
||||
--selected: #dcebff;
|
||||
--hover: #f0f6ff;
|
||||
--shadow: rgba(0, 0, 0, .12);
|
||||
--folder-bg: #f3f4f6;
|
||||
--message-bg: #fff;
|
||||
--reader-bg: #fff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="apple-mail"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #171719;
|
||||
--panel: #202023;
|
||||
--panel-2: #27272a;
|
||||
--panel-3: #303034;
|
||||
--sidebar: #1b1b1e;
|
||||
--line: #38383d;
|
||||
--line-strong: #595960;
|
||||
--text: #f5f5f7;
|
||||
--muted: #b7b7bd;
|
||||
--subtle: #8e8e95;
|
||||
--accent: #64a8ff;
|
||||
--accent-strong: #82baff;
|
||||
--accent-ink: #102237;
|
||||
--danger: #ff808b;
|
||||
--warning: #dfbc65;
|
||||
--success: #78c69f;
|
||||
--field: #1c1c1f;
|
||||
--selected: #294867;
|
||||
--hover: #2a2a2e;
|
||||
--shadow: rgba(0, 0, 0, .38);
|
||||
--folder-bg: #1b1b1e;
|
||||
--message-bg: #202023;
|
||||
--reader-bg: #202023;
|
||||
}
|
||||
|
||||
html[data-interface-skin="yahoo"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f7f5fb;
|
||||
--panel: #fff;
|
||||
--panel-2: #faf8ff;
|
||||
--panel-3: #f1edfa;
|
||||
--sidebar: #f4f0fb;
|
||||
--line: #e6dff1;
|
||||
--line-strong: #cbbdde;
|
||||
--text: #24202d;
|
||||
--muted: #6c6278;
|
||||
--subtle: #91859e;
|
||||
--accent: #6001d2;
|
||||
--accent-strong: #4b00a6;
|
||||
--accent-ink: #fff;
|
||||
--danger: #c63f58;
|
||||
--warning: #8f6810;
|
||||
--success: #28815e;
|
||||
--field: #fff;
|
||||
--selected: #ede3ff;
|
||||
--hover: #f4edff;
|
||||
--shadow: rgba(65, 27, 100, .14);
|
||||
--folder-bg: #f4f0fb;
|
||||
--message-bg: #fff;
|
||||
--reader-bg: #fff;
|
||||
}
|
||||
|
||||
html[data-interface-skin="yahoo"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1a1420;
|
||||
--panel: #231a2b;
|
||||
--panel-2: #2d2137;
|
||||
--panel-3: #392946;
|
||||
--sidebar: #1e1726;
|
||||
--line: #44334f;
|
||||
--line-strong: #685275;
|
||||
--text: #f6effb;
|
||||
--muted: #c8b9d0;
|
||||
--subtle: #9e8ba9;
|
||||
--accent: #b686ff;
|
||||
--accent-strong: #c9a7ff;
|
||||
--accent-ink: #231038;
|
||||
--danger: #ef8298;
|
||||
--warning: #dfbd61;
|
||||
--success: #7ac5a0;
|
||||
--field: #1e1726;
|
||||
--selected: #493166;
|
||||
--hover: #30243a;
|
||||
--shadow: rgba(0, 0, 0, .42);
|
||||
--folder-bg: #1e1726;
|
||||
--message-bg: #231a2b;
|
||||
--reader-bg: #231a2b;
|
||||
}
|
||||
|
||||
html[data-interface-skin="hey"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #fff8de;
|
||||
--panel: #fffdf4;
|
||||
--panel-2: #fff6d0;
|
||||
--panel-3: #f5e8ae;
|
||||
--sidebar: #fff3c2;
|
||||
--line: #eadb99;
|
||||
--line-strong: #cbb568;
|
||||
--text: #29251c;
|
||||
--muted: #756b4d;
|
||||
--subtle: #9a8e69;
|
||||
--accent: #e5533d;
|
||||
--accent-strong: #bd3e2c;
|
||||
--accent-ink: #fff;
|
||||
--danger: #c73838;
|
||||
--warning: #8c6500;
|
||||
--success: #35795c;
|
||||
--field: #fffdf4;
|
||||
--selected: #fff0a8;
|
||||
--hover: #fff4c5;
|
||||
--shadow: rgba(90, 70, 15, .14);
|
||||
--folder-bg: #fff3c2;
|
||||
--message-bg: #fffdf4;
|
||||
--reader-bg: #fffdf4;
|
||||
}
|
||||
|
||||
html[data-interface-skin="hey"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #211f18;
|
||||
--panel: #29271e;
|
||||
--panel-2: #333025;
|
||||
--panel-3: #403a28;
|
||||
--sidebar: #252219;
|
||||
--line: #4b4633;
|
||||
--line-strong: #70684a;
|
||||
--text: #fff7d8;
|
||||
--muted: #c9bd91;
|
||||
--subtle: #9f9470;
|
||||
--accent: #ff8a6e;
|
||||
--accent-strong: #ffa18b;
|
||||
--accent-ink: #34160f;
|
||||
--danger: #f47c7c;
|
||||
--warning: #e0bd60;
|
||||
--success: #81c29e;
|
||||
--field: #252219;
|
||||
--selected: #554a25;
|
||||
--hover: #363223;
|
||||
--shadow: rgba(0, 0, 0, .38);
|
||||
--folder-bg: #252219;
|
||||
--message-bg: #29271e;
|
||||
--reader-bg: #29271e;
|
||||
}
|
||||
|
||||
html[data-interface-skin="nord"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #eceff4;
|
||||
--panel: #f8fafc;
|
||||
--panel-2: #eef2f7;
|
||||
--panel-3: #e5e9f0;
|
||||
--sidebar: #e5e9f0;
|
||||
--line: #d2d8e1;
|
||||
--line-strong: #b4bfcc;
|
||||
--text: #2e3440;
|
||||
--muted: #596273;
|
||||
--subtle: #7a8494;
|
||||
--accent: #5e81ac;
|
||||
--accent-strong: #4c6f99;
|
||||
--accent-ink: #fff;
|
||||
--danger: #bf616a;
|
||||
--warning: #a47718;
|
||||
--success: #4f806c;
|
||||
--field: #f8fafc;
|
||||
--selected: #d8dee9;
|
||||
--hover: #e7ecf2;
|
||||
--shadow: rgba(46, 52, 64, .15);
|
||||
--folder-bg: #e5e9f0;
|
||||
--message-bg: #f8fafc;
|
||||
--reader-bg: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-interface-skin="nord"][data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #2e3440;
|
||||
--panel: #343b49;
|
||||
--panel-2: #3b4252;
|
||||
--panel-3: #434c5e;
|
||||
--sidebar: #303744;
|
||||
--line: #4c566a;
|
||||
--line-strong: #67748c;
|
||||
--text: #eceff4;
|
||||
--muted: #c3cad6;
|
||||
--subtle: #9aa5b6;
|
||||
--accent: #88c0d0;
|
||||
--accent-strong: #8fbcbb;
|
||||
--accent-ink: #223039;
|
||||
--danger: #bf616a;
|
||||
--warning: #ebcb8b;
|
||||
--success: #a3be8c;
|
||||
--field: #303744;
|
||||
--selected: #46556c;
|
||||
--hover: #3b4555;
|
||||
--shadow: rgba(0, 0, 0, .36);
|
||||
--folder-bg: #303744;
|
||||
--message-bg: #343b49;
|
||||
--reader-bg: #343b49;
|
||||
}
|
||||
|
||||
html[data-interface-skin="fastmail"] .folder-button.active,
|
||||
html[data-interface-skin="fastmail"] .message-row.active,
|
||||
html[data-interface-skin="fastmail"] .message-row.selected,
|
||||
html[data-interface-skin="fastmail"] .settings-tab.active,
|
||||
html[data-interface-skin="apple-mail"] .folder-button.active,
|
||||
html[data-interface-skin="apple-mail"] .message-row.active,
|
||||
html[data-interface-skin="apple-mail"] .message-row.selected,
|
||||
html[data-interface-skin="apple-mail"] .settings-tab.active,
|
||||
html[data-interface-skin="yahoo"] .folder-button.active,
|
||||
html[data-interface-skin="yahoo"] .message-row.active,
|
||||
html[data-interface-skin="yahoo"] .message-row.selected,
|
||||
html[data-interface-skin="yahoo"] .settings-tab.active,
|
||||
html[data-interface-skin="hey"] .folder-button.active,
|
||||
html[data-interface-skin="hey"] .message-row.active,
|
||||
html[data-interface-skin="hey"] .message-row.selected,
|
||||
html[data-interface-skin="hey"] .settings-tab.active,
|
||||
html[data-interface-skin="nord"] .folder-button.active,
|
||||
html[data-interface-skin="nord"] .message-row.active,
|
||||
html[data-interface-skin="nord"] .message-row.selected,
|
||||
html[data-interface-skin="nord"] .settings-tab.active {
|
||||
background: var(--selected);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-interface-skin="fastmail"] .button.primary,
|
||||
html[data-interface-skin="apple-mail"] .button.primary,
|
||||
html[data-interface-skin="yahoo"] .button.primary,
|
||||
html[data-interface-skin="hey"] .button.primary,
|
||||
html[data-interface-skin="nord"] .button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
@@ -269,4 +269,329 @@ html[data-preview="bottom"] .reader-pane {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 0.0.3 mobile reliability pass */
|
||||
.folder-quota {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: auto;
|
||||
padding: 9px 0 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.folder-quota+.folder-pane-footer {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.folder-quota-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--subtle);
|
||||
font: 9px var(--font-mono);
|
||||
}
|
||||
|
||||
.folder-quota-label span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-quota-track {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-3);
|
||||
}
|
||||
|
||||
.folder-quota-track span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width .16s ease;
|
||||
}
|
||||
|
||||
.folder-quota.quota-warning .folder-quota-track span {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.folder-quota.quota-danger .folder-quota-track span {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* The visual Sieve builder must never let checkbox inputs inherit width:100%. */
|
||||
.sieve-builder .checkbox-row input[type="checkbox"] {
|
||||
width: 15px;
|
||||
min-width: 15px;
|
||||
max-width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 15px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sieve-builder .checkbox-row span {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.reader-recipients {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.reader-recipients:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message-meta.addresses-expanded .sender strong,
|
||||
.message-meta.addresses-expanded .recipient-line>span {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-meta.addresses-expanded {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
|
||||
.sieve-builder-grid,
|
||||
.sieve-builder-grid-rule,
|
||||
.sieve-dynamic-row {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
}
|
||||
|
||||
.sieve-builder .checkbox-row {
|
||||
align-self: start;
|
||||
justify-content: flex-start;
|
||||
min-height: auto;
|
||||
padding-block: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dialog {
|
||||
box-sizing: border-box;
|
||||
width: calc(100vw - 12px);
|
||||
max-width: calc(100vw - 12px);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.dialog>.dialog-card {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
.dialog-actions,
|
||||
.settings-panel,
|
||||
.settings-panel-heading,
|
||||
.settings-section,
|
||||
.settings-grid,
|
||||
.appearance-grid,
|
||||
.appearance-toggles,
|
||||
.appearance-presets,
|
||||
.sieve-layout,
|
||||
.sieve-editor-wrap,
|
||||
.headers-content,
|
||||
.message-source-content {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-actions .toolbar-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
height: calc(100dvh - 12px);
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-content: start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
align-self: center;
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 12px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-content * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-content .settings-grid,
|
||||
.settings-content .appearance-grid,
|
||||
.settings-content .appearance-toggles,
|
||||
.settings-content .notification-folder-list,
|
||||
.settings-content .pgp-settings-grid {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
}
|
||||
|
||||
.settings-panel-heading,
|
||||
.sieve-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-panel-heading>.button,
|
||||
.sieve-header>.inline-actions {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.headers-card,
|
||||
.source-card,
|
||||
.calendar-card,
|
||||
.contacts-card {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.headers-content,
|
||||
.message-source-content {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.calendar-toolbar>* {
|
||||
min-width: 0 !important;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.calendar-toolbar #calendar-range {
|
||||
grid-column: 1 / -1;
|
||||
order: -2;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.calendar-toolbar #calendar-view {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.calendar-toolbar #calendar-new {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.calendar-toolbar .button,
|
||||
.calendar-toolbar select {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.message-meta time {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.reader-toolbar .toolbar-menu>summary span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reader-toolbar .toolbar-menu>summary {
|
||||
min-width: 30px;
|
||||
padding-inline: 7px;
|
||||
}
|
||||
|
||||
.reader-toolbar .toolbar-menu-popover {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.compose-footer {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.compose-footer #compose-status {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compose-message-size {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.compose-mode-control {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
justify-self: start;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.compose-submit-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.compose-mode-control select {
|
||||
min-width: 108px;
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -36,5 +36,24 @@
|
||||
"print_message": "Print message",
|
||||
"create_filter_sender": "Create filter from sender",
|
||||
"filter_draft_created": "Filter draft created from message",
|
||||
"select_all_results": "Select all results"
|
||||
"select_all_results": "Select all results",
|
||||
"loading": "Loading…",
|
||||
"loading_quota": "Loading quota…",
|
||||
"of": "of",
|
||||
"used": "used",
|
||||
"server_limit_unavailable": "server limit unavailable",
|
||||
"quota_unavailable": "Quota unavailable",
|
||||
"unavailable": "Unavailable",
|
||||
"show_full_addresses": "Show full addresses",
|
||||
"collapse_addresses": "Collapse addresses",
|
||||
"fixed_interface_style_note": "fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.",
|
||||
"custom_interface_style_note": "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.",
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply all",
|
||||
"forward": "Forward",
|
||||
"headers": "Headers",
|
||||
"message_details": "Message details",
|
||||
"move_copy": "Move / copy",
|
||||
"move_to": "Move to…",
|
||||
"copy_to": "Copy to…"
|
||||
}
|
||||
+594
-1
@@ -36,5 +36,598 @@
|
||||
"print_message": "Drukuj wiadomość",
|
||||
"create_filter_sender": "Utwórz filtr z nadawcy",
|
||||
"filter_draft_created": "Utworzono szkic filtra z wiadomości",
|
||||
"select_all_results": "Zaznacz wszystkie wyniki"
|
||||
"select_all_results": "Zaznacz wszystkie wyniki",
|
||||
"loading": "Ładowanie…",
|
||||
"loading_quota": "Ładowanie limitu…",
|
||||
"of": "z",
|
||||
"used": "wykorzystano",
|
||||
"server_limit_unavailable": "limit serwera niedostępny",
|
||||
"quota_unavailable": "Limit niedostępny",
|
||||
"unavailable": "Niedostępne",
|
||||
"show_full_addresses": "Pokaż pełne adresy",
|
||||
"collapse_addresses": "Zwiń adresy",
|
||||
"_text": {
|
||||
"Webmail": "Poczta internetowa",
|
||||
"Email": "E-mail",
|
||||
"Password": "Hasło",
|
||||
"Keep me signed in": "Zapamiętaj mnie",
|
||||
"Sign in": "Zaloguj się",
|
||||
"Connecting": "Łączenie",
|
||||
"Folders": "Foldery",
|
||||
"Compose": "Napisz",
|
||||
"Labels": "Etykiety",
|
||||
"IMAP storage": "Pojemność IMAP",
|
||||
"Loading…": "Ładowanie…",
|
||||
"New message": "Nowa wiadomość",
|
||||
"Recover draft": "Odzyskaj szkic",
|
||||
"From": "Od",
|
||||
"Account default": "Domyślne dla konta",
|
||||
"To": "Do",
|
||||
"Cc": "DW",
|
||||
"Bcc": "UDW",
|
||||
"Subject": "Temat",
|
||||
"Options": "Opcje",
|
||||
"Priority": "Priorytet",
|
||||
"Normal": "Normalny",
|
||||
"High": "Wysoki",
|
||||
"Low": "Niski",
|
||||
"Off": "Wyłączone",
|
||||
"Sign": "Podpisz",
|
||||
"Encrypt": "Szyfruj",
|
||||
"Sign + encrypt": "Podpisz i szyfruj",
|
||||
"Request read receipt": "Żądaj potwierdzenia odczytu",
|
||||
"OpenPGP disabled": "OpenPGP wyłączone",
|
||||
"Details": "Szczegóły",
|
||||
"Inline OpenPGP (text only; PGP/MIME recommended)": "OpenPGP inline (tylko tekst; zalecane PGP/MIME)",
|
||||
"Attach my public OpenPGP key": "Dołącz mój publiczny klucz OpenPGP",
|
||||
"Attach public keys of To/Cc recipients": "Dołącz klucze publiczne odbiorców Do/DW",
|
||||
"Bcc recipient keys are never attached, so hidden recipients are not disclosed.": "Klucze odbiorców UDW nie są dołączane, aby nie ujawniać ukrytych odbiorców.",
|
||||
"Private-key passphrase": "Hasło klucza prywatnego",
|
||||
"Check recipient keys": "Sprawdź klucze odbiorców",
|
||||
"Find missing keys": "Znajdź brakujące klucze",
|
||||
"Font": "Czcionka",
|
||||
"Text size": "Rozmiar tekstu",
|
||||
"Size": "Rozmiar",
|
||||
"Small": "Mały",
|
||||
"Large": "Duży",
|
||||
"Larger": "Większy",
|
||||
"Text color": "Kolor tekstu",
|
||||
"Saved response": "Zapisana odpowiedź",
|
||||
"Response…": "Odpowiedź…",
|
||||
"Compose format": "Format wiadomości",
|
||||
"Plain text": "Zwykły tekst",
|
||||
"Uploading…": "Wysyłanie…",
|
||||
"Cancel": "Anuluj",
|
||||
"Send": "Wyślij",
|
||||
"Create folder": "Utwórz folder",
|
||||
"Create": "Utwórz",
|
||||
"Current folder": "Bieżący folder",
|
||||
"Subscribe / Unsubscribe": "Subskrybuj / anuluj subskrypcję",
|
||||
"Rename": "Zmień nazwę",
|
||||
"Delete": "Usuń",
|
||||
"Settings": "Ustawienia",
|
||||
"Appearance": "Wygląd",
|
||||
"Mailbox": "Skrzynka",
|
||||
"Composing": "Redagowanie",
|
||||
"Identities & signatures": "Tożsamości i podpisy",
|
||||
"Notifications": "Powiadomienia",
|
||||
"Filters": "Filtry",
|
||||
"Diagnostics": "Diagnostyka",
|
||||
"Account": "Konto",
|
||||
"About": "Informacje",
|
||||
"Personalize RustBox without changing mail behavior.": "Dostosuj wygląd RustBox bez zmiany działania poczty.",
|
||||
"Reset appearance": "Przywróć wygląd",
|
||||
"Language": "Język",
|
||||
"English": "Angielski",
|
||||
"Theme": "Motyw",
|
||||
"Dark": "Ciemny",
|
||||
"Light": "Jasny",
|
||||
"System": "Systemowy",
|
||||
"Interface style": "Styl interfejsu",
|
||||
"RustBox (custom)": "RustBox (niestandardowy)",
|
||||
"Global default": "Domyślny globalny",
|
||||
"Gmail inspired": "Inspiracja Gmail",
|
||||
"Proton inspired": "Inspiracja Proton",
|
||||
"Zoho inspired": "Inspiracja Zoho",
|
||||
"Outlook inspired": "Inspiracja Outlook",
|
||||
"High contrast": "Wysoki kontrast",
|
||||
"RustBox is fully customizable: colors, spacing, panes and typography.": "RustBox można w pełni dostosować: kolory, odstępy, panele i typografię.",
|
||||
"Color preset": "Paleta kolorów",
|
||||
"Custom": "Niestandardowy",
|
||||
"Accent color": "Kolor akcentu",
|
||||
"Custom background": "Niestandardowe tło",
|
||||
"Custom panel": "Niestandardowy panel",
|
||||
"Custom text": "Niestandardowy tekst",
|
||||
"Density": "Gęstość",
|
||||
"Compact": "Kompaktowa",
|
||||
"Comfortable": "Wygodna",
|
||||
"Preview pane": "Panel podglądu",
|
||||
"Right": "Prawo",
|
||||
"Bottom": "Dół",
|
||||
"UI size": "Rozmiar interfejsu",
|
||||
"Font family": "Krój pisma",
|
||||
"System UI": "Systemowy",
|
||||
"Humanist": "Humanistyczny",
|
||||
"Monospace": "Monospace",
|
||||
"Font size": "Rozmiar tekstu",
|
||||
"Message row height": "Wysokość wiersza wiadomości",
|
||||
"Folder pane width": "Szerokość panelu folderów",
|
||||
"Message pane width": "Szerokość listy wiadomości",
|
||||
"Corner radius": "Zaokrąglenie narożników",
|
||||
"Contrast": "Kontrast",
|
||||
"Soft": "Łagodny",
|
||||
"Date format": "Format daty",
|
||||
"Locale": "Lokalny",
|
||||
"Time format": "Format godziny",
|
||||
"24-hour": "24-godzinny",
|
||||
"12-hour": "12-godzinny",
|
||||
"Show avatars": "Pokazuj awatary",
|
||||
"Show attachment icons": "Pokazuj ikony załączników",
|
||||
"Show flags": "Pokazuj flagi",
|
||||
"Reduce motion": "Ogranicz animacje",
|
||||
"Advanced interface styling": "Zaawansowany wygląd interfejsu",
|
||||
"Icon size": "Rozmiar ikon",
|
||||
"Toolbar density": "Gęstość paska narzędzi",
|
||||
"Spacious": "Przestronna",
|
||||
"Sidebar item height": "Wysokość elementu paska bocznego",
|
||||
"Reader max width": "Maks. szerokość czytnika",
|
||||
"Fluid": "Płynna",
|
||||
"Composer width": "Szerokość edytora",
|
||||
"Selection color": "Kolor zaznaczenia",
|
||||
"Unread weight": "Grubość nieprzeczytanych",
|
||||
"Unread marker": "Znacznik nieprzeczytanych",
|
||||
"Dot": "Kropka",
|
||||
"Outline": "Obrys",
|
||||
"Solid": "Pełny",
|
||||
"Scrollbar": "Pasek przewijania",
|
||||
"Thin": "Cienki",
|
||||
"Hidden": "Ukryty",
|
||||
"Compact message headers": "Kompaktowe nagłówki wiadomości",
|
||||
"Show panel borders": "Pokazuj obramowania paneli",
|
||||
"Monochrome interface": "Monochromatyczny interfejs",
|
||||
"Folder background override": "Nadpisanie tła folderów",
|
||||
"Message-list background override": "Nadpisanie tła listy wiadomości",
|
||||
"Reader background override": "Nadpisanie tła czytnika",
|
||||
"Control message-list and reader behavior for this account.": "Ustaw zachowanie listy wiadomości i czytnika dla tego konta.",
|
||||
"Messages per page": "Wiadomości na stronę",
|
||||
"Mark as read": "Oznacz jako przeczytane",
|
||||
"Immediately": "Natychmiast",
|
||||
"After 0.75 s": "Po 0,75 s",
|
||||
"After 1.5 s": "Po 1,5 s",
|
||||
"After 3 s": "Po 3 s",
|
||||
"After 5 s": "Po 5 s",
|
||||
"Remote images": "Zdalne obrazy",
|
||||
"Never": "Nigdy",
|
||||
"Contacts only": "Tylko kontakty",
|
||||
"Ask for each message": "Pytaj dla każdej wiadomości",
|
||||
"Always load": "Zawsze wczytuj",
|
||||
"Message body": "Treść wiadomości",
|
||||
"Auto": "Automatycznie",
|
||||
"Prefer HTML": "Preferuj HTML",
|
||||
"Threads": "Wątki",
|
||||
"Threaded": "Wątki",
|
||||
"Flat": "Płasko",
|
||||
"Default sort": "Domyślne sortowanie",
|
||||
"Newest first": "Najnowsze najpierw",
|
||||
"Oldest first": "Najstarsze najpierw",
|
||||
"Sender A–Z": "Nadawca A–Z",
|
||||
"Sender Z–A": "Nadawca Z–A",
|
||||
"Subject A–Z": "Temat A–Z",
|
||||
"Subject Z–A": "Temat Z–A",
|
||||
"Smallest first": "Najmniejsze najpierw",
|
||||
"Largest first": "Największe najpierw",
|
||||
"Collapse quoted text where possible": "Zwijaj cytowany tekst, gdy to możliwe",
|
||||
"Show message snippets in the list": "Pokazuj fragmenty wiadomości na liście",
|
||||
"Keep flagged messages on top by default": "Domyślnie trzymaj oznaczone wiadomości na górze",
|
||||
"Synchronize RustBox labels as IMAP keywords": "Synchronizuj etykiety RustBox jako słowa kluczowe IMAP",
|
||||
"Keyword synchronization is used only in folders whose IMAP server advertises persistent custom flags. Local labels remain available everywhere.": "Synchronizacja słów kluczowych działa tylko w folderach, w których serwer IMAP obsługuje trwałe własne flagi. Lokalne etykiety pozostają dostępne wszędzie.",
|
||||
"Save mailbox settings": "Zapisz ustawienia skrzynki",
|
||||
"Reconcile IMAP labels now": "Synchronizuj etykiety IMAP teraz",
|
||||
"Defaults for new messages and draft behavior.": "Domyślne ustawienia nowych wiadomości i szkiców.",
|
||||
"Default format": "Domyślny format",
|
||||
"Autosave interval": "Interwał autozapisu",
|
||||
"1 second": "1 sekunda",
|
||||
"3 seconds": "3 sekundy",
|
||||
"5 seconds": "5 sekund",
|
||||
"10 seconds": "10 sekund",
|
||||
"20 seconds": "20 sekund",
|
||||
"60 seconds": "60 sekund",
|
||||
"Default priority": "Domyślny priorytet",
|
||||
"Default message font": "Domyślna czcionka wiadomości",
|
||||
"System sans": "Systemowy sans",
|
||||
"Humanist sans": "Humanistyczny sans",
|
||||
"Serif": "Szeryfowa",
|
||||
"Reply position": "Pozycja odpowiedzi",
|
||||
"Write above quote": "Pisz nad cytatem",
|
||||
"Write below quote": "Pisz pod cytatem",
|
||||
"Quote style": "Styl cytatu",
|
||||
"Standard attribution": "Standardowe przypisanie",
|
||||
"Compact attribution": "Kompaktowe przypisanie",
|
||||
"Do not include quote": "Nie dołączaj cytatu",
|
||||
"Forward button": "Przycisk przekazywania",
|
||||
"Forward inline": "Przekazuj w treści",
|
||||
"Forward as .eml attachment": "Przekazuj jako załącznik .eml",
|
||||
"Request read receipt by default": "Domyślnie żądaj potwierdzenia odczytu",
|
||||
"Warn when message text suggests a missing attachment": "Ostrzegaj, gdy treść sugeruje brak załącznika",
|
||||
"Use browser spellcheck while composing": "Używaj sprawdzania pisowni przeglądarki",
|
||||
"Markdown is stored as source text and sent as a safe rendered HTML alternative with a plain-text fallback.": "Markdown jest przechowywany jako tekst źródłowy i wysyłany jako bezpiecznie wyrenderowany HTML z wersją tekstową.",
|
||||
"Saved responses": "Zapisane odpowiedzi",
|
||||
"Reusable text or HTML responses, compatible with the Roundcube canned-response workflow.": "Wielokrotnego użytku odpowiedzi tekstowe lub HTML, zgodne z przepływem gotowych odpowiedzi Roundcube.",
|
||||
"New response": "Nowa odpowiedź",
|
||||
"No saved responses.": "Brak zapisanych odpowiedzi.",
|
||||
"Choose special IMAP folders used by RustBox. Values are stored per account.": "Wybierz specjalne foldery IMAP używane przez RustBox. Wartości są zapisywane osobno dla konta.",
|
||||
"Auto-detect": "Wykryj automatycznie",
|
||||
"Sent": "Wysłane",
|
||||
"Drafts": "Szkice",
|
||||
"Trash": "Kosz",
|
||||
"Default spam destination": "Domyślny folder spamu",
|
||||
"Shared by all accounts in this browser when that folder exists.": "Wspólne dla wszystkich kont w tej przeglądarce, jeśli folder istnieje.",
|
||||
"Spam destination override (this account)": "Nadpisanie folderu spamu (to konto)",
|
||||
"Leave automatic to use the default above, then IMAP Junk/Spam auto-detection.": "Pozostaw automatyczne, aby użyć ustawienia powyżej, a następnie automatycznego wykrywania IMAP Junk/Spam.",
|
||||
"Archive": "Archiwizuj",
|
||||
"Sent, Trash, Spam/Junk and Archive are used directly by mail actions. The spam destination resolves in this order: account override → browser default → IMAP Junk/Spam auto-detection.": "Foldery Wysłane, Kosz, Spam/Junk i Archiwum są używane bezpośrednio przez akcje poczty. Folder spamu jest wybierany kolejno: ustawienie konta → domyślne przeglądarki → automatyczne wykrywanie IMAP Junk/Spam.",
|
||||
"Mailbox storage": "Pamięć skrzynki",
|
||||
"Loading quota…": "Ładowanie limitu…",
|
||||
"Save folder settings": "Zapisz ustawienia folderów",
|
||||
"Sender identities and account-wide signatures. Editing opens in a dedicated window so the form never collides with Settings.": "Tożsamości nadawcy i podpisy konta. Edycja otwiera się w osobnym oknie, aby formularz nie kolidował z Ustawieniami.",
|
||||
"New identity": "Nowa tożsamość",
|
||||
"Configured identities": "Skonfigurowane tożsamości",
|
||||
"Select an identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.": "Wybierz tożsamość, aby edytować dane nadawcy, stopkę tekstową/HTML, obraz i domyślne ustawienia OpenPGP.",
|
||||
"Manage per-user keys, trust and discovery. Private keys never leave the server unless you explicitly export one.": "Zarządzaj kluczami użytkownika, zaufaniem i wyszukiwaniem. Klucze prywatne nie opuszczają serwera, chyba że jawnie je wyeksportujesz.",
|
||||
"Refresh": "Odśwież",
|
||||
"Policy & discovery": "Polityka i wyszukiwanie",
|
||||
"Discover keys with WKD": "Wyszukuj klucze przez WKD",
|
||||
"Fallback to configured HTTPS key server": "Użyj skonfigurowanego serwera kluczy HTTPS jako zapasowego",
|
||||
"Encrypt only to trusted keys": "Szyfruj tylko do zaufanych kluczy",
|
||||
"Save OpenPGP policy": "Zapisz politykę OpenPGP",
|
||||
"Generate key": "Generuj klucz",
|
||||
"Name": "Nazwa",
|
||||
"Passphrase": "Hasło",
|
||||
"Not stored by RustBox.": "Nie jest przechowywane przez RustBox.",
|
||||
"Import / discover": "Import / wyszukiwanie",
|
||||
"Armored public/private key": "Klucz publiczny/prywatny ASCII-armored",
|
||||
"Initial trust": "Początkowe zaufanie",
|
||||
"Unknown": "Nieznane",
|
||||
"Verified": "Zweryfikowane",
|
||||
"Trusted": "Zaufane",
|
||||
"Untrusted": "Niezaufane",
|
||||
"Import": "Importuj",
|
||||
"Import file": "Importuj plik",
|
||||
"Discover by email": "Wyszukaj po e-mailu",
|
||||
"Discover key": "Wyszukaj klucz",
|
||||
"Keyring": "Brelok kluczy",
|
||||
"No keys loaded": "Brak wczytanych kluczy",
|
||||
"Desktop notifications": "Powiadomienia systemowe",
|
||||
"Sound for new mail and calendar reminders": "Dźwięk dla nowej poczty i przypomnień kalendarza",
|
||||
"Do Not Disturb": "Nie przeszkadzać",
|
||||
"DND from": "DND od",
|
||||
"DND until": "DND do",
|
||||
"Browser permission: unknown": "Uprawnienie przeglądarki: nieznane",
|
||||
"Request permission": "Poproś o uprawnienie",
|
||||
"Test notification": "Testuj powiadomienie",
|
||||
"Save": "Zapisz",
|
||||
"ManageSieve filters": "Filtry ManageSieve",
|
||||
"Not checked": "Niesprawdzono",
|
||||
"Backup": "Kopia zapasowa",
|
||||
"Restore": "Przywróć",
|
||||
"Scripts": "Skrypty",
|
||||
"New script": "Nowy skrypt",
|
||||
"Script name": "Nazwa skryptu",
|
||||
"Rules in this script": "Reguły w tym skrypcie",
|
||||
"Existing": "Istniejące",
|
||||
"filters and RustBox rules are parsed automatically.": "filtry i reguły RustBox są analizowane automatycznie.",
|
||||
"No rules found in this script": "Brak reguł w tym skrypcie",
|
||||
"Create filter": "Utwórz filtr",
|
||||
"Rules are edited in a separate visual window with a live “what will happen” preview.": "Reguły są edytowane w osobnym oknie wizualnym z podglądem „co się stanie”.",
|
||||
"Autoresponder / vacation": "Autoresponder / urlop",
|
||||
"Reply interval (days)": "Odstęp odpowiedzi (dni)",
|
||||
"Active from": "Aktywne od",
|
||||
"Active until": "Aktywne do",
|
||||
"Message": "Wiadomość",
|
||||
"Skip mailing lists": "Pomiń listy mailingowe",
|
||||
"Skip automated messages": "Pomiń wiadomości automatyczne",
|
||||
"Append autoresponder": "Dodaj autoresponder",
|
||||
"Advanced Sieve source": "Zaawansowane źródło Sieve",
|
||||
"Use this only for syntax the visual editor does not understand. Parsed rules stay editable above.": "Używaj tylko dla składni, której edytor wizualny nie rozumie. Rozpoznane reguły nadal można edytować powyżej.",
|
||||
"Validate": "Sprawdź",
|
||||
"Activate": "Aktywuj",
|
||||
"Deactivate all": "Dezaktywuj wszystkie",
|
||||
"Live capability checks for the current account. Credentials and server host names are not returned to the browser.": "Bieżące sprawdzanie możliwości aktualnego konta. Dane logowania i nazwy hostów serwera nie są zwracane do przeglądarki.",
|
||||
"Reconnect": "Połącz ponownie",
|
||||
"Realtime": "Czas rzeczywisty",
|
||||
"Disconnected": "Rozłączono",
|
||||
"IMAP IDLE events are delivered to the browser through the RustBox WebSocket connection.": "Zdarzenia IMAP IDLE są dostarczane do przeglądarki przez połączenie WebSocket RustBox.",
|
||||
"Synchronization cache": "Pamięć synchronizacji",
|
||||
"Search index": "Indeks wyszukiwania",
|
||||
"Rebuild index": "Przebuduj indeks",
|
||||
"Accounts signed in to this RustBox browser session.": "Konta zalogowane w tej sesji przeglądarki RustBox.",
|
||||
"Add account": "Dodaj konto",
|
||||
"Active sessions": "Aktywne sesje",
|
||||
"Live RustBox sessions for this account, including other browsers while the server is running.": "Aktywne sesje RustBox dla tego konta, także w innych przeglądarkach, gdy serwer działa.",
|
||||
"Revoke other sessions": "Unieważnij pozostałe sesje",
|
||||
"Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.": "Webmail oparty na kodzie Oxi, z interfejsem RustBox kompilowanym bezpośrednio do programu Rust.",
|
||||
"Upstream": "Projekt bazowy",
|
||||
"Oxi base": "Baza Oxi",
|
||||
"Frontend": "Frontend",
|
||||
"HTML, CSS and vanilla JavaScript embedded at Cargo build time": "HTML, CSS i czysty JavaScript osadzane podczas kompilacji Cargo",
|
||||
"Author": "Autor",
|
||||
"Keyboard shortcuts": "Skróty klawiaturowe",
|
||||
"Identity & signature": "Tożsamość i podpis",
|
||||
"Sender identity": "Tożsamość nadawcy",
|
||||
"Address and headers used when this identity is selected in the composer.": "Adres i nagłówki używane po wybraniu tej tożsamości w edytorze.",
|
||||
"Display name": "Nazwa wyświetlana",
|
||||
"Reply-To": "Odpowiedź do",
|
||||
"Automatic Bcc": "Automatyczne UDW",
|
||||
"Organization": "Organizacja",
|
||||
"Signature / footer": "Podpis / stopka",
|
||||
"Maintain separate text and HTML versions. Imported images are embedded into outgoing mail as inline CID images.": "Utrzymuj osobne wersje tekstową i HTML. Importowane obrazy są osadzane w wysyłanej poczcie jako obrazy CID inline.",
|
||||
"Plain-text signature / footer": "Podpis / stopka tekstowa",
|
||||
"HTML signature / footer": "Podpis / stopka HTML",
|
||||
"You can also paste an image directly into this editor.": "Możesz też wkleić obraz bezpośrednio do edytora.",
|
||||
"Signature position": "Pozycja podpisu",
|
||||
"Above quoted text": "Nad cytowanym tekstem",
|
||||
"Below quoted text": "Pod cytowanym tekstem",
|
||||
"OpenPGP key": "Klucz OpenPGP",
|
||||
"Automatic / none": "Automatycznie / brak",
|
||||
"Defaults": "Domyślne",
|
||||
"Choose where this signature is inserted and, when OpenPGP is configured, its default behavior.": "Wybierz miejsce wstawiania podpisu oraz domyślne zachowanie OpenPGP, jeśli jest skonfigurowane.",
|
||||
"Signature on new messages": "Podpis w nowych wiadomościach",
|
||||
"Signature on replies": "Podpis w odpowiedziach",
|
||||
"Signature on forwards": "Podpis przy przekazywaniu",
|
||||
"Default identity": "Domyślna tożsamość",
|
||||
"Sign by default with OpenPGP": "Domyślnie podpisuj przez OpenPGP",
|
||||
"Encrypt by default with OpenPGP": "Domyślnie szyfruj przez OpenPGP",
|
||||
"Use inline OpenPGP by default": "Domyślnie używaj OpenPGP inline",
|
||||
"Save identity": "Zapisz tożsamość",
|
||||
"Build the rule visually and verify the preview before applying it.": "Zbuduj regułę wizualnie i sprawdź podgląd przed zastosowaniem.",
|
||||
"Rule": "Reguła",
|
||||
"Give it a readable name and decide whether it is active.": "Nadaj czytelną nazwę i zdecyduj, czy reguła ma być aktywna.",
|
||||
"Rule name": "Nazwa reguły",
|
||||
"Rule enabled": "Reguła włączona",
|
||||
"Stop after this rule": "Zatrzymaj po tej regule",
|
||||
"When": "Kiedy",
|
||||
"Choose what the incoming message must match.": "Wybierz, co ma pasować w przychodzącej wiadomości.",
|
||||
"Condition": "Warunek",
|
||||
"From address": "Adres nadawcy",
|
||||
"From domain": "Domena nadawcy",
|
||||
"To / Cc address": "Adres Do / DW",
|
||||
"From header": "Nagłówek From",
|
||||
"To header": "Nagłówek To",
|
||||
"Custom header": "Własny nagłówek",
|
||||
"Match": "Dopasowanie",
|
||||
"contains": "zawiera",
|
||||
"is exactly": "jest dokładnie",
|
||||
"wildcard matches": "pasuje do wzorca",
|
||||
"Header name": "Nazwa nagłówka",
|
||||
"Value": "Wartość",
|
||||
"NOT / negate": "NIE / negacja",
|
||||
"Join next condition": "Połącz następny warunek",
|
||||
"Second condition": "Drugi warunek",
|
||||
"None": "Brak",
|
||||
"Join third condition": "Połącz trzeci warunek",
|
||||
"Third condition": "Trzeci warunek",
|
||||
"+ Add condition": "+ Dodaj warunek",
|
||||
"Then": "Wtedy",
|
||||
"Actions run in the order shown below.": "Akcje są wykonywane w kolejności pokazanej poniżej.",
|
||||
"Action": "Akcja",
|
||||
"Move to folder": "Przenieś do folderu",
|
||||
"Copy to folder": "Kopiuj do folderu",
|
||||
"Redirect": "Przekieruj",
|
||||
"Redirect and keep copy": "Przekieruj i zachowaj kopię",
|
||||
"Discard": "Odrzuć",
|
||||
"Mark read": "Oznacz jako przeczytaną",
|
||||
"Add IMAP flag": "Dodaj flagę IMAP",
|
||||
"Set IMAP flags": "Ustaw flagi IMAP",
|
||||
"Keep": "Zachowaj",
|
||||
"Folder": "Folder",
|
||||
"Second action": "Druga akcja",
|
||||
"+ Add action": "+ Dodaj akcję",
|
||||
"What will happen": "Co się stanie",
|
||||
"Live explanation of the rule before you save it.": "Opis działania reguły przed zapisaniem.",
|
||||
"Ready": "Gotowe",
|
||||
"Complete the rule to see its effect.": "Uzupełnij regułę, aby zobaczyć jej efekt.",
|
||||
"Add rule": "Dodaj regułę",
|
||||
"Update selected rule": "Aktualizuj wybraną regułę",
|
||||
"Clear": "Wyczyść",
|
||||
"Global": "Globalne",
|
||||
"Focus search": "Przejdź do wyszukiwania",
|
||||
"Toggle selection mode": "Przełącz tryb zaznaczania",
|
||||
"Show shortcuts": "Pokaż skróty",
|
||||
"Next / previous message": "Następna / poprzednia wiadomość",
|
||||
"Select / unselect current message": "Zaznacz / odznacz bieżącą wiadomość",
|
||||
"Reply": "Odpowiedz",
|
||||
"Reply all": "Odpowiedz wszystkim",
|
||||
"Forward": "Przekaż",
|
||||
"Read / unread": "Przeczytana / nieprzeczytana",
|
||||
"Flag / unflag": "Oznacz / usuń oznaczenie",
|
||||
"Spam / not spam": "Spam / nie spam",
|
||||
"Delete / move to Trash": "Usuń / przenieś do Kosza",
|
||||
"Close": "Zamknij",
|
||||
"Label": "Etykieta",
|
||||
"Color": "Kolor",
|
||||
"Advanced search": "Wyszukiwanie zaawansowane",
|
||||
"Contains": "Zawiera",
|
||||
"All folders": "Wszystkie foldery",
|
||||
"Date from": "Data od",
|
||||
"Date to": "Data do",
|
||||
"Read status": "Stan odczytu",
|
||||
"Any": "Dowolny",
|
||||
"Unread": "Nieprzeczytane",
|
||||
"Read": "Przeczytane",
|
||||
"Flag": "Oznacz gwiazdką",
|
||||
"Flagged": "Oznaczone",
|
||||
"Not flagged": "Nieoznaczone",
|
||||
"Attachments": "Załączniki",
|
||||
"Has attachment": "Ma załącznik",
|
||||
"No attachment": "Bez załącznika",
|
||||
"Sort": "Sortowanie",
|
||||
"Saved searches": "Zapisane wyszukiwania",
|
||||
"Reset": "Wyczyść",
|
||||
"Save search": "Zapisz wyszukiwanie",
|
||||
"Search": "Szukaj",
|
||||
"Contacts": "Kontakty",
|
||||
"All contacts": "Wszystkie kontakty",
|
||||
"New group": "Nowa grupa",
|
||||
"New": "Nowy",
|
||||
"Import vCard": "Importuj vCard",
|
||||
"Export vCard": "Eksportuj vCard",
|
||||
"Find duplicates": "Znajdź duplikaty",
|
||||
"Photo": "Zdjęcie",
|
||||
"Upload photo": "Wgraj zdjęcie",
|
||||
"Remove photo": "Usuń zdjęcie",
|
||||
"Other emails": "Inne adresy e-mail",
|
||||
"Phone": "Telefon",
|
||||
"Other phones": "Inne telefony",
|
||||
"Company": "Firma",
|
||||
"Job title": "Stanowisko",
|
||||
"Website": "Strona WWW",
|
||||
"Birthday": "Urodziny",
|
||||
"Address": "Adres",
|
||||
"Other addresses": "Inne adresy",
|
||||
"Notes": "Notatki",
|
||||
"Favorite": "Ulubiony",
|
||||
"Groups": "Grupy",
|
||||
"Duplicate contacts": "Duplikaty kontaktów",
|
||||
"Duplicates are detected when two contacts share a primary or alternate e-mail address. Choose which record to keep.": "Duplikaty są wykrywane, gdy dwa kontakty mają ten sam główny lub dodatkowy adres e-mail. Wybierz rekord do zachowania.",
|
||||
"Calendar": "Kalendarz",
|
||||
"Today": "Dzisiaj",
|
||||
"Previous": "Poprzedni",
|
||||
"Next": "Następny",
|
||||
"Month": "Miesiąc",
|
||||
"Week": "Tydzień",
|
||||
"Day": "Dzień",
|
||||
"Agenda": "Agenda",
|
||||
"Import ICS": "Importuj ICS",
|
||||
"Export ICS": "Eksportuj ICS",
|
||||
"New event": "Nowe wydarzenie",
|
||||
"Title": "Tytuł",
|
||||
"All day": "Cały dzień",
|
||||
"Start": "Początek",
|
||||
"End": "Koniec",
|
||||
"Recurrence": "Powtarzanie",
|
||||
"Does not repeat": "Nie powtarza się",
|
||||
"Daily": "Codziennie",
|
||||
"Weekly": "Co tydzień",
|
||||
"Monthly": "Co miesiąc",
|
||||
"Yearly": "Co rok",
|
||||
"Custom RRULE": "Własna reguła RRULE",
|
||||
"Status": "Status",
|
||||
"Confirmed": "Potwierdzone",
|
||||
"Tentative": "Wstępne",
|
||||
"Cancelled": "Anulowane",
|
||||
"Reminder": "Przypomnienie",
|
||||
"At start": "Na początku",
|
||||
"5 minutes": "5 minut",
|
||||
"15 minutes": "15 minut",
|
||||
"30 minutes": "30 minut",
|
||||
"1 hour": "1 godzina",
|
||||
"1 day": "1 dzień",
|
||||
"Organizer": "Organizator",
|
||||
"Attendees": "Uczestnicy",
|
||||
"Location": "Lokalizacja",
|
||||
"Meeting link": "Link do spotkania",
|
||||
"Description": "Opis",
|
||||
"Message headers": "Nagłówki wiadomości",
|
||||
"Copy": "Kopiuj",
|
||||
"Message source": "Źródło wiadomości",
|
||||
"Copy source": "Kopiuj źródło",
|
||||
"Download .eml": "Pobierz .eml",
|
||||
"Show full addresses": "Pokaż pełne adresy",
|
||||
"Collapse addresses": "Zwiń adresy",
|
||||
"Close folders": "Zamknij foldery",
|
||||
"Back to messages": "Wróć do wiadomości",
|
||||
"Manage folders": "Zarządzaj folderami",
|
||||
"New label": "Nowa etykieta",
|
||||
"Mail folders": "Foldery poczty",
|
||||
"Message labels": "Etykiety wiadomości",
|
||||
"Sign out": "Wyloguj się",
|
||||
"Recover a saved draft": "Odzyskaj zapisany szkic",
|
||||
"Toggle full screen composer": "Przełącz pełny ekran edytora",
|
||||
"Full screen": "Pełny ekran",
|
||||
"Formatting": "Formatowanie",
|
||||
"Bold": "Pogrubienie",
|
||||
"Italic": "Kursywa",
|
||||
"Underline": "Podkreślenie",
|
||||
"Strikethrough": "Przekreślenie",
|
||||
"Bullet list": "Lista punktowana",
|
||||
"Numbered list": "Lista numerowana",
|
||||
"Quote": "Cytat",
|
||||
"Align left": "Wyrównaj do lewej",
|
||||
"Align center": "Wyśrodkuj",
|
||||
"Align right": "Wyrównaj do prawej",
|
||||
"Insert link": "Wstaw link",
|
||||
"Insert inline image": "Wstaw obraz w treści",
|
||||
"Edit selected image": "Edytuj zaznaczony obraz",
|
||||
"Clear formatting": "Wyczyść formatowanie",
|
||||
"Undo": "Cofnij",
|
||||
"Redo": "Ponów",
|
||||
"Attach files": "Dołącz pliki",
|
||||
"Preview Markdown": "Podgląd Markdown",
|
||||
"Insert saved response": "Wstaw zapisaną odpowiedź",
|
||||
"Write a message": "Napisz wiadomość",
|
||||
"Markdown preview": "Podgląd Markdown",
|
||||
"Recovered draft": "Odzyskany szkic",
|
||||
"Forward as attachment": "Przekaż jako załącznik",
|
||||
"Open": "Otwórz",
|
||||
"Mark unread": "Oznacz jako nieprzeczytaną",
|
||||
"Unflag": "Usuń oznaczenie",
|
||||
"Move to…": "Przenieś do…",
|
||||
"Copy to…": "Kopiuj do…",
|
||||
"Add label…": "Dodaj etykietę…",
|
||||
"Remove label…": "Usuń etykietę…",
|
||||
"Create filter from sender": "Utwórz filtr z nadawcy",
|
||||
"Create filter from domain": "Utwórz filtr z domeny",
|
||||
"Create filter from subject": "Utwórz filtr z tematu",
|
||||
"Add sender to contacts": "Dodaj nadawcę do kontaktów",
|
||||
"Search messages from sender": "Szukaj wiadomości od nadawcy",
|
||||
"Unsubscribe from mailing list": "Wypisz się z listy mailingowej",
|
||||
"Show headers": "Pokaż nagłówki",
|
||||
"Show source": "Pokaż źródło",
|
||||
"Copy Message-ID": "Kopiuj Message-ID",
|
||||
"Copy sender address": "Kopiuj adres nadawcy",
|
||||
"Print": "Drukuj",
|
||||
"Choose folder": "Wybierz folder",
|
||||
"Compose to sender": "Napisz do nadawcy",
|
||||
"Add to contacts": "Dodaj do kontaktów",
|
||||
"Stop trusting sender": "Przestań ufać nadawcy",
|
||||
"Trust sender and load remote images": "Ufaj nadawcy i ładuj zdalne obrazy",
|
||||
"Copy address": "Kopiuj adres",
|
||||
"Search messages": "Szukaj wiadomości",
|
||||
"Draft saved": "Szkic zapisany",
|
||||
"Uploading files…": "Wysyłanie plików…",
|
||||
"Upload complete": "Wysyłanie zakończone",
|
||||
"Allow UI scaling for predefined style": "Zezwól na skalowanie interfejsu dla gotowego stylu",
|
||||
"When enabled, the UI size control can override the scale defined by the selected preset.": "Po włączeniu ustawienie rozmiaru interfejsu może nadpisać skalę wybranego stylu.",
|
||||
"Maximum attachment size": "Maksymalny rozmiar załącznika",
|
||||
"Server-wide limit set with": "Globalny limit serwera ustawiany przez",
|
||||
"in rustbox.toml.": "w rustbox.toml.",
|
||||
"Uploading attachment…": "Wysyłanie załącznika…",
|
||||
"maximum attachment size is": "maksymalny rozmiar załącznika to",
|
||||
"Attachment uploaded": "Załącznik wysłany",
|
||||
"attachments uploaded": "załączników wysłano",
|
||||
"Upload failed": "Wysyłanie nie powiodło się",
|
||||
"Another upload is already in progress": "Inne wysyłanie jest już w toku",
|
||||
"Repository": "Repozytorium",
|
||||
"Fastmail inspired": "Inspirowany Fastmail",
|
||||
"Apple Mail inspired": "Inspirowany Apple Mail",
|
||||
"Yahoo Mail inspired": "Inspirowany Yahoo Mail",
|
||||
"HEY inspired": "Inspirowany HEY",
|
||||
"Maximum message size": "Maksymalny rozmiar wiadomości",
|
||||
"Per-file upload limit set with": "Limit pojedynczego pliku ustawiany przez",
|
||||
"Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.": "Twardy limit całej wiadomości RFC822/MIME wysyłanej do MTA, wraz z narzutem base64/MIME.",
|
||||
"Estimated final message size": "Szacowany końcowy rozmiar wiadomości",
|
||||
"Estimated size. The exact RFC822/MIME size is checked by the server before SMTP delivery.": "Rozmiar szacowany. Dokładny rozmiar RFC822/MIME jest sprawdzany przez serwer przed wysłaniem przez SMTP.",
|
||||
"Estimated message size exceeds the configured limit": "Szacowany rozmiar wiadomości przekracza skonfigurowany limit",
|
||||
"The exact size will be checked before sending.": "Dokładny rozmiar zostanie sprawdzony przed wysłaniem."
|
||||
},
|
||||
"fixed_interface_style_note": "stały styl interfejsu. Motyw nadal przełącza jasny/ciemny; wybierz RustBox (niestandardowy), aby edytować kolory, odstępy i geometrię.",
|
||||
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
||||
"reply": "Odpowiedz",
|
||||
"reply_all": "Odpowiedz wszystkim",
|
||||
"forward": "Przekaż",
|
||||
"headers": "Nagłówki",
|
||||
"message_details": "Szczegóły",
|
||||
"move_copy": "Przenieś / kopiuj",
|
||||
"move_to": "Przenieś do…",
|
||||
"copy_to": "Kopiuj do…"
|
||||
}
|
||||
+86
-45
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-theme="dark" data-density="compact" data-preview="right">
|
||||
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-default-theme="__RUSTBOX_DEFAULT_THEME__" data-default-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-max-attachment-bytes="__RUSTBOX_MAX_ATTACHMENT_BYTES__" data-max-attachment-mb="__RUSTBOX_MAX_ATTACHMENT_MB__" data-max-message-bytes="__RUSTBOX_MAX_MESSAGE_BYTES__" data-max-message-mb="__RUSTBOX_MAX_MESSAGE_MB__" data-theme="dark" data-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-density="compact" data-preview="right">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -7,6 +7,16 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="description" content="RustBox webmail">
|
||||
<title>RustBox</title>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const configured = root.dataset.defaultTheme || "system";
|
||||
root.dataset.theme = configured === "system"
|
||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: configured;
|
||||
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css">
|
||||
</head>
|
||||
@@ -19,7 +29,7 @@
|
||||
<strong>RustBox</strong>
|
||||
</div>
|
||||
|
||||
<section id="login-view" class="login-view hidden" aria-label="Sign in">
|
||||
<section id="login-view" class="login-view hidden" data-i18n-scope aria-label="Sign in">
|
||||
<div class="login-panel">
|
||||
<header class="login-brand">
|
||||
<svg class="brand-icon" aria-hidden="true">
|
||||
@@ -43,7 +53,7 @@
|
||||
|
||||
<main id="app-view" class="app-view hidden">
|
||||
<div id="offline-banner" class="offline-banner hidden" role="status" aria-live="polite"></div>
|
||||
<header class="appbar">
|
||||
<header class="appbar" data-i18n-scope>
|
||||
<div class="app-brand">
|
||||
<svg class="brand-icon" aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use>
|
||||
@@ -82,7 +92,7 @@
|
||||
<div class="mail-shell">
|
||||
<button id="folder-drawer-scrim" class="folder-drawer-scrim" type="button" aria-label="Close folders"
|
||||
tabindex="-1"></button>
|
||||
<aside id="folder-pane" class="folder-pane">
|
||||
<aside id="folder-pane" class="folder-pane" data-i18n-scope>
|
||||
<div class="folder-drawer-header mobile-only">
|
||||
<button id="folder-drawer-close" class="icon-button folder-drawer-close" type="button"
|
||||
aria-label="Back to messages" title="Back to messages"><svg>
|
||||
@@ -105,6 +115,10 @@
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use>
|
||||
</svg></button></div>
|
||||
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
|
||||
<div id="folder-quota" class="folder-quota" aria-live="polite">
|
||||
<div class="folder-quota-label"><span>IMAP storage</span><span id="folder-quota-text">Loading…</span></div>
|
||||
<div class="folder-quota-track"><span id="folder-quota-bar"></span></div>
|
||||
</div>
|
||||
<div class="folder-pane-footer"><span id="mail-server-label">IMAP</span><span>RustBox __RUSTBOX_VERSION__</span>
|
||||
</div>
|
||||
<div id="folder-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
|
||||
@@ -193,15 +207,22 @@
|
||||
</svg></button>
|
||||
<button id="reply-btn" class="button quiet" type="button"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
|
||||
</svg><span>Reply</span></button>
|
||||
</svg><span data-i18n="reply">Reply</span></button>
|
||||
<button id="reply-all-btn" class="button quiet" type="button"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply-all"></use>
|
||||
</svg><span>Reply all</span></button>
|
||||
</svg><span data-i18n="reply_all">Reply all</span></button>
|
||||
<button id="forward-btn" class="button quiet" type="button"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-forward"></use>
|
||||
</svg><span>Forward</span></button>
|
||||
<button id="headers-btn" class="button quiet" type="button">Headers</button><button id="source-btn"
|
||||
class="button quiet" type="button" data-i18n="source">Source</button>
|
||||
</svg><span data-i18n="forward">Forward</span></button>
|
||||
<details id="message-details-menu" class="toolbar-menu">
|
||||
<summary class="button quiet"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
|
||||
</svg><span data-i18n="message_details">Message details</span></summary>
|
||||
<div class="toolbar-menu-popover" role="menu">
|
||||
<button id="headers-btn" type="button" role="menuitem" data-i18n="headers">Headers</button>
|
||||
<button id="source-btn" type="button" role="menuitem" data-i18n="source">Source</button>
|
||||
</div>
|
||||
</details>
|
||||
<button id="quick-filter-btn" class="icon-button" type="button" title="Create filter from sender"
|
||||
aria-label="Create filter from sender" data-i18n-title="create_filter_sender"
|
||||
data-i18n-aria-label="create_filter_sender"><svg>
|
||||
@@ -225,12 +246,15 @@
|
||||
aria-label="Mark read or unread"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use>
|
||||
</svg></button>
|
||||
<label class="move-control"><span class="sr-only">Move to folder</span><select id="move-select">
|
||||
<option value="">Move to…</option>
|
||||
</select></label>
|
||||
<label class="move-control"><span class="sr-only">Copy to folder</span><select id="copy-select">
|
||||
<option value="">Copy to…</option>
|
||||
</select></label>
|
||||
<details id="message-transfer-menu" class="toolbar-menu">
|
||||
<summary class="button quiet"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-folder"></use>
|
||||
</svg><span data-i18n="move_copy">Move / copy</span></summary>
|
||||
<div class="toolbar-menu-popover" role="menu">
|
||||
<button id="reader-move-action" type="button" role="menuitem" data-i18n="move_to">Move to…</button>
|
||||
<button id="reader-copy-action" type="button" role="menuitem" data-i18n="copy_to">Copy to…</button>
|
||||
</div>
|
||||
</details>
|
||||
<span class="toolbar-spacer"></span>
|
||||
<button id="delete-btn" class="icon-button danger" type="button" title="Delete" aria-label="Delete"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-trash"></use>
|
||||
@@ -246,7 +270,7 @@
|
||||
aria-label="Add sender to contacts"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
||||
</svg></button></div>
|
||||
<div id="reader-to" class="reader-recipients"></div>
|
||||
<div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false" title="Show full addresses"></div>
|
||||
</div><time id="reader-date"></time>
|
||||
</div>
|
||||
</header>
|
||||
@@ -275,7 +299,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog id="compose-dialog" class="dialog compose-dialog">
|
||||
<dialog id="compose-dialog" class="dialog compose-dialog" data-i18n-scope>
|
||||
<form id="compose-form" class="dialog-card compose-card" method="dialog">
|
||||
<header class="dialog-header"><strong id="compose-title">New message</strong>
|
||||
<div class="compose-header-actions"><button type="button" id="compose-recover" class="button quiet small"
|
||||
@@ -410,28 +434,31 @@
|
||||
<button type="button" id="compose-response-insert" title="Insert saved response" disabled><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
|
||||
</svg></button>
|
||||
<label class="compose-mode-control"><span class="sr-only">Compose format</span><select id="compose-mode">
|
||||
<option value="html">HTML</option>
|
||||
<option value="text">Plain text</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"></div>
|
||||
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"
|
||||
data-placeholder="Write a message"></div>
|
||||
<div id="compose-upload-progress" class="compose-upload-progress hidden" role="status" aria-live="polite">
|
||||
<div class="compose-upload-track" aria-hidden="true"><span id="compose-upload-bar"></span></div><span
|
||||
id="compose-upload-label">Uploading…</span>
|
||||
</div>
|
||||
<div id="compose-attachments" class="compose-attachments hidden"></div>
|
||||
<div id="compose-error" class="form-error hidden"></div>
|
||||
<footer class="dialog-actions"><span id="compose-status" class="muted"></span><button type="button"
|
||||
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit"
|
||||
class="button primary"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
|
||||
</svg><span>Send</span></button></footer>
|
||||
<footer class="dialog-actions compose-footer">
|
||||
<label class="compose-mode-control"><span class="sr-only">Compose format</span><select id="compose-mode">
|
||||
<option value="html">HTML</option>
|
||||
<option value="text">Plain text</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
</select></label>
|
||||
<span id="compose-status" class="muted"></span><span id="compose-message-size" class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__ MiB</span>
|
||||
<div class="compose-submit-actions"><button type="button" class="button quiet close-dialog">Cancel</button><button
|
||||
id="send-btn" type="submit" class="button primary"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
|
||||
</svg><span>Send</span></button></div>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="markdown-preview-dialog" class="dialog markdown-preview-dialog">
|
||||
<dialog id="markdown-preview-dialog" class="dialog markdown-preview-dialog" data-i18n-scope>
|
||||
<div class="dialog-card markdown-preview-card">
|
||||
<header class="dialog-header"><strong>Markdown preview</strong><button type="button"
|
||||
class="icon-button close-dialog" aria-label="Close"><svg>
|
||||
@@ -490,7 +517,7 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="folder-dialog" class="dialog">
|
||||
<dialog id="folder-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card folder-card">
|
||||
<header class="dialog-header"><strong>Folders</strong><button type="button" class="icon-button close-dialog"
|
||||
aria-label="Close"><svg>
|
||||
@@ -509,7 +536,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settings-dialog" class="dialog settings-dialog">
|
||||
<dialog id="settings-dialog" class="dialog settings-dialog" data-i18n-scope>
|
||||
<div class="dialog-card settings-card">
|
||||
<header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog"
|
||||
aria-label="Close"><svg>
|
||||
@@ -548,15 +575,27 @@
|
||||
<option value="system">System</option>
|
||||
</select></label>
|
||||
<label><span>Interface style</span><select id="pref-interface-skin">
|
||||
<option value="default">Global default</option>
|
||||
<option value="rustbox">RustBox (custom)</option>
|
||||
<option value="roundcube-classic">Roundcube Classic</option>
|
||||
<option value="roundcube-larry">Roundcube Larry</option>
|
||||
<option value="roundcube-elastic">Roundcube Elastic</option>
|
||||
<option value="clean">Clean</option>
|
||||
<option value="gmail">Gmail inspired</option>
|
||||
<option value="proton">Proton inspired</option>
|
||||
<option value="zoho">Zoho inspired</option>
|
||||
<option value="outlook">Outlook inspired</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="graphite">Graphite</option>
|
||||
<option value="high-contrast">High contrast</option>
|
||||
<option value="fastmail">Fastmail inspired</option>
|
||||
<option value="apple-mail">Apple Mail inspired</option>
|
||||
<option value="yahoo">Yahoo Mail inspired</option>
|
||||
<option value="hey">HEY inspired</option>
|
||||
<option value="nord">Nord</option>
|
||||
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
|
||||
spacing, panes and typography.</small></label>
|
||||
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined style</span><small class="muted">When enabled, the UI size control can override the scale defined by the selected preset.</small></label>
|
||||
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
|
||||
<option value="sage">Sage</option>
|
||||
<option value="blue">Blue</option>
|
||||
@@ -819,6 +858,8 @@
|
||||
message text suggests a missing attachment</span></label>
|
||||
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
||||
spellcheck while composing</span></label>
|
||||
<label><span>Maximum attachment size</span><output id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in rustbox.toml.</small></label>
|
||||
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.</small></label>
|
||||
</div>
|
||||
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
||||
plain-text fallback.</p>
|
||||
@@ -1032,12 +1073,10 @@
|
||||
</div>
|
||||
<div class="diagnostics-grid">
|
||||
<section class="diagnostic-card">
|
||||
<div class="section-heading"><span>IMAP</span>
|
||||
<div class="inline-actions"><strong id="diagnostic-imap-status">Not checked</strong><button
|
||||
id="diagnostic-imap-reconnect" class="button quiet" type="button">Reconnect</button></div>
|
||||
</div>
|
||||
<div class="section-heading"><span>IMAP</span><strong id="diagnostic-imap-status">Not checked</strong></div>
|
||||
<div id="diagnostic-imap-summary" class="capability-grid"></div>
|
||||
<div id="diagnostic-imap-caps" class="capability-list"></div>
|
||||
<div class="inline-actions diagnostic-imap-actions"><button id="diagnostic-imap-reconnect" class="button quiet" type="button">Reconnect</button></div>
|
||||
</section>
|
||||
<section class="diagnostic-card">
|
||||
<div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong>
|
||||
@@ -1099,6 +1138,8 @@
|
||||
<dd>HTML, CSS and vanilla JavaScript embedded at Cargo build time</dd>
|
||||
<dt>Author</dt>
|
||||
<dd>@linuxiarz.pl</dd>
|
||||
<dt>Repository</dt>
|
||||
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank" rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
||||
</dl>
|
||||
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
|
||||
shortcuts</button></div>
|
||||
@@ -1110,7 +1151,7 @@
|
||||
|
||||
|
||||
|
||||
<dialog id="identity-dialog" class="dialog identity-dialog">
|
||||
<dialog id="identity-dialog" class="dialog identity-dialog" data-i18n-scope>
|
||||
<form id="identity-form" class="dialog-card identity-card">
|
||||
<header class="dialog-header"><strong id="identity-dialog-title">Identity & signature</strong><button
|
||||
type="button" class="icon-button close-dialog" aria-label="Close"><svg>
|
||||
@@ -1197,7 +1238,7 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="sieve-rule-dialog" class="dialog sieve-rule-dialog">
|
||||
<dialog id="sieve-rule-dialog" class="dialog sieve-rule-dialog" data-i18n-scope>
|
||||
<div class="dialog-card sieve-rule-card">
|
||||
<header class="dialog-header">
|
||||
<div><strong id="sieve-rule-dialog-title">Create filter</strong><span id="sieve-rule-dialog-subtitle"
|
||||
@@ -1365,7 +1406,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="shortcuts-dialog" class="dialog">
|
||||
<dialog id="shortcuts-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card shortcuts-card">
|
||||
<header class="dialog-header"><strong>Keyboard shortcuts</strong><button type="button"
|
||||
class="icon-button close-dialog" aria-label="Close"><svg>
|
||||
@@ -1415,7 +1456,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="tag-dialog" class="dialog">
|
||||
<dialog id="tag-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card tag-card">
|
||||
<header class="dialog-header"><strong>Label</strong><button type="button" class="icon-button close-dialog"
|
||||
aria-label="Close"><svg>
|
||||
@@ -1433,7 +1474,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="advanced-search-dialog" class="dialog">
|
||||
<dialog id="advanced-search-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card advanced-search-card">
|
||||
<header class="dialog-header"><strong>Advanced search</strong><button type="button"
|
||||
class="icon-button close-dialog" aria-label="Close"><svg>
|
||||
@@ -1485,7 +1526,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="contacts-dialog" class="dialog">
|
||||
<dialog id="contacts-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card contacts-card">
|
||||
<header class="dialog-header"><strong>Contacts</strong><button type="button" class="icon-button close-dialog"
|
||||
aria-label="Close"><svg>
|
||||
@@ -1544,7 +1585,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="contact-duplicates-dialog" class="dialog">
|
||||
<dialog id="contact-duplicates-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card duplicates-card">
|
||||
<header class="dialog-header"><strong>Duplicate contacts</strong><button type="button"
|
||||
class="icon-button close-dialog" aria-label="Close"><svg>
|
||||
@@ -1557,7 +1598,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="calendar-dialog" class="dialog">
|
||||
<dialog id="calendar-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card calendar-card">
|
||||
<header class="dialog-header"><strong>Calendar</strong>
|
||||
<div class="inline-actions"><button id="calendar-today" type="button" class="button quiet">Today</button><button
|
||||
@@ -1620,7 +1661,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="headers-dialog" class="dialog">
|
||||
<dialog id="headers-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card headers-card">
|
||||
<header class="dialog-header"><strong>Message headers</strong><button type="button"
|
||||
class="icon-button close-dialog" aria-label="Close"><svg>
|
||||
@@ -1634,7 +1675,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="add-account-dialog" class="dialog">
|
||||
<dialog id="add-account-dialog" class="dialog" data-i18n-scope>
|
||||
<div class="dialog-card add-account-card">
|
||||
<header class="dialog-header"><strong>Add account</strong><button type="button" class="icon-button close-dialog"
|
||||
aria-label="Close"><svg>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
imageEditorBlob: null,
|
||||
draftSaveTimer: null,
|
||||
prefs: {
|
||||
theme: "system", interfaceSkin: "rustbox", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
|
||||
theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", presetScaleUnlocked: false, 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,
|
||||
|
||||
+371
-304
@@ -1,333 +1,400 @@
|
||||
/* 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 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 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 lastFolderStorageKey() {
|
||||
return `rustbox.lastFolder.${state.accountId || "anonymous"}`;
|
||||
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 activeSearchStorageKey() {
|
||||
return `rustbox.activeSearch.${state.accountId || "anonymous"}`;
|
||||
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 saveActiveSearchContext() {
|
||||
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey());
|
||||
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext));
|
||||
}
|
||||
function scheduleMessageListStateSave() {
|
||||
clearTimeout(state.listStateTimer);
|
||||
state.listStateTimer = setTimeout(saveMessageListState, 120);
|
||||
}
|
||||
|
||||
function readActiveSearchContext() {
|
||||
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 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);
|
||||
const response = await fetch(apiPath(path), {
|
||||
credentials: "include",
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
stack.append(node);
|
||||
requestAnimationFrame(() => node.classList.add("visible"));
|
||||
}
|
||||
const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
|
||||
error.cause = lastNetworkError;
|
||||
error.network = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
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) => {
|
||||
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"); }
|
||||
});
|
||||
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 progressToast(message) {
|
||||
const node = el("div", "toast toast-info toast-progress");
|
||||
node.setAttribute("role", "status");
|
||||
const icon = el("span", "toast-icon");
|
||||
icon.innerHTML = svgIcon("paperclip");
|
||||
const content = el("span", "toast-progress-content");
|
||||
const text = el("span", "toast-message", message);
|
||||
const track = el("span", "toast-progress-track");
|
||||
const bar = el("span", "toast-progress-bar");
|
||||
track.append(bar);
|
||||
content.append(text, track);
|
||||
const value = el("span", "toast-progress-value", "0%");
|
||||
const close = el("button", "toast-close");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.innerHTML = svgIcon("x");
|
||||
let stack = $("#toast-stack");
|
||||
const openDialogs = $$("dialog[open]");
|
||||
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);
|
||||
}
|
||||
node.addEventListener("mouseenter", pause);
|
||||
node.addEventListener("mouseleave", resume);
|
||||
node.addEventListener("focusin", pause);
|
||||
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
|
||||
resume();
|
||||
}
|
||||
node.append(icon, content, value, close);
|
||||
stack.append(node);
|
||||
requestAnimationFrame(() => node.classList.add("visible"));
|
||||
let dismissed = false;
|
||||
const dismiss = (delay = 0) => {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
setTimeout(() => {
|
||||
node.classList.remove("visible");
|
||||
setTimeout(() => node.remove(), 160);
|
||||
}, Math.max(0, delay));
|
||||
};
|
||||
close.addEventListener("click", () => dismiss());
|
||||
return {
|
||||
update(percent, label = null) {
|
||||
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
||||
bar.style.width = `${normalized}%`;
|
||||
value.textContent = `${normalized}%`;
|
||||
if (label) text.textContent = label;
|
||||
},
|
||||
complete(label = trText("Upload complete")) {
|
||||
bar.style.width = "100%";
|
||||
value.textContent = "100%";
|
||||
text.textContent = label;
|
||||
node.classList.remove("toast-info");
|
||||
node.classList.add("toast-success");
|
||||
icon.innerHTML = svgIcon("check-circle");
|
||||
dismiss(1200);
|
||||
},
|
||||
fail(label) {
|
||||
text.textContent = label || trText("Upload failed");
|
||||
node.classList.remove("toast-info");
|
||||
node.classList.add("toast-error");
|
||||
icon.innerHTML = svgIcon("circle-alert");
|
||||
dismiss(4800);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
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 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 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));
|
||||
}
|
||||
function setBusy(button, busy) {
|
||||
if (!button) return;
|
||||
button.disabled = busy;
|
||||
button.setAttribute("aria-busy", String(busy));
|
||||
}
|
||||
|
||||
+622
-511
File diff suppressed because it is too large.
Load diff
+694
-679
File diff suppressed because it is too large.
Load diff
+1153
-1072
File diff suppressed because it is too large.
Load diff
@@ -455,7 +455,7 @@ function showContextMenu(x, y, items) {
|
||||
menu.replaceChildren();
|
||||
for (const item of items) {
|
||||
if (item === "separator") { menu.append(el("div", "separator")); continue; }
|
||||
const button = el("button", item.danger ? "danger" : "", item.label);
|
||||
const button = el("button", item.danger ? "danger" : "", trText(item.label));
|
||||
button.type = "button";
|
||||
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
|
||||
menu.append(button);
|
||||
@@ -476,7 +476,7 @@ async function ensureMessageOpen(message) {
|
||||
async function promptFolder(label, exclude = null) {
|
||||
const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude);
|
||||
if (!options.length) return null;
|
||||
return appSelect(label, options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: "Choose folder" });
|
||||
return appSelect(trText(label), options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: trText("Choose folder") });
|
||||
}
|
||||
|
||||
function openMessageContextMenu(x, y, message) {
|
||||
@@ -735,6 +735,7 @@ async function showMessageSource() {
|
||||
const source = await blob.text();
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "dialog";
|
||||
dialog.dataset.i18nScope = "";
|
||||
const card = el("div", "dialog-card source-card");
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", "Message source"));
|
||||
@@ -747,6 +748,7 @@ async function showMessageSource() {
|
||||
const download = el("button", "button primary", "Download .eml"); download.type = "button"; download.addEventListener("click", downloadEml);
|
||||
actions.append(copy, download);
|
||||
card.append(header, pre, actions); dialog.append(card); document.body.append(dialog);
|
||||
translateScopedUiText(state.prefs.language);
|
||||
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
||||
dialog.showModal();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
@@ -813,7 +815,7 @@ async function forwardAsAttachment() {
|
||||
try {
|
||||
const blob = await fetchEmlBlob(detail);
|
||||
openCompose("new");
|
||||
$("#compose-title").textContent = "Forward as attachment";
|
||||
$("#compose-title").textContent = trText("Forward as attachment");
|
||||
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
|
||||
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
|
||||
const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" });
|
||||
|
||||
@@ -2,29 +2,46 @@
|
||||
async function loadQuota() {
|
||||
const summary = $("#quota-summary span");
|
||||
const bar = $("#quota-bar");
|
||||
if (!summary || !bar) return;
|
||||
summary.textContent = "Loading quota…";
|
||||
bar.style.width = "0%";
|
||||
const sidebar = $("#folder-quota");
|
||||
const sidebarText = $("#folder-quota-text");
|
||||
const sidebarBar = $("#folder-quota-bar");
|
||||
if (!summary && !sidebarText) return;
|
||||
if (summary) summary.textContent = tr("loading_quota", "Loading quota…");
|
||||
if (bar) bar.style.width = "0%";
|
||||
if (sidebarText) sidebarText.textContent = tr("loading", "Loading…");
|
||||
if (sidebarBar) sidebarBar.style.width = "0%";
|
||||
const setLevel = (level) => {
|
||||
for (const box of [$("#quota-summary"), sidebar]) {
|
||||
box?.classList.toggle("quota-warning", level === "warning");
|
||||
box?.classList.toggle("quota-danger", level === "danger");
|
||||
}
|
||||
};
|
||||
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");
|
||||
if (summary) summary.textContent = `${formatBytes(used)} ${tr("of", "of")} ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
|
||||
if (bar) bar.style.width = `${percent}%`;
|
||||
if (sidebarText) sidebarText.textContent = `${percent.toFixed(0)}% · ${formatBytes(used)} / ${formatBytes(limit)}`;
|
||||
if (sidebarBar) sidebarBar.style.width = `${percent}%`;
|
||||
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
|
||||
quotaBox?.classList.toggle("quota-warning", level === "warning");
|
||||
quotaBox?.classList.toggle("quota-danger", level === "danger");
|
||||
setLevel(level);
|
||||
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`;
|
||||
if (summary) summary.textContent = `${formatBytes(used)} ${tr("used", "used")} · ${tr("server_limit_unavailable", "server limit unavailable")}`;
|
||||
if (sidebarText) sidebarText.textContent = `${formatBytes(used)} ${tr("used", "used")}`;
|
||||
setLevel("normal");
|
||||
}
|
||||
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; }
|
||||
} catch (error) {
|
||||
if (summary) summary.textContent = `${tr("quota_unavailable", "Quota unavailable")}: ${error.message}`;
|
||||
if (sidebarText) sidebarText.textContent = tr("unavailable", "Unavailable");
|
||||
setLevel("normal");
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings(tab = "appearance") {
|
||||
|
||||
@@ -61,7 +61,41 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
|
||||
});
|
||||
}
|
||||
|
||||
function positionToolbarMenu(menu) {
|
||||
if (!menu?.open) return;
|
||||
const popover = menu.querySelector(".toolbar-menu-popover");
|
||||
const summary = menu.querySelector("summary");
|
||||
if (!popover || !summary) return;
|
||||
if (matchMedia("(max-width: 560px)").matches) {
|
||||
Object.assign(popover.style, { left: "8px", right: "8px", top: "auto", bottom: "8px" });
|
||||
return;
|
||||
}
|
||||
Object.assign(popover.style, { right: "auto", bottom: "auto" });
|
||||
const rect = summary.getBoundingClientRect();
|
||||
const width = Math.max(164, popover.offsetWidth || 164);
|
||||
const height = popover.offsetHeight || 80;
|
||||
const margin = 8;
|
||||
let left = Math.min(rect.left, window.innerWidth - width - margin);
|
||||
left = Math.max(margin, left);
|
||||
let top = rect.bottom + 5;
|
||||
if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 5);
|
||||
popover.style.left = `${Math.round(left)}px`;
|
||||
popover.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function bindToolbarMenus() {
|
||||
const menus = $$(".toolbar-menu");
|
||||
menus.forEach((menu) => menu.addEventListener("toggle", () => {
|
||||
if (!menu.open) return;
|
||||
menus.forEach((other) => { if (other !== menu) other.open = false; });
|
||||
requestAnimationFrame(() => positionToolbarMenu(menu));
|
||||
}));
|
||||
window.addEventListener("resize", () => menus.forEach(positionToolbarMenu), { passive: true });
|
||||
document.addEventListener("scroll", () => menus.forEach(positionToolbarMenu), true);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
bindToolbarMenus();
|
||||
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
|
||||
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
|
||||
bindRecipientAutocomplete("#compose-to");
|
||||
@@ -95,20 +129,23 @@ function bindEvents() {
|
||||
setComposeMode(next, true);
|
||||
insertIdentitySignature(editor);
|
||||
scheduleDraftSave();
|
||||
updateComposeMessageSize();
|
||||
});
|
||||
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); });
|
||||
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); updateComposeMessageSize(); });
|
||||
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
|
||||
state.pgpRecipientStatus = [];
|
||||
updateComposePgpUi();
|
||||
scheduleDraftSave();
|
||||
updateComposeMessageSize();
|
||||
}));
|
||||
$("#compose-subject").addEventListener("input", scheduleDraftSave);
|
||||
$("#compose-subject").addEventListener("input", () => { scheduleDraftSave(); updateComposeMessageSize(); });
|
||||
["#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();
|
||||
updateComposeMessageSize();
|
||||
}));
|
||||
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); });
|
||||
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); updateComposeMessageSize(); });
|
||||
$("#compose-editor").addEventListener("click", (event) => {
|
||||
const image = event.target.closest?.("img[data-rustbox-cid]");
|
||||
setSelectedInlineImage(image || null);
|
||||
@@ -145,8 +182,16 @@ function bindEvents() {
|
||||
$("#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); });
|
||||
$("#reader-move-action").addEventListener("click", async () => {
|
||||
$("#message-transfer-menu").open = false;
|
||||
const target = await promptFolder("Move to folder", state.currentDetail?.folder || state.folder);
|
||||
if (target) await moveCurrentTo(target);
|
||||
});
|
||||
$("#reader-copy-action").addEventListener("click", async () => {
|
||||
$("#message-transfer-menu").open = false;
|
||||
const target = await promptFolder("Copy to folder", state.currentDetail?.folder || state.folder);
|
||||
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"));
|
||||
@@ -221,11 +266,13 @@ function bindEvents() {
|
||||
$("#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);
|
||||
$("#headers-btn").addEventListener("click", () => { $("#message-details-menu").open = false; showHeaders(); });
|
||||
$("#source-btn").addEventListener("click", async () => { $("#message-details-menu").open = false; await 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); } });
|
||||
$("#reader-to").addEventListener("click", toggleReaderAddressExpansion);
|
||||
$("#reader-to").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); toggleReaderAddressExpansion(); } });
|
||||
$("#print-btn").addEventListener("click", printCurrentMessage);
|
||||
$("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage);
|
||||
$("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner);
|
||||
@@ -250,6 +297,7 @@ function bindEvents() {
|
||||
$$(".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-preset-scale-unlocked").addEventListener("change", (event) => savePreference("presetScaleUnlocked", event.target.checked));
|
||||
$("#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));
|
||||
@@ -396,7 +444,10 @@ function bindEvents() {
|
||||
$("#sieve-deactivate").addEventListener("click", deactivateSieve);
|
||||
$("#sieve-delete").addEventListener("click", deleteSieve);
|
||||
bindEditor();
|
||||
document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); });
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("#context-menu")) hideContextMenu();
|
||||
if (!event.target.closest(".toolbar-menu")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
|
||||
});
|
||||
window.addEventListener("blur", hideContextMenu);
|
||||
document.addEventListener("scroll", hideContextMenu, true);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
@@ -438,7 +489,10 @@ function bindEvents() {
|
||||
else closeReader();
|
||||
}
|
||||
});
|
||||
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
|
||||
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
|
||||
if (!$("#login-view").classList.contains("hidden")) showLogin();
|
||||
else if (state.prefs.theme === "system") applyPreferences();
|
||||
});
|
||||
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
|
||||
}
|
||||
|
||||
@@ -447,7 +501,7 @@ async function boot() {
|
||||
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); }
|
||||
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in new issue
Block a user