0.2.24
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 23:34:09 +01:00
1 parent beae94e932
commit de4d70970c
51 files changed
+4034 -421

No files matched your search

+1
View File
@@ -100,6 +100,7 @@ pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
meeting_url: url,
color: None,
reminder_minutes: None,
reminder_extra_minutes: None,
});
}
}
+110 -6
View File
@@ -1,6 +1,6 @@
use crate::db::pool::{Connection, DbValue, ToDbValue, params};
use crate::queries::sqlite::queries::db_calendar::{EVENT_SELECT_COLS, SETTINGS_SELECT_COLS};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
@@ -23,6 +23,8 @@ pub struct CalendarEvent {
pub meeting_url: Option<String>,
pub color: Option<String>,
pub reminder_minutes: Option<i64>,
/// Optional second reminder (e.g. one day before), in minutes before start.
pub reminder_extra_minutes: Option<i64>,
pub created_at: String,
pub updated_at: String,
}
@@ -53,6 +55,16 @@ pub struct CreateEvent {
pub meeting_url: Option<String>,
pub color: Option<String>,
pub reminder_minutes: Option<i64>,
#[serde(default)]
pub reminder_extra_minutes: Option<i64>,
}
/// `Some(None)` when the field is present and `null` (clear it), `None` when absent.
fn deserialize_nullable_minutes<'de, D>(deserializer: D) -> Result<Option<Option<i64>>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Some(Option::<i64>::deserialize(deserializer)?))
}
fn default_attendees() -> String {
@@ -81,7 +93,10 @@ pub struct UpdateEvent {
pub status: Option<String>,
pub meeting_url: Option<String>,
pub color: Option<String>,
pub reminder_minutes: Option<i64>,
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
pub reminder_minutes: Option<Option<i64>>,
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
pub reminder_extra_minutes: Option<Option<i64>>,
}
#[derive(Debug, Clone, Serialize)]
@@ -98,6 +113,9 @@ pub struct CalendarSettings {
pub caldav_username: String,
pub caldav_enabled: bool,
pub updated_at: String,
/// Reminders pre-selected for new events.
pub default_reminder_minutes: Option<i64>,
pub default_extra_reminder_minutes: Option<i64>,
}
#[derive(Debug, Deserialize)]
@@ -114,6 +132,10 @@ pub struct UpdateCalendarSettings {
pub caldav_username: Option<String>,
pub caldav_password: Option<String>,
pub caldav_enabled: Option<bool>,
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
pub default_reminder_minutes: Option<Option<i64>>,
#[serde(default, deserialize_with = "deserialize_nullable_minutes")]
pub default_extra_reminder_minutes: Option<Option<i64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -149,6 +171,7 @@ fn row_to_event(row: &crate::db::pool::Row<'_>) -> Result<CalendarEvent, crate::
reminder_minutes: row.get(17)?,
created_at: row.get(18)?,
updated_at: row.get(19)?,
reminder_extra_minutes: row.get(20)?,
})
}
@@ -169,6 +192,8 @@ fn row_to_settings(
caldav_username: row.get(9)?,
caldav_enabled: caldav_enabled_int != 0,
updated_at: row.get(11)?,
default_reminder_minutes: row.get(12)?,
default_extra_reminder_minutes: row.get(13)?,
})
}
@@ -269,6 +294,14 @@ pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEve
)
.map_err(|e| format!("Failed to create event: {e}"))?;
if let Some(minutes) = data.reminder_extra_minutes {
conn.execute(
"UPDATE calendar_events SET reminder_extra_minutes = ?1 WHERE id = ?2",
params![minutes, id],
)
.map_err(|e| format!("Failed to set second reminder: {e}"))?;
}
get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string())
}
@@ -309,10 +342,15 @@ pub fn update_event(
idx += 1;
}
if let Some(v) = data.reminder_minutes {
sets.push(format!("reminder_minutes = ?{idx}"));
values.push(v.to_db_value());
idx += 1;
for (column, value) in [
("reminder_minutes", data.reminder_minutes),
("reminder_extra_minutes", data.reminder_extra_minutes),
] {
if let Some(minutes) = value {
sets.push(format!("{column} = ?{idx}"));
values.push(minutes.to_db_value());
idx += 1;
}
}
if sets.is_empty() {
@@ -409,6 +447,20 @@ pub fn update_calendar_settings(
idx += 1;
}
for (column, value) in [
("default_reminder_minutes", data.default_reminder_minutes),
(
"default_extra_reminder_minutes",
data.default_extra_reminder_minutes,
),
] {
if let Some(minutes) = value {
sets.push(format!("{column} = ?{idx}"));
values.push(minutes.to_db_value());
idx += 1;
}
}
if let Some(v) = data.caldav_enabled {
sets.push(format!("caldav_enabled = ?{idx}"));
values.push((v as i32).to_db_value());
@@ -497,6 +549,7 @@ mod tests {
meeting_url: Some("https://zoom.us/j/123".to_string()),
color: Some("blue".to_string()),
reminder_minutes: Some(15),
reminder_extra_minutes: None,
};
let event = create_event(&conn, &data).unwrap();
@@ -531,6 +584,7 @@ mod tests {
meeting_url: None,
color: None,
reminder_minutes: None,
reminder_extra_minutes: None,
};
let late = CreateEvent {
@@ -572,6 +626,7 @@ mod tests {
meeting_url: None,
color: None,
reminder_minutes: None,
reminder_extra_minutes: None,
};
let event = create_event(&conn, &data).unwrap();
@@ -589,6 +644,7 @@ mod tests {
meeting_url: None,
color: None,
reminder_minutes: None,
reminder_extra_minutes: None,
};
let updated = update_event(&conn, &event.id, &update).unwrap();
@@ -617,6 +673,7 @@ mod tests {
meeting_url: None,
color: None,
reminder_minutes: None,
reminder_extra_minutes: None,
};
let event = create_event(&conn, &data).unwrap();
@@ -646,6 +703,7 @@ mod tests {
meeting_url: None,
color: None,
reminder_minutes: None,
reminder_extra_minutes: None,
};
create_event(&conn, &data).unwrap();
@@ -680,12 +738,57 @@ mod tests {
caldav_username: None,
caldav_password: None,
caldav_enabled: None,
default_reminder_minutes: Some(Some(15)),
default_extra_reminder_minutes: Some(Some(1440)),
};
let updated = update_calendar_settings(&conn, &update).unwrap();
assert_eq!(updated.default_view, "week");
assert_eq!(updated.week_starts_on, 1);
assert_eq!(updated.time_format, "24h");
assert_eq!(updated.default_reminder_minutes, Some(15));
assert_eq!(updated.default_extra_reminder_minutes, Some(1440));
let clear: UpdateCalendarSettings =
serde_json::from_value(serde_json::json!({"default_extra_reminder_minutes": null}))
.unwrap();
let cleared = update_calendar_settings(&conn, &clear).unwrap();
assert_eq!(cleared.default_reminder_minutes, Some(15));
assert_eq!(cleared.default_extra_reminder_minutes, None);
}
#[test]
fn second_reminder_roundtrips_and_reminders_can_be_cleared() {
let conn = open_test_db();
let mut data = sample_reminder_event();
data.reminder_minutes = Some(15);
data.reminder_extra_minutes = Some(1440);
let event = create_event(&conn, &data).unwrap();
assert_eq!(event.reminder_minutes, Some(15));
assert_eq!(event.reminder_extra_minutes, Some(1440));
let update: UpdateEvent = serde_json::from_value(serde_json::json!({
"reminder_minutes": null,
"reminder_extra_minutes": 2880
}))
.unwrap();
let updated = update_event(&conn, &event.id, &update).unwrap().unwrap();
assert_eq!(updated.reminder_minutes, None);
assert_eq!(updated.reminder_extra_minutes, Some(2880));
let untouched: UpdateEvent =
serde_json::from_value(serde_json::json!({"title": "Renamed"})).unwrap();
let renamed = update_event(&conn, &event.id, &untouched).unwrap().unwrap();
assert_eq!(renamed.reminder_extra_minutes, Some(2880));
}
fn sample_reminder_event() -> CreateEvent {
serde_json::from_value(serde_json::json!({
"title": "Dentist",
"start_time": "2026-11-10T09:00:00Z",
"end_time": "2026-11-10T10:00:00Z"
}))
.unwrap()
}
#[test]
@@ -726,6 +829,7 @@ mod tests {
meeting_url: self.meeting_url.clone(),
color: self.color.clone(),
reminder_minutes: self.reminder_minutes,
reminder_extra_minutes: self.reminder_extra_minutes,
}
}
}
+106 -13
View File
@@ -7,28 +7,30 @@ use serde::{Deserialize, Deserializer, Serialize};
/// the server default and the per-user preference can never drift apart.
pub const INTERFACE_SKINS: &[&str] = &[
"rustbox",
"roundcube-classic",
"roundcube-larry",
"roundcube-elastic",
"clean",
"gmail",
"proton",
"zoho",
"outlook",
"minimal",
"graphite",
"high-contrast",
"fastmail",
"roundcube-classic",
"roundcube-larry",
"roundcube-elastic",
"gmail",
"outlook",
"apple-mail",
"fastmail",
"proton",
"zoho",
"yahoo",
"hey",
"nord",
"github",
"gitlab",
"discord",
"slack",
"reddit",
"notion",
"linear",
"material",
"nord",
"dracula",
"catppuccin",
"solarized",
@@ -40,9 +42,13 @@ pub const INTERFACE_SKINS: &[&str] = &[
"paper",
"terminal",
"aqua",
"linear",
"material",
"frutiger-aero",
"retro",
"winxp",
"osx",
"ios6",
"win7",
"beos",
];
/// How far a predefined interface style may reshape the interface.
@@ -105,6 +111,9 @@ pub struct DisplayPreferences {
pub adaptive_email_colors: bool,
pub skin_depth: String,
pub skin_gradients: bool,
pub theme_light_from: String,
pub theme_dark_from: String,
pub account_color: String,
pub updated_at: String,
}
@@ -166,6 +175,9 @@ pub struct UpdateDisplayPreferences {
pub adaptive_email_colors: Option<bool>,
pub skin_depth: Option<String>,
pub skin_gradients: Option<bool>,
pub theme_light_from: Option<String>,
pub theme_dark_from: Option<String>,
pub account_color: Option<String>,
}
fn deserialize_animation_mode_field<'de, D>(
@@ -235,6 +247,9 @@ fn defaults() -> DisplayPreferences {
adaptive_email_colors: false,
skin_depth: "standard".into(),
skin_gradients: false,
theme_light_from: "07:00".into(),
theme_dark_from: "19:00".into(),
account_color: String::new(),
updated_at: String::new(),
}
}
@@ -297,7 +312,10 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
adaptive_email_colors: row.get::<_, i32>(52)? != 0,
skin_depth: row.get(53)?,
skin_gradients: row.get::<_, i32>(54)? != 0,
updated_at: row.get(55)?,
theme_light_from: row.get(55)?,
theme_dark_from: row.get(56)?,
account_color: row.get(57)?,
updated_at: row.get(58)?,
})
});
match result {
@@ -321,6 +339,25 @@ fn range(name: &str, value: i32, min: i32, max: i32) -> Result<(), String> {
Err(format!("Invalid {name}: {value}"))
}
}
/// `HH:MM`, 24-hour clock.
fn valid_clock_time(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return false;
}
let digits = |a: u8, b: u8| -> Option<u8> {
if a.is_ascii_digit() && b.is_ascii_digit() {
Some((a - b'0') * 10 + (b - b'0'))
} else {
None
}
};
matches!(
(digits(bytes[0], bytes[1]), digits(bytes[3], bytes[4])),
(Some(hour), Some(minute)) if hour < 24 && minute < 60
)
}
fn valid_hex(value: &str) -> bool {
value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
}
@@ -367,7 +404,7 @@ pub fn update_preferences(
enum_value("density", v, &["compact", "comfortable"])?;
}
if let Some(ref v) = data.theme {
enum_value("theme", v, &["light", "dark", "system"])?;
enum_value("theme", v, &["light", "dark", "system", "schedule"])?;
}
if let Some(ref v) = data.compose_format {
enum_value("compose_format", v, &["html", "text", "markdown"])?;
@@ -443,6 +480,22 @@ pub fn update_preferences(
if let Some(ref v) = data.skin_depth {
enum_value("skin_depth", v, SKIN_DEPTHS)?;
}
for (name, value) in [
("theme_light_from", data.theme_light_from.as_deref()),
("theme_dark_from", data.theme_dark_from.as_deref()),
] {
if let Some(v) = value
&& !valid_clock_time(v)
{
return Err(format!("Invalid {name}: {v}"));
}
}
if let Some(ref v) = data.account_color
&& !v.is_empty()
&& !valid_hex(v)
{
return Err(format!("Invalid account_color: {v}"));
}
if let Some(v) = data.font_size {
range("font_size", v, 11, 18)?;
}
@@ -577,6 +630,9 @@ pub fn update_preferences(
set_bool!(adaptive_email_colors, "adaptive_email_colors");
set_text!(skin_depth, "skin_depth");
set_bool!(skin_gradients, "skin_gradients");
set_text!(theme_light_from, "theme_light_from");
set_text!(theme_dark_from, "theme_dark_from");
set_text!(account_color, "account_color");
if sets.is_empty() {
return get_preferences(conn);
@@ -692,6 +748,43 @@ mod tests {
assert!(update_preferences(&conn, &update).unwrap().skin_gradients);
}
#[test]
fn theme_schedule_and_account_color_roundtrip() {
let conn = open_test_db();
let defaults = get_preferences(&conn).unwrap();
assert_eq!(defaults.theme_light_from, "07:00");
assert_eq!(defaults.theme_dark_from, "19:00");
assert_eq!(defaults.account_color, "");
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
"theme": "schedule", "theme_light_from": "06:30", "theme_dark_from": "20:15", "account_color": "#cc3366"
}))
.unwrap();
let prefs = update_preferences(&conn, &update).unwrap();
assert_eq!(prefs.theme, "schedule");
assert_eq!(prefs.theme_light_from, "06:30");
assert_eq!(prefs.theme_dark_from, "20:15");
assert_eq!(prefs.account_color, "#cc3366");
let clear: UpdateDisplayPreferences =
serde_json::from_value(serde_json::json!({"account_color": ""})).unwrap();
assert_eq!(update_preferences(&conn, &clear).unwrap().account_color, "");
}
#[test]
fn invalid_theme_schedule_times_are_rejected() {
let conn = open_test_db();
for value in ["7:00", "24:00", "12:60", "ab:cd", "12-30", ""] {
let update: UpdateDisplayPreferences =
serde_json::from_value(serde_json::json!({"theme_light_from": value})).unwrap();
assert!(
update_preferences(&conn, &update).is_err(),
"{value} accepted"
);
}
let update: UpdateDisplayPreferences =
serde_json::from_value(serde_json::json!({"account_color": "red"})).unwrap();
assert!(update_preferences(&conn, &update).is_err());
}
#[test]
fn invalid_skin_depth_is_rejected() {
let conn = open_test_db();
+64 -13
View File
@@ -21,18 +21,38 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
53,
include_str!("../../migrations/sqlite/V053__skin_depth_and_gradients.sql"),
)];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
53,
include_str!("../../migrations/mysql/V053__skin_depth_and_gradients.sql"),
)];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
53,
include_str!("../../migrations/pgsql/V053__skin_depth_and_gradients.sql"),
)];
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
(
53,
include_str!("../../migrations/sqlite/V053__skin_depth_and_gradients.sql"),
),
(
54,
include_str!(
"../../migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql"
),
),
];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
(
53,
include_str!("../../migrations/mysql/V053__skin_depth_and_gradients.sql"),
),
(
54,
include_str!("../../migrations/mysql/V054__theme_schedule_account_color_and_reminders.sql"),
),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
53,
include_str!("../../migrations/pgsql/V053__skin_depth_and_gradients.sql"),
),
(
54,
include_str!("../../migrations/pgsql/V054__theme_schedule_account_color_and_reminders.sql"),
),
];
const TENANT_TABLES: &[&str] = &[
"blob_delete_outbox",
@@ -2632,8 +2652,12 @@ mod tests {
conn.execute_batch(
"CREATE TABLE rustbox_accounts (user_hash TEXT PRIMARY KEY, table_prefix TEXT NOT NULL, schema_version INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\n\
CREATE TABLE u_test__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system', interface_skin TEXT NOT NULL DEFAULT 'rustbox');\n\
CREATE TABLE u_test__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER);\n\
CREATE TABLE u_test__calendar_settings (id INTEGER PRIMARY KEY);\n\
INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES('test', 'u_test__', 52);\n\
INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');",
INSERT INTO u_test__display_preferences(id, interface_skin) VALUES(1, 'nord');\n\
INSERT INTO u_test__calendar_events(id, reminder_minutes) VALUES('e1', 15);\n\
INSERT INTO u_test__calendar_settings(id) VALUES(1);",
)
.unwrap();
@@ -2658,6 +2682,33 @@ mod tests {
assert_eq!(skin, "nord");
assert_eq!(depth, "standard");
assert_eq!(gradients, 0);
let (light_from, dark_from, account_color): (String, String, String) = conn
.query_row(
"SELECT theme_light_from, theme_dark_from, account_color FROM u_test__display_preferences WHERE id=1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(light_from, "07:00");
assert_eq!(dark_from, "19:00");
assert_eq!(account_color, "");
let (reminder, extra): (i64, Option<i64>) = conn
.query_row(
"SELECT reminder_minutes, reminder_extra_minutes FROM u_test__calendar_events WHERE id='e1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(reminder, 15);
assert_eq!(extra, None);
let (default_reminder, default_extra): (Option<i64>, Option<i64>) = conn
.query_row(
"SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_test__calendar_settings WHERE id=1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!((default_reminder, default_extra), (None, None));
}
#[test]
+34
View File
@@ -218,6 +218,15 @@ pub trait ImapClient: Send + Sync {
folder: &str,
) -> Result<Vec<(u32, Vec<String>)>, ImapError>;
/// `UID SEARCH <criteria>` in `folder`. Callers must build `criteria`
/// from validated, quoted parts (see `routes::filters`).
async fn search_uids(
&self,
creds: &ImapCredentials,
folder: &str,
criteria: &str,
) -> Result<Vec<u32>, ImapError>;
async fn folder_status_extended(
&self,
creds: &ImapCredentials,
@@ -1156,6 +1165,31 @@ impl ImapClient for RealImapClient {
Ok(results)
}
async fn search_uids(
&self,
creds: &ImapCredentials,
folder: &str,
criteria: &str,
) -> Result<Vec<u32>, ImapError> {
let mut session = self.pool.checkout(creds).await?;
session.select(folder).await.map_err(|e| match &e {
async_imap::error::Error::No(msg)
if msg.to_lowercase().contains("not found")
|| msg.to_lowercase().contains("doesn't exist")
|| msg.to_lowercase().contains("does not exist")
|| msg.to_lowercase().contains("no such") =>
{
ImapError::FolderNotFound(folder.to_string())
}
_ => map_imap_error(e),
})?;
let matches = session.uid_search(criteria).await.map_err(map_imap_error)?;
session.recycle();
let mut uids: Vec<u32> = matches.into_iter().collect();
uids.sort_unstable();
Ok(uids)
}
async fn folder_status_extended(
&self,
creds: &ImapCredentials,
+20
View File
@@ -383,6 +383,26 @@ impl ImapClient for MockImapClient {
Ok(headers.iter().map(|h| (h.uid, h.flags.clone())).collect())
}
/// Test double: `NOT ALL` matches nothing, any other criteria every
/// header the mock knows about (a superset, which callers post-filter).
async fn search_uids(
&self,
_creds: &ImapCredentials,
_folder: &str,
criteria: &str,
) -> Result<Vec<u32>, ImapError> {
if let Some(ref err) = *self.should_fail.lock().unwrap() {
return Err(clone_error(err));
}
if criteria.trim() == "NOT ALL" {
return Ok(Vec::new());
}
let headers = self.headers.lock().unwrap();
let mut uids: Vec<u32> = headers.iter().map(|h| h.uid).collect();
uids.sort_unstable();
Ok(uids)
}
async fn folder_status_extended(
&self,
_creds: &ImapCredentials,
+8 -3
View File
@@ -6,10 +6,11 @@ pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
created_at, updated_at, reminder_extra_minutes";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
caldav_username, caldav_enabled, updated_at, default_reminder_minutes, \
default_extra_reminder_minutes";
pub const Q_001: &str = "SELECT CAST(CURRENT_TIMESTAMP AS CHAR)";
pub const Q_002: &str = "INSERT INTO calendar_events
@@ -127,7 +128,7 @@ pub mod db_contacts {
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, updated_at FROM display_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, theme_light_from, theme_dark_from, account_color, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?";
}
@@ -172,6 +173,10 @@ pub mod db_drafts {
pub const Q_012: &str = "SELECT id, draft_id, filename, content_type, size, file_path, checksum_sha256, created_at FROM draft_attachments WHERE draft_id=? ORDER BY created_at ASC FOR UPDATE";
}
pub mod db_filter_match {
pub const Q_001: &str = "SELECT uid, from_address, from_name, to_addresses, cc_addresses, subject, size, has_attachments, raw_headers FROM messages WHERE folder = ?";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
+8 -3
View File
@@ -6,10 +6,11 @@ pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
created_at, updated_at, reminder_extra_minutes";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
caldav_username, caldav_enabled, updated_at, default_reminder_minutes, \
default_extra_reminder_minutes";
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP::text";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
@@ -127,7 +128,7 @@ pub mod db_contacts {
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, updated_at FROM display_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, theme_light_from, theme_dark_from, account_color, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT INTO display_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ${}";
@@ -173,6 +174,10 @@ pub mod db_drafts {
pub const Q_012: &str = "SELECT id, draft_id, filename, content_type, size, file_path, checksum_sha256, created_at FROM draft_attachments WHERE draft_id=$1 ORDER BY created_at ASC FOR UPDATE";
}
pub mod db_filter_match {
pub const Q_001: &str = "SELECT uid, from_address, from_name, to_addresses, cc_addresses, subject, size, has_attachments, raw_headers FROM messages WHERE folder = $1";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = $1";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
+8 -3
View File
@@ -6,10 +6,11 @@ pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
created_at, updated_at, reminder_extra_minutes";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
caldav_username, caldav_enabled, updated_at, default_reminder_minutes, \
default_extra_reminder_minutes";
pub const Q_001: &str = "SELECT datetime('now')";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
@@ -125,7 +126,7 @@ pub mod db_contacts {
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, updated_at FROM display_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, adaptive_email_colors, skin_depth, skin_gradients, theme_light_from, theme_dark_from, account_color, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?{}";
}
@@ -170,6 +171,10 @@ pub mod db_drafts {
pub const Q_012: &str = "SELECT id, draft_id, filename, content_type, size, file_path, checksum_sha256, created_at FROM draft_attachments WHERE draft_id=?1 ORDER BY created_at ASC";
}
pub mod db_filter_match {
pub const Q_001: &str = "SELECT uid, from_address, from_name, to_addresses, cc_addresses, subject, size, has_attachments, raw_headers FROM messages WHERE folder = ?1";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
+5 -1
View File
@@ -142,7 +142,11 @@ fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) {
event.status.trim().to_ascii_uppercase()
));
}
if let Some(minutes) = event.reminder_minutes.filter(|value| *value >= 0) {
for minutes in [event.reminder_minutes, event.reminder_extra_minutes]
.into_iter()
.flatten()
.filter(|value| *value >= 0)
{
out.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\n");
out.push_str(&format!("TRIGGER:-PT{minutes}M\r\n"));
out.push_str("DESCRIPTION:Reminder\r\nEND:VALARM\r\n");
+774
View File
@@ -0,0 +1,774 @@
//! "Apply now" for mail filters: find the messages already in a folder that a
//! visual filter rule matches, so the client can apply the rule's actions to
//! existing mail (Sieve itself only ever runs on newly delivered messages).
//!
//! Matching runs on the IMAP server with `UID SEARCH`, so it covers the whole
//! folder regardless of what RustBox has cached. IMAP SEARCH only does
//! case-insensitive substring matching, though, while Sieve rules can also use
//! `:is` / `:matches`, sizes, attachments and non-ASCII values. Every rule is
//! therefore translated into two searches:
//!
//! * an *upper bound* — a superset of the real matches, and
//! * a *lower bound* — a subset that is certainly correct.
//!
//! Exact leaves contribute the same criteria to both bounds; inexact leaves
//! widen the upper bound to `ALL` (or a substring) and shrink the lower bound
//! to `NOT ALL`; `NOT` swaps the bounds. Messages in the lower bound match.
//! Messages only in the upper bound are decided exactly from the local message
//! cache when it holds what the rule needs; anything still undecided is
//! reported as `uncertain` and left alone.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use crate::auth::session::SessionState;
use crate::config::AppConfig;
use crate::db;
use crate::db::pool::{Connection, params};
use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials};
use crate::routes::messages::types::parse_address_list;
/// Upper limit on UIDs returned to the client in one preview.
const MAX_RETURNED_UIDS: usize = 20_000;
/// Rules deeper than this are rejected rather than evaluated.
const MAX_RULE_DEPTH: usize = 12;
const MAX_RULE_LEAVES: usize = 64;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchKind {
Contains,
Is,
Matches,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RuleTest {
pub kind: String,
#[serde(rename = "match", default = "default_match")]
pub match_kind: MatchKind,
#[serde(default)]
pub value: String,
#[serde(default)]
pub header: String,
}
fn default_match() -> MatchKind {
MatchKind::Contains
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum RuleExpr {
Group { op: String, args: Vec<RuleExpr> },
Not { not: Box<RuleExpr> },
Test { test: RuleTest },
}
#[derive(Debug, Deserialize)]
pub struct FilterMatchRequest {
pub folder: String,
pub rule: RuleExpr,
}
#[derive(Debug, Serialize)]
pub struct FilterMatchResponse {
pub folder: String,
pub matched: usize,
pub uids: Vec<u32>,
pub truncated: bool,
/// Candidates the server could not decide exactly (left untouched).
pub uncertain: usize,
}
/// One cached message row, as needed for exact evaluation.
#[derive(Debug, Clone, Default)]
pub struct CachedMessage {
pub from_address: String,
pub from_name: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub to_header: String,
pub subject: String,
pub size: i64,
pub has_attachments: bool,
pub raw_headers: Option<String>,
}
/// IMAP search criteria for the upper and lower bound of a rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bounds {
pub upper: String,
pub lower: String,
}
const ALL: &str = "ALL";
const NONE: &str = "NOT ALL";
fn validate_rule(expr: &RuleExpr, depth: usize, leaves: &mut usize) -> Result<(), String> {
if depth > MAX_RULE_DEPTH {
return Err("Filter rule is nested too deeply".to_string());
}
match expr {
RuleExpr::Group { op, args } => {
if op != "allof" && op != "anyof" {
return Err(format!("Unknown rule operator: {op}"));
}
if args.is_empty() {
return Err("Filter rule group is empty".to_string());
}
for arg in args {
validate_rule(arg, depth + 1, leaves)?;
}
Ok(())
}
RuleExpr::Not { not } => validate_rule(not, depth + 1, leaves),
RuleExpr::Test { test } => {
*leaves += 1;
if *leaves > MAX_RULE_LEAVES {
return Err("Filter rule has too many conditions".to_string());
}
validate_test(test)
}
}
}
fn validate_test(test: &RuleTest) -> Result<(), String> {
match test.kind.as_str() {
"from" | "from-domain" | "to" | "to-only" | "cc-only" | "header-from" | "header-to"
| "subject" | "list-id" => {
if test.value.trim().is_empty() {
return Err(format!("Condition {} needs a value", test.kind));
}
}
"header" => {
if test.header.is_empty()
|| !test
.header
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err("Custom header name is invalid".to_string());
}
if test.value.trim().is_empty() {
return Err("Custom header condition needs a value".to_string());
}
}
"size-over" | "size-under" => {
parse_size(&test.value).ok_or_else(|| format!("Invalid size: {}", test.value))?;
}
"has-attachment" | "priority-high" => {}
other => return Err(format!("Unsupported condition: {other}")),
}
if test.value.contains(['\r', '\n']) {
return Err("Condition values cannot contain line breaks".to_string());
}
Ok(())
}
/// `10`, `10K`, `2M`, `1G` (Sieve quantifiers) in bytes.
pub fn parse_size(value: &str) -> Option<u64> {
let value = value.trim();
let (digits, unit) = match value.char_indices().last() {
Some((index, ch)) if ch.is_ascii_alphabetic() => (&value[..index], Some(ch)),
_ => (value, None),
};
let number: u64 = digits.trim().parse().ok()?;
let factor = match unit.map(|ch| ch.to_ascii_uppercase()) {
None => 1,
Some('K') => 1024,
Some('M') => 1024 * 1024,
Some('G') => 1024 * 1024 * 1024,
Some(_) => return None,
};
number.checked_mul(factor)
}
fn quote(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
/// The literal text a value must contain, or `None` when IMAP cannot search
/// for it (non-ASCII text needs `CHARSET` support we do not rely on).
fn search_literal(test: &RuleTest) -> Option<String> {
let value = test.value.trim();
let literal = match test.match_kind {
MatchKind::Matches => value
.split(['*', '?'])
.max_by_key(|part| part.len())
.unwrap_or("")
.to_string(),
_ => value.to_string(),
};
if literal.is_empty() || !literal.is_ascii() {
return None;
}
Some(literal)
}
fn bounds_for_test(test: &RuleTest) -> Bounds {
let inexact = |upper: String| Bounds {
upper,
lower: NONE.to_string(),
};
let exact_for = |criteria: String, exact: bool| {
if exact {
Bounds {
upper: criteria.clone(),
lower: criteria,
}
} else {
inexact(criteria)
}
};
match test.kind.as_str() {
"size-over" | "size-under" => {
let bytes = parse_size(&test.value).unwrap_or(0);
let keyword = if test.kind == "size-over" {
"LARGER"
} else {
"SMALLER"
};
exact_for(format!("{keyword} {bytes}"), true)
}
"has-attachment" => inexact(ALL.to_string()),
"priority-high" => exact_for(
"OR HEADER X-Priority \"1\" OR HEADER X-Priority \"2\" HEADER Importance \"high\""
.to_string(),
false,
),
// `:matches "*"` on a header is "the header exists"; IMAP's
// `HEADER name ""` is exactly that.
"list-id" | "header"
if matches!(test.match_kind, MatchKind::Matches)
&& !test.value.trim().is_empty()
&& test.value.trim().chars().all(|c| c == '*') =>
{
let name = if test.kind == "list-id" {
"List-Id"
} else {
test.header.as_str()
};
exact_for(format!("HEADER {name} \"\""), true)
}
_ => {
let Some(literal) = search_literal(test) else {
return inexact(ALL.to_string());
};
// IMAP SEARCH does case-insensitive substring matching on the whole
// header text. That is exact for Sieve `header :contains` tests;
// `:is` / `:matches` and Sieve `address` tests (which ignore the
// display name) are only a superset and are narrowed afterwards
// from the cache.
let substring = matches!(test.match_kind, MatchKind::Contains);
let q = quote(&literal);
match test.kind.as_str() {
"header-from" => exact_for(format!("FROM {q}"), substring),
"header-to" => exact_for(format!("TO {q}"), substring),
"subject" => exact_for(format!("SUBJECT {q}"), substring),
"list-id" => exact_for(format!("HEADER List-Id {q}"), substring),
"header" => exact_for(format!("HEADER {} {q}", test.header), substring),
"from" => inexact(format!("FROM {q}")),
"from-domain" if matches!(test.match_kind, MatchKind::Is) => {
inexact(format!("FROM {}", quote(&format!("@{literal}"))))
}
"from-domain" => inexact(format!("FROM {q}")),
"to" => inexact(format!("OR TO {q} CC {q}")),
"to-only" => inexact(format!("TO {q}")),
"cc-only" => inexact(format!("CC {q}")),
_ => inexact(ALL.to_string()),
}
}
}
}
fn combine(op: &str, parts: Vec<String>) -> String {
if parts.len() == 1 {
return parts.into_iter().next().unwrap_or_else(|| ALL.to_string());
}
if op == "allof" {
parts
.into_iter()
.map(|part| format!("({part})"))
.collect::<Vec<_>>()
.join(" ")
} else {
// IMAP OR is binary: OR a OR b c
let mut iter = parts.into_iter().rev();
let mut acc = iter.next().unwrap_or_else(|| NONE.to_string());
for part in iter {
acc = format!("OR ({part}) ({acc})");
}
acc
}
}
pub fn bounds(expr: &RuleExpr) -> Bounds {
match expr {
RuleExpr::Test { test } => bounds_for_test(test),
RuleExpr::Not { not } => {
let inner = bounds(not);
Bounds {
upper: format!("NOT ({})", inner.lower),
lower: format!("NOT ({})", inner.upper),
}
}
RuleExpr::Group { op, args } => {
let parts: Vec<Bounds> = args.iter().map(bounds).collect();
Bounds {
upper: combine(op, parts.iter().map(|b| b.upper.clone()).collect()),
lower: combine(op, parts.iter().map(|b| b.lower.clone()).collect()),
}
}
}
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let text: Vec<char> = text.chars().collect();
let (mut p, mut t) = (0usize, 0usize);
let (mut star, mut mark) = (None::<usize>, 0usize);
while t < text.len() {
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
p += 1;
t += 1;
} else if p < pattern.len() && pattern[p] == '*' {
star = Some(p);
mark = t;
p += 1;
} else if let Some(star_index) = star {
p = star_index + 1;
mark += 1;
t = mark;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == '*' {
p += 1;
}
p == pattern.len()
}
fn compare(match_kind: &MatchKind, value: &str, candidate: &str) -> bool {
let value = value.trim().to_lowercase();
let candidate = candidate.trim().to_lowercase();
match match_kind {
MatchKind::Contains => candidate.contains(&value),
MatchKind::Is => candidate == value,
MatchKind::Matches => wildcard_match(&value, &candidate),
}
}
fn any(match_kind: &MatchKind, value: &str, candidates: &[String]) -> bool {
candidates
.iter()
.any(|candidate| compare(match_kind, value, candidate))
}
/// Unfolded values of `name` in a raw RFC 5322 header block.
fn header_values(raw: &str, name: &str) -> Vec<String> {
let mut values = Vec::new();
let mut current: Option<String> = None;
for line in raw.lines() {
if line.starts_with([' ', '\t']) {
if let Some(value) = current.as_mut() {
value.push(' ');
value.push_str(line.trim());
}
continue;
}
if let Some(value) = current.take() {
values.push(value);
}
if let Some((field, value)) = line.split_once(':')
&& field.trim().eq_ignore_ascii_case(name)
{
current = Some(value.trim().to_string());
}
}
if let Some(value) = current {
values.push(value);
}
values
}
/// Exact evaluation of one test against the cache; `None` when the cache does
/// not hold what the test needs.
fn eval_test(test: &RuleTest, message: &CachedMessage) -> Option<bool> {
let value = test.value.as_str();
let m = &test.match_kind;
let raw_header = |name: &str| -> Option<Vec<String>> {
message
.raw_headers
.as_deref()
.map(|raw| header_values(raw, name))
};
Some(match test.kind.as_str() {
"from" => compare(m, value, &message.from_address),
"from-domain" => {
let domain = message
.from_address
.rsplit_once('@')
.map(|(_, domain)| domain)
.unwrap_or("");
compare(m, value, domain)
}
"to" => {
let all: Vec<String> = message
.to
.iter()
.chain(message.cc.iter())
.cloned()
.collect();
any(m, value, &all)
}
"to-only" => any(m, value, &message.to),
"cc-only" => any(m, value, &message.cc),
"header-from" => {
let display = if message.from_name.is_empty() {
message.from_address.clone()
} else {
format!("{} <{}>", message.from_name, message.from_address)
};
compare(m, value, &display)
}
"header-to" => compare(m, value, &message.to_header),
"subject" => compare(m, value, &message.subject),
"size-over" => message.size > parse_size(value)? as i64,
"size-under" => message.size < parse_size(value)? as i64,
"has-attachment" => message.has_attachments,
"list-id" => any(m, value, &raw_header("List-Id")?),
"header" => any(m, value, &raw_header(&test.header)?),
"priority-high" => {
let priority = raw_header("X-Priority")?;
let importance = raw_header("Importance")?;
priority
.iter()
.any(|p| p.trim_start().starts_with('1') || p.trim_start().starts_with('2'))
|| importance
.iter()
.any(|i| i.trim().eq_ignore_ascii_case("high"))
}
_ => return None,
})
}
/// Three-valued evaluation: `None` means "cannot decide from the cache".
pub fn eval(expr: &RuleExpr, message: &CachedMessage) -> Option<bool> {
match expr {
RuleExpr::Test { test } => eval_test(test, message),
RuleExpr::Not { not } => eval(not, message).map(|value| !value),
RuleExpr::Group { op, args } => {
let mut unknown = false;
for arg in args {
match (op.as_str(), eval(arg, message)) {
("allof", Some(false)) => return Some(false),
("anyof", Some(true)) => return Some(true),
(_, None) => unknown = true,
_ => {}
}
}
if unknown { None } else { Some(op == "allof") }
}
}
}
fn load_cache(conn: &Connection, folder: &str) -> Result<HashMap<u32, CachedMessage>, String> {
let mut stmt = conn
.prepare(crate::dbq!(db_filter_match::Q_001))
.map_err(|e| format!("Failed to prepare filter cache query: {e}"))?;
let rows = stmt
.query_map(params![folder], |row| {
let uid: i64 = row.get(0)?;
let to_raw: String = row.get(3)?;
let cc_raw: String = row.get(4)?;
let addresses = |raw: &str| -> Vec<String> {
parse_address_list(raw)
.into_iter()
.map(|entry| entry.address)
.collect()
};
let to_header = parse_address_list(&to_raw)
.into_iter()
.map(|entry| match entry.name {
Some(name) if !name.is_empty() => format!("{name} <{}>", entry.address),
_ => entry.address,
})
.collect::<Vec<_>>()
.join(", ");
Ok((
uid.max(0) as u32,
CachedMessage {
from_address: row.get(1)?,
from_name: row.get(2)?,
to: addresses(&to_raw),
cc: addresses(&cc_raw),
to_header,
subject: row.get(5)?,
size: row.get(6)?,
has_attachments: row.get::<_, i64>(7)? != 0,
raw_headers: row.get(8)?,
},
))
})
.map_err(|e| format!("Failed to read filter cache: {e}"))?;
let mut cache = HashMap::new();
for row in rows {
let (uid, message) = row.map_err(|e| format!("Failed to read cached message: {e}"))?;
cache.insert(uid, message);
}
Ok(cache)
}
/// Combines the two IMAP result sets with exact cache evaluation.
pub fn resolve_matches(
rule: &RuleExpr,
upper: &[u32],
lower: &[u32],
cache: &HashMap<u32, CachedMessage>,
) -> (Vec<u32>, usize) {
let lower_set: HashSet<u32> = lower.iter().copied().collect();
let candidates: std::collections::BTreeSet<u32> =
upper.iter().chain(lower.iter()).copied().collect();
let mut matched: Vec<u32> = Vec::new();
let mut uncertain = 0usize;
for uid in candidates {
let decided = if lower_set.contains(&uid) {
Some(true)
} else {
cache.get(&uid).and_then(|message| eval(rule, message))
};
match decided {
Some(true) => matched.push(uid),
Some(false) => {}
None => uncertain += 1,
}
}
(matched, uncertain)
}
fn build_creds(session: &SessionState) -> ImapCredentials {
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(),
}
}
/// `POST /api/filters/match` — which messages already in `folder` the rule matches.
pub async fn match_filter_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Json(body): Json<FilterMatchRequest>,
) -> Result<Response, AppError> {
if body.folder.trim().is_empty() || body.folder.contains(['\r', '\n']) {
return Err(AppError::BadRequest("A folder is required".to_string()));
}
let mut leaves = 0usize;
validate_rule(&body.rule, 0, &mut leaves).map_err(AppError::BadRequest)?;
let bounds = bounds(&body.rule);
let creds = build_creds(&session);
let upper = imap_client
.search_uids(&creds, &body.folder, &bounds.upper)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP search failed: {e}")))?;
let lower = if bounds.lower == bounds.upper {
upper.clone()
} else {
imap_client
.search_uids(&creds, &body.folder, &bounds.lower)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP search failed: {e}")))?
};
let needs_cache = upper.len() > lower.len();
let cache = if needs_cache {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
load_cache(&conn, &body.folder).map_err(AppError::InternalError)?
} else {
HashMap::new()
};
let (mut uids, uncertain) = resolve_matches(&body.rule, &upper, &lower, &cache);
let matched = uids.len();
let truncated = uids.len() > MAX_RETURNED_UIDS;
uids.truncate(MAX_RETURNED_UIDS);
Ok(Json(FilterMatchResponse {
folder: body.folder,
matched,
uids,
truncated,
uncertain,
})
.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
fn test(kind: &str, match_kind: &str, value: &str) -> RuleExpr {
serde_json::from_value(serde_json::json!({
"test": {"kind": kind, "match": match_kind, "value": value}
}))
.unwrap()
}
fn message(from: &str, subject: &str) -> CachedMessage {
CachedMessage {
from_address: from.to_string(),
subject: subject.to_string(),
..CachedMessage::default()
}
}
#[test]
fn substring_tests_are_exact_in_both_bounds() {
let b = bounds(&test("header-from", "contains", "alice@"));
assert_eq!(b.upper, "FROM \"alice@\"");
assert_eq!(b.lower, b.upper);
// Sieve `address` tests ignore display names, IMAP FROM does not.
let address = bounds(&test("from", "contains", "alice@"));
assert_eq!(address.upper, "FROM \"alice@\"");
assert_eq!(address.lower, "NOT ALL");
}
#[test]
fn exact_and_wildcard_tests_only_widen_the_upper_bound() {
let b = bounds(&test("subject", "is", "Invoice"));
assert_eq!(b.upper, "SUBJECT \"Invoice\"");
assert_eq!(b.lower, "NOT ALL");
let w = bounds(&test("subject", "matches", "Invoice *2026*"));
assert_eq!(w.upper, "SUBJECT \"Invoice \"");
}
#[test]
fn negation_swaps_bounds_and_non_ascii_falls_back_to_the_cache() {
let rule: RuleExpr = serde_json::from_value(serde_json::json!({
"not": {"test": {"kind": "subject", "match": "contains", "value": "faktura"}}
}))
.unwrap();
let b = bounds(&rule);
assert_eq!(b.upper, "NOT (SUBJECT \"faktura\")");
assert_eq!(b.lower, b.upper);
let polish = bounds(&test("subject", "contains", "zażółć"));
assert_eq!(polish.upper, "ALL");
assert_eq!(polish.lower, "NOT ALL");
}
#[test]
fn groups_use_binary_imap_or_and_parenthesised_and() {
let rule: RuleExpr = serde_json::from_value(serde_json::json!({
"op": "anyof",
"args": [
{"test": {"kind": "subject", "value": "a"}},
{"test": {"kind": "subject", "value": "b"}},
{"test": {"kind": "subject", "value": "c"}}
]
}))
.unwrap();
assert_eq!(
bounds(&rule).upper,
"OR (SUBJECT \"a\") (OR (SUBJECT \"b\") (SUBJECT \"c\"))"
);
let all: RuleExpr = serde_json::from_value(serde_json::json!({
"op": "allof",
"args": [{"test": {"kind": "size-over", "value": "2M"}}, {"test": {"kind": "subject", "value": "x"}}]
}))
.unwrap();
assert_eq!(bounds(&all).upper, "(LARGER 2097152) (SUBJECT \"x\")");
}
#[test]
fn quotes_in_values_are_escaped() {
assert_eq!(
bounds(&test("subject", "contains", "say \"hi\"\\")).upper,
"SUBJECT \"say \\\"hi\\\"\\\\\""
);
}
#[test]
fn cache_evaluation_decides_exact_and_wildcard_matches() {
let rule = test("subject", "is", "Invoice");
assert_eq!(eval(&rule, &message("a@x", "invoice")), Some(true));
assert_eq!(eval(&rule, &message("a@x", "Invoice 12")), Some(false));
let pattern = test("from-domain", "matches", "*.example.com");
assert_eq!(
eval(&pattern, &message("a@mail.example.com", "")),
Some(true)
);
assert_eq!(eval(&pattern, &message("a@example.org", "")), Some(false));
}
#[test]
fn header_tests_without_cached_headers_are_undecided() {
let rule = test("list-id", "is", "news.example.com");
assert_eq!(eval(&rule, &message("a@x", "")), None);
let mut cached = message("a@x", "");
cached.raw_headers =
Some("List-Id: Weekly\r\n <news.example.com>\r\nSubject: x".to_string());
let contains = test("list-id", "contains", "news.example.com");
assert_eq!(eval(&contains, &cached), Some(true));
}
#[test]
fn resolve_combines_imap_bounds_with_the_cache() {
let rule = test("subject", "is", "Invoice");
let mut cache = HashMap::new();
cache.insert(1, message("a@x", "Invoice"));
cache.insert(2, message("a@x", "Invoice reminder"));
let (matched, uncertain) = resolve_matches(&rule, &[1, 2, 3], &[], &cache);
assert_eq!(matched, vec![1]);
assert_eq!(uncertain, 1);
let (matched, uncertain) = resolve_matches(&rule, &[4, 5], &[4, 5], &HashMap::new());
assert_eq!(matched, vec![4, 5]);
assert_eq!(uncertain, 0);
}
#[test]
fn rules_are_validated() {
let mut leaves = 0;
assert!(validate_rule(&test("header", "contains", "x"), 0, &mut leaves).is_err());
let mut leaves = 0;
assert!(validate_rule(&test("size-over", "contains", "lots"), 0, &mut leaves).is_err());
let mut leaves = 0;
assert!(validate_rule(&test("subject", "contains", "a\r\nb"), 0, &mut leaves).is_err());
let mut leaves = 0;
assert!(validate_rule(&test("has-attachment", "contains", ""), 0, &mut leaves).is_ok());
assert_eq!(parse_size("10K"), Some(10 * 1024));
assert_eq!(parse_size("3"), Some(3));
assert_eq!(parse_size("1X"), None);
}
#[test]
fn wildcards_follow_sieve_semantics() {
assert!(wildcard_match("a*c", "abbbc"));
assert!(wildcard_match("a?c", "abc"));
assert!(!wildcard_match("a?c", "abbc"));
assert!(wildcard_match("*", ""));
}
#[test]
fn wildcard_only_header_means_header_exists() {
let rule = RuleExpr::Test {
test: RuleTest {
kind: "list-id".to_string(),
match_kind: MatchKind::Matches,
value: "*".to_string(),
header: String::new(),
},
};
let b = bounds(&rule);
assert_eq!(b.upper, "HEADER List-Id \"\"");
assert_eq!(b.lower, b.upper);
}
}
+2
View File
@@ -6,6 +6,7 @@ pub mod contacts;
pub mod diagnostics;
pub mod display_preferences;
pub mod drafts;
pub mod filters;
pub mod folder_counts;
pub mod folder_mgmt;
pub mod folder_preferences;
@@ -425,6 +426,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
post(tags::tag_message_handler).get(tags::list_tag_messages_handler),
)
.route("/tags/{id}/messages/bulk", post(tags::bulk_tag_handler))
.route("/filters/match", post(filters::match_filter_handler))
.route(
"/tags/{id}/messages/{folder}/{uid}",
delete(tags::untag_message_handler),
+96
View File
@@ -2977,3 +2977,99 @@ async fn sends_with_attachments_release_their_build_slot() {
}
assert_eq!(mock_imap.appended.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn filter_match_narrows_imap_candidates_from_the_cache() {
let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap();
let config = test_config_with_imap(
static_dir.path().to_str().unwrap(),
data_dir.path().to_str().unwrap(),
);
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
for (uid, from) in [(1u32, "news@shop.example"), (2u32, "friend@example.com")] {
crate::db::messages::upsert_message(
&conn,
"INBOX",
uid,
None,
None,
None,
"Hello",
from,
"",
"[]",
"[]",
"2024-01-01",
"",
100,
false,
"",
None,
)
.unwrap();
}
drop(conn);
let header = |uid: u32, address: &str| ImapMessageHeader {
uid,
subject: Some("Hello".to_string()),
from: vec![EmailAddress {
name: None,
address: address.to_string(),
}],
to: vec![],
date: None,
flags: vec![],
has_attachments: false,
size: 100,
message_id: None,
in_reply_to: None,
references: None,
cc: vec![],
reaction: None,
};
// The mock answers every search with all UIDs, i.e. a pure superset, so
// the exact answer has to come from the cache.
let mock = MockImapClient::new().with_headers(vec![
header(1, "news@shop.example"),
header(2, "friend@example.com"),
]);
let imap_client: Arc<dyn ImapClient> = Arc::new(mock);
let app = create_router(
config,
store,
imap_client,
test_smtp_client(),
test_search_engine(data_dir.path().to_str().unwrap()),
test_event_bus(),
test_idle_manager(),
);
let mut req = Request::builder()
.method("POST")
.uri("/api/filters/match")
.header("x-requested-with", "XMLHttpRequest")
.header("content-type", "application/json");
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
req = req.header(name, value);
}
let body = r#"{"folder":"INBOX","rule":{"test":{"kind":"from-domain","match":"is","value":"shop.example"}}}"#;
let response = app
.oneshot(req.body(Body::from(body)).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["uids"], serde_json::json!([1]));
assert_eq!(json["matched"], 1);
assert_eq!(json["uncertain"], 0);
}
+20 -1
View File
@@ -222,10 +222,19 @@
}
.message-subject {
flex: 0 1 auto;
min-width: min(100%, 14ch);
max-width: 100%;
font-size: 11px;
}
.message-subject-line .message-tags {
flex: 0 0 auto;
}
.message-snippet {
flex: 1 1 0;
min-width: 0;
color: var(--muted);
font-size: 10px;
}
@@ -1067,7 +1076,7 @@ html[data-reduce-motion="true"] #thread-toggle svg {
.message-row.flagged {
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--danger) 72%, transparent);
}
.search-highlight {
@@ -1157,6 +1166,16 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
@container reader-pane (max-width: 820px) {
.reader-toolbar .button span {
display: none;
}
.reader-toolbar .button {
padding-inline: 6px;
}
}
@container reader-pane (max-width: 640px) {
.reader-toolbar {
gap: 1px;
+231
View File
@@ -358,6 +358,7 @@
margin: 0;
color: var(--muted);
line-height: 1.5;
white-space: pre-line;
}
.app-prompt-control {
@@ -444,6 +445,24 @@
border-radius: var(--radius-control);
background: var(--panel-2);
box-shadow: 0 12px 28px var(--shadow);
overflow-y: auto;
overscroll-behavior: contain;
}
.context-submenu {
z-index: 1001;
max-width: min(380px, calc(100vw - 12px));
}
.context-menu button.has-submenu[aria-expanded="true"] {
background: var(--hover);
}
.context-menu-arrow {
margin-left: auto;
color: var(--muted);
font-size: 15px;
line-height: 1;
}
.context-menu button {
@@ -1123,6 +1142,58 @@
justify-self: start;
}
.sieve-exception-row {
border-style: dashed;
}
.sieve-templates {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.sieve-template-chip {
min-height: 26px;
padding: 3px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--field);
color: var(--text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.sieve-template-chip:hover,
.sieve-template-chip:focus-visible {
border-color: var(--accent);
color: var(--accent);
}
.sieve-apply-row {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px;
}
.sieve-apply-row label {
display: grid;
gap: 4px;
min-width: 200px;
flex: 1 1 220px;
}
.sieve-apply-result {
min-height: 18px;
font-size: 12px;
line-height: 1.45;
}
.sieve-apply-result:empty {
display: none;
}
.sieve-rule-preview {
display: grid;
gap: 9px;
@@ -2562,3 +2633,163 @@ kbd {
margin: 0;
white-space: nowrap;
}
/* 0.2.24: theme schedule, account colour, contrast hint, list skeleton and
a short cross-fade when the theme or interface style changes. */
.theme-schedule-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.theme-schedule-row.hidden {
display: none;
}
.account-color-row {
display: grid;
align-content: start;
}
.account-color-row > span {
margin-bottom: 5px;
color: var(--muted);
font-size: 11px;
font-weight: 650;
}
.account-color-row small {
margin-top: 4px;
}
.account-color-row .color-input {
grid-template-columns: 16px 44px;
justify-content: start;
}
.account-color-row .color-input input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
}
.account-color-row .color-input {
display: flex;
align-items: center;
gap: 8px;
}
.contrast-warning {
grid-column: 1 / -1;
margin: 0;
padding: 6px 9px;
/* Fixed colours on purpose: the warning must stay readable precisely when
the user's own text/panel colours are not. */
border: 1px solid #d9a93a;
border-radius: var(--radius-control);
background: #fff4d6;
color: #3d2e00;
font-size: 11px;
}
.contrast-warning.hidden {
display: none;
}
html[data-account-color="true"] .appbar {
position: relative;
}
html[data-account-color="true"] .appbar::before {
content: "";
position: absolute;
z-index: 1;
inset: 0 0 auto;
height: 3px;
background: var(--account-color);
pointer-events: none;
}
html[data-account-color="true"] #account-email::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 5px;
border-radius: 50%;
background: var(--account-color);
vertical-align: 1px;
}
.message-skeleton-row {
display: grid;
grid-template-columns: 19px minmax(0, 1fr) 46px;
grid-template-rows: auto auto;
align-items: center;
gap: 7px 8px;
min-height: var(--row-height);
padding: 9px;
border-bottom: 1px solid var(--line);
}
.skeleton-dot,
.skeleton-line {
display: block;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 9%, transparent);
animation: rustbox-skeleton-pulse 1.3s ease-in-out infinite;
}
.skeleton-dot {
grid-row: 1 / 3;
width: 8px;
height: 8px;
}
.skeleton-line {
width: var(--w, 60%);
height: 8px;
}
.skeleton-date {
width: 100%;
}
.skeleton-wide {
grid-column: 2 / 4;
height: 7px;
opacity: .75;
}
.message-skeleton-row:nth-child(odd) .skeleton-line {
animation-delay: .15s;
}
@keyframes rustbox-skeleton-pulse {
50% {
opacity: .45;
}
}
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition: background-color .3s ease, color .3s ease, border-color .3s ease, fill .3s ease !important;
}
html[data-reduce-motion="true"] :is(.skeleton-dot, .skeleton-line) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
:is(.skeleton-dot, .skeleton-line) {
animation: none;
}
html.theme-transition,
html.theme-transition * {
transition: none !important;
}
}
+327
View File
@@ -1853,3 +1853,330 @@ html[data-interface-skin="retro"][data-theme="dark"] {
--reader-bg: #232327;
}
/* 0.2.24 predefined styles: light and dark palettes. */
html[data-interface-skin="winxp"][data-theme="light"] {
color-scheme: light;
--bg: #ece9d8;
--panel: #ffffff;
--panel-2: #f6f4ec;
--panel-3: #e3dfcc;
--sidebar: #d6dff7;
--line: #d0ccb8;
--line-strong: #7f9db9;
--text: #000000;
--muted: #3b3b3b;
--subtle: #5f5f5f;
--accent: #245edb;
--accent-strong: #1941a5;
--accent-ink: #ffffff;
--danger: #c0281e;
--warning: #8a6300;
--success: #2e7d1f;
--field: #ffffff;
--selected: #c1d2ee;
--hover: #e8eef9;
--shadow: rgba(0, 0, 0, .25);
--folder-bg: #d6dff7;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="winxp"][data-theme="dark"] {
color-scheme: dark;
--bg: #1b1d24;
--panel: #23262f;
--panel-2: #2a2e39;
--panel-3: #343947;
--sidebar: #1d2433;
--line: #353b4a;
--line-strong: #4d5a78;
--text: #f0f2f7;
--muted: #b6bdcc;
--subtle: #8d95a8;
--accent: #4c8bf5;
--accent-strong: #75a6ff;
--accent-ink: #071226;
--danger: #ff6b5e;
--warning: #e8c15a;
--success: #5cc74a;
--field: #1b1d24;
--selected: #2c4370;
--hover: #2b3040;
--shadow: rgba(0, 0, 0, .5);
--folder-bg: #1d2433;
--message-bg: #23262f;
--reader-bg: #23262f;
}
html[data-interface-skin="osx"][data-theme="light"] {
color-scheme: light;
--bg: #ececec;
--panel: #ffffff;
--panel-2: #f5f5f5;
--panel-3: #e3e3e3;
--sidebar: #d6dde5;
--line: #c8c8c8;
--line-strong: #8e8e8e;
--text: #000000;
--muted: #4a4a4a;
--subtle: #6d6d6d;
--accent: #3875d7;
--accent-strong: #1f5bbf;
--accent-ink: #ffffff;
--danger: #c4302b;
--warning: #8c6a00;
--success: #2d8a2d;
--field: #ffffff;
--selected: #c5d8f4;
--hover: #edf3fb;
--shadow: rgba(0, 0, 0, .3);
--folder-bg: #d6dde5;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="osx"][data-theme="dark"] {
color-scheme: dark;
--bg: #1e1f22;
--panel: #26272b;
--panel-2: #2d2e33;
--panel-3: #36373d;
--sidebar: #2a2c31;
--line: #3a3b41;
--line-strong: #5c5e66;
--text: #ececee;
--muted: #b4b5bb;
--subtle: #8b8c93;
--accent: #5a9cf8;
--accent-strong: #82b4ff;
--accent-ink: #06152b;
--danger: #ff6961;
--warning: #e6c35a;
--success: #5ccf5c;
--field: #1e1f22;
--selected: #2f4b73;
--hover: #303137;
--shadow: rgba(0, 0, 0, .5);
--folder-bg: #2a2c31;
--message-bg: #26272b;
--reader-bg: #26272b;
}
html[data-interface-skin="ios6"][data-theme="light"] {
color-scheme: light;
--bg: #c5ccd4;
--panel: #ffffff;
--panel-2: #f7f7f7;
--panel-3: #e8ebef;
--sidebar: #e3e7ec;
--line: #cbd2d9;
--line-strong: #9aa5b1;
--text: #000000;
--muted: #4c566a;
--subtle: #6a7583;
--accent: #3d6aa6;
--accent-strong: #2c5285;
--accent-ink: #ffffff;
--danger: #c8262b;
--warning: #8a6a00;
--success: #3a8b2b;
--field: #ffffff;
--selected: #cfe0f7;
--hover: #eef2f6;
--shadow: rgba(0, 0, 0, .35);
--folder-bg: #e3e7ec;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="ios6"][data-theme="dark"] {
color-scheme: dark;
--bg: #15181d;
--panel: #1f2329;
--panel-2: #262b32;
--panel-3: #2f353e;
--sidebar: #1a1e24;
--line: #343b45;
--line-strong: #56606d;
--text: #f2f4f7;
--muted: #b8c0cb;
--subtle: #8a94a1;
--accent: #6a9ae0;
--accent-strong: #8fb3ea;
--accent-ink: #08142a;
--danger: #ff6b6b;
--warning: #e9c25a;
--success: #6fcf5c;
--field: #15181d;
--selected: #2c4366;
--hover: #29303a;
--shadow: rgba(0, 0, 0, .55);
--folder-bg: #1a1e24;
--message-bg: #1f2329;
--reader-bg: #1f2329;
}
html[data-interface-skin="win7"][data-theme="light"] {
color-scheme: light;
--bg: #dbe6f4;
--panel: #ffffff;
--panel-2: #f5f9fd;
--panel-3: #e3ecf7;
--sidebar: #eef4fb;
--line: #d5dfea;
--line-strong: #8ea3bd;
--text: #1e1e1e;
--muted: #3f4c5c;
--subtle: #5f7086;
--accent: #1f6fc4;
--accent-strong: #175aa0;
--accent-ink: #ffffff;
--danger: #c42b1c;
--warning: #8a6200;
--success: #2e7d32;
--field: #ffffff;
--selected: #cce8ff;
--hover: #e5f3ff;
--shadow: rgba(0, 0, 0, .28);
--folder-bg: #eef4fb;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="win7"][data-theme="dark"] {
color-scheme: dark;
--bg: #0f1724;
--panel: #18212f;
--panel-2: #1e2939;
--panel-3: #263347;
--sidebar: #142033;
--line: #2a384d;
--line-strong: #456184;
--text: #eef3fa;
--muted: #b2c0d3;
--subtle: #8395ad;
--accent: #5ba7ff;
--accent-strong: #86beff;
--accent-ink: #04111f;
--danger: #ff6b5e;
--warning: #ffd166;
--success: #6ad36a;
--field: #0f1724;
--selected: #24466e;
--hover: #213046;
--shadow: rgba(0, 0, 0, .5);
--folder-bg: #142033;
--message-bg: #18212f;
--reader-bg: #18212f;
}
html[data-interface-skin="frutiger-aero"][data-theme="light"] {
color-scheme: light;
--bg: #e6f6ff;
--panel: #ffffff;
--panel-2: #f3fbff;
--panel-3: #dff2fb;
--sidebar: #eefaf3;
--line: #cde7f2;
--line-strong: #8fc3d9;
--text: #0b2a3a;
--muted: #2f5668;
--subtle: #557a8a;
--accent: #0b7fc0;
--accent-strong: #08679c;
--accent-ink: #ffffff;
--danger: #d23a3a;
--warning: #8a6800;
--success: #2f8a2f;
--field: #ffffff;
--selected: #c9ecff;
--hover: #ebf8ff;
--shadow: rgba(0, 70, 110, .18);
--folder-bg: #eefaf3;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="frutiger-aero"][data-theme="dark"] {
color-scheme: dark;
--bg: #04131f;
--panel: #0a2030;
--panel-2: #0e293b;
--panel-3: #133449;
--sidebar: #062433;
--line: #16405a;
--line-strong: #2d6987;
--text: #e8f7ff;
--muted: #a9d3e6;
--subtle: #7aa9bf;
--accent: #39c0ff;
--accent-strong: #74d4ff;
--accent-ink: #02121d;
--danger: #ff7a7a;
--warning: #ffd166;
--success: #6ee36e;
--field: #04131f;
--selected: #164c6e;
--hover: #0f3046;
--shadow: rgba(0, 0, 0, .5);
--folder-bg: #062433;
--message-bg: #0a2030;
--reader-bg: #0a2030;
}
html[data-interface-skin="beos"][data-theme="light"] {
color-scheme: light;
--bg: #d8d8d8;
--panel: #ffffff;
--panel-2: #f0f0f0;
--panel-3: #dedede;
--sidebar: #e6e6e6;
--line: #b8b8b8;
--line-strong: #7a7a7a;
--text: #000000;
--muted: #3a3a3a;
--subtle: #5e5e5e;
--accent: #3366bb;
--accent-strong: #24508f;
--accent-ink: #ffffff;
--danger: #c01818;
--warning: #806000;
--success: #2a7a2a;
--field: #ffffff;
--selected: #d0dcf2;
--hover: #ececec;
--shadow: rgba(0, 0, 0, .3);
--folder-bg: #e6e6e6;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="beos"][data-theme="dark"] {
color-scheme: dark;
--bg: #1c1c1c;
--panel: #262626;
--panel-2: #2e2e2e;
--panel-3: #383838;
--sidebar: #222222;
--line: #3e3e3e;
--line-strong: #5e5e5e;
--text: #eeeeee;
--muted: #b8b8b8;
--subtle: #8c8c8c;
--accent: #5f8fe0;
--accent-strong: #86aaf0;
--accent-ink: #08142a;
--danger: #ff6b6b;
--warning: #ffcb2d;
--success: #6fcf6f;
--field: #1c1c1c;
--selected: #2e4470;
--hover: #303030;
--shadow: rgba(0, 0, 0, .5);
--folder-bg: #222222;
--message-bg: #262626;
--reader-bg: #262626;
}
+181 -50
View File
@@ -7,9 +7,8 @@
* 3. the structural tokens below, consumed only when the user selects
* "Immersive" style depth (58-skin-immersive.css).
*
* Gradients are opt-in through their own Appearance switch (skin_gradients):
* every gradient token has a flat value here and its gradient form only in a
* block scoped to html[data-skin-gradients="true"].
* Tokens here are always flat. Gradients live in 59-skin-gradients.css and
* only apply with the separate Appearance switch (skin_gradients).
*
* Generated from one table; keep every style listed here.
*/
@@ -106,15 +105,6 @@ html[data-interface-skin="roundcube-classic"][data-theme="dark"] {
--skin-appbar-bg: #313131;
--skin-appbar-fg: #f2f2f2;
}
html[data-skin-gradients="true"][data-interface-skin="roundcube-classic"] {
--skin-primary-bg: linear-gradient(color-mix(in srgb, var(--accent) 78%, white), var(--accent));
}
html[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="light"] {
--skin-appbar-bg: linear-gradient(#f4f4f4, #cccccc);
}
html[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="dark"] {
--skin-appbar-bg: linear-gradient(#3a3a3a, #292929);
}
html[data-interface-skin="roundcube-larry"] {
--skin-radius-control: 3px;
@@ -553,12 +543,6 @@ html[data-interface-skin="dracula"][data-theme="dark"] {
--skin-primary-bg: var(--accent);
--skin-primary-fg: #1d1530;
}
html[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="light"] {
--skin-primary-bg: linear-gradient(135deg, #644ac9, #a3144d);
}
html[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="dark"] {
--skin-primary-bg: linear-gradient(135deg, #bd93f9, #ff79c6);
}
html[data-interface-skin="catppuccin"] {
--skin-row-inset: 6px;
@@ -623,14 +607,6 @@ html[data-interface-skin="tokyo-night"][data-theme="dark"] {
--skin-marker-color: #bb9af7;
--skin-primary-bg: var(--accent);
}
html[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="light"] {
--skin-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(46, 125, 233, .14), transparent 60%), var(--bg);
--skin-primary-bg: linear-gradient(135deg, var(--accent), #9854f1);
}
html[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="dark"] {
--skin-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(122, 162, 247, .12), transparent 60%), var(--bg);
--skin-primary-bg: linear-gradient(135deg, var(--accent), #bb9af7);
}
html[data-interface-skin="rose-pine"] {
--skin-row-inset: 6px;
@@ -684,14 +660,6 @@ html[data-interface-skin="synthwave"][data-theme="dark"] {
--skin-primary-shadow: 0 0 14px rgba(255, 126, 219, .55);
--skin-marker-color: #72f1b8;
}
html[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="light"] {
--skin-backdrop: linear-gradient(180deg, #f3e9ff 0%, #ffe3f3 100%);
--skin-primary-bg: linear-gradient(90deg, #d6249f, #7c3aed);
}
html[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] {
--skin-backdrop: linear-gradient(180deg, #1a1029 0%, #241038 55%, #3a1145 100%);
--skin-primary-bg: linear-gradient(90deg, #ff7edb, #f97e72);
}
html[data-interface-skin="paper"] {
--skin-shell-gap: 0px;
@@ -739,9 +707,6 @@ html[data-interface-skin="terminal"][data-theme="dark"] {
--skin-pane-shadow: 0 0 0 1px rgba(57, 255, 106, .06), 0 0 18px -8px rgba(57, 255, 106, .35);
--skin-backdrop: var(--bg);
}
html[data-skin-gradients="true"][data-interface-skin="terminal"][data-theme="dark"] {
--skin-backdrop: repeating-linear-gradient(0deg, rgba(57, 255, 106, .035) 0 1px, transparent 1px 3px), var(--bg);
}
html[data-interface-skin="aqua"] {
--skin-row-inset: 6px;
@@ -776,15 +741,6 @@ html[data-interface-skin="aqua"][data-theme="dark"] {
--skin-backdrop: var(--bg);
--skin-pane-border: 1px solid rgba(255, 255, 255, .08);
}
html[data-skin-gradients="true"][data-interface-skin="aqua"] {
--skin-primary-bg: linear-gradient(180deg, color-mix(in srgb, var(--accent) 72%, white), var(--accent));
}
html[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="light"] {
--skin-backdrop: radial-gradient(900px 520px at 8% 0%, #bfe0ff 0%, transparent 60%), radial-gradient(900px 620px at 100% 100%, #e8d6ff 0%, transparent 55%), linear-gradient(135deg, #e6eef8, #f4f0fb);
}
html[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="dark"] {
--skin-backdrop: radial-gradient(900px 520px at 8% 0%, rgba(74, 163, 255, .28), transparent 60%), radial-gradient(900px 620px at 100% 100%, rgba(175, 82, 222, .22), transparent 55%), #0b1220;
}
html[data-interface-skin="linear"] {
--skin-font: "Inter Variable", Inter, var(--font-sans);
@@ -888,9 +844,184 @@ html[data-interface-skin="retro"][data-theme="dark"] {
--skin-appbar-bg: #1c2f63;
--skin-selected-bg: #2b3f6b;
}
html[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="light"] {
--skin-appbar-bg: linear-gradient(90deg, #000080, #1084d0);
html[data-interface-skin="winxp"] {
--skin-font: Tahoma, "Segoe UI", Verdana, sans-serif;
--skin-heading-weight: 700;
--skin-heading-tracking: 0;
--skin-radius-control: 3px;
--skin-radius-panel: 8px;
--skin-radius-row: 0px;
--skin-radius-avatar: 3px;
--skin-shell-gap: 6px;
--skin-shell-padding: 0 6px 6px;
--skin-pane-border: 1px solid var(--line-strong);
--skin-pane-shadow: none;
--skin-appbar-fg: #ffffff;
--skin-search-bg: #ffffff;
--skin-search-fg: #000000;
--skin-search-border: #7f9db9;
--skin-selected-fg: #ffffff;
--skin-selected-muted: #dfe8fa;
--skin-compose-bg: #3c9d29;
--skin-compose-fg: #ffffff;
--skin-radius-compose: 4px 14px 14px 4px;
--skin-section-transform: none;
--skin-section-tracking: 0;
}
html[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="dark"] {
--skin-appbar-bg: linear-gradient(90deg, #1c2f63, #3a6fd8);
html[data-interface-skin="winxp"][data-theme="light"] {
--skin-appbar-bg: #245edb;
--skin-sidebar-bg: #c7d7f7;
--skin-selected-bg: #316ac5;
}
html[data-interface-skin="winxp"][data-theme="dark"] {
--skin-appbar-bg: #1c3f8f;
--skin-selected-bg: #2c4370;
}
html[data-interface-skin="osx"] {
--skin-shell-gap: 0px;
--skin-shell-padding: 0;
--skin-pane-border: 0 solid transparent;
--skin-pane-divider: 1px solid var(--line);
--skin-pane-shadow: none;
--skin-radius-panel: 0px;
--skin-font: "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif;
--skin-heading-weight: 700;
--skin-heading-tracking: 0;
--skin-radius-control: 5px;
--skin-radius-button: 999px;
--skin-radius-row: 0px;
--skin-appbar-border: var(--line-strong);
--skin-search-radius: 999px;
--skin-selected-bg: #3875d7;
--skin-selected-fg: #ffffff;
--skin-selected-muted: #dbe7fa;
--skin-folder-active-bg: #3875d7;
--skin-folder-active-fg: #ffffff;
--skin-section-tracking: .04em;
}
html[data-interface-skin="osx"][data-theme="light"] {
--skin-appbar-bg: #d4d4d4;
}
html[data-interface-skin="osx"][data-theme="dark"] {
--skin-appbar-bg: #2f3035;
}
html[data-interface-skin="ios6"] {
--skin-shell-gap: 0px;
--skin-shell-padding: 0;
--skin-pane-border: 0 solid transparent;
--skin-pane-divider: 1px solid var(--line);
--skin-pane-shadow: none;
--skin-radius-panel: 0px;
--skin-font: "Helvetica Neue", Helvetica, Arial, sans-serif;
--skin-heading-weight: 700;
--skin-heading-tracking: 0;
--skin-radius-control: 5px;
--skin-radius-row: 0px;
--skin-radius-avatar: 4px;
--skin-appbar-fg: #ffffff;
--skin-search-bg: #ffffff;
--skin-search-fg: #000000;
--skin-search-border: transparent;
--skin-search-radius: 999px;
--skin-selected-bg: #1a7ae6;
--skin-selected-fg: #ffffff;
--skin-selected-muted: #dbeaff;
--skin-section-transform: none;
--skin-section-tracking: 0;
}
html[data-interface-skin="ios6"][data-theme="light"] {
--skin-appbar-bg: #6d84a2;
}
html[data-interface-skin="ios6"][data-theme="dark"] {
--skin-appbar-bg: #2a313b;
}
html[data-interface-skin="win7"] {
--skin-font: "Segoe UI", Tahoma, sans-serif;
--skin-heading-weight: 600;
--skin-heading-tracking: 0;
--skin-radius-control: 3px;
--skin-radius-panel: 8px;
--skin-radius-row: 3px;
--skin-appbar-bg: transparent;
--skin-glass: blur(14px) saturate(1.4);
--skin-pane-shadow: 0 8px 26px -10px rgba(0, 0, 0, .45);
--skin-search-bg: #ffffff;
--skin-search-fg: #000000;
--skin-search-border: transparent;
--skin-folder-active-fg: var(--text);
--skin-section-transform: none;
--skin-section-tracking: 0;
}
html[data-interface-skin="win7"][data-theme="light"] {
--skin-backdrop: #3a7bd5;
--skin-appbar-fg: #ffffff;
--skin-folder-active-bg: #cce8ff;
}
html[data-interface-skin="win7"][data-theme="dark"] {
--skin-backdrop: #0b1a2e;
--skin-folder-active-bg: #24466e;
}
html[data-interface-skin="frutiger-aero"] {
--skin-row-inset: 6px;
--skin-row-gap: 2px;
--skin-row-divider: transparent;
--skin-font: "Segoe UI", "Trebuchet MS", sans-serif;
--skin-heading-weight: 600;
--skin-radius-control: 12px;
--skin-radius-button: 999px;
--skin-radius-panel: 18px;
--skin-radius-row: 12px;
--skin-shell-gap: 12px;
--skin-shell-padding: 0 12px 12px;
--skin-glass: blur(16px) saturate(1.6);
--skin-pane-bg: color-mix(in srgb, var(--panel) 76%, transparent);
--skin-reader-bg: color-mix(in srgb, var(--panel) 86%, transparent);
--skin-sidebar-bg: color-mix(in srgb, var(--sidebar) 62%, transparent);
--skin-pane-shadow: 0 14px 34px -16px var(--shadow);
--skin-appbar-bg: transparent;
--skin-search-radius: 999px;
--skin-section-transform: none;
--skin-section-tracking: .02em;
}
html[data-interface-skin="frutiger-aero"][data-theme="light"] {
--skin-backdrop: #bfe9ff;
--skin-pane-border: 1px solid rgba(255, 255, 255, .75);
}
html[data-interface-skin="frutiger-aero"][data-theme="dark"] {
--skin-backdrop: #04131f;
--skin-pane-border: 1px solid rgba(120, 220, 255, .14);
}
html[data-interface-skin="beos"] {
--skin-font: "Swis721 BT", Verdana, "DejaVu Sans", sans-serif;
--skin-heading-weight: 700;
--skin-heading-tracking: 0;
--skin-radius-control: 2px;
--skin-radius-panel: 0px;
--skin-radius-row: 0px;
--skin-radius-avatar: 0px;
--skin-shell-gap: 6px;
--skin-shell-padding: 0 6px 6px;
--skin-pane-border: 1px solid var(--line-strong);
--skin-pane-shadow: 2px 2px 0 rgba(0, 0, 0, .25);
--skin-appbar-fg: #000000;
--skin-search-bg: #ffffff;
--skin-search-fg: #000000;
--skin-search-border: #7a7a7a;
--skin-search-radius: 0px;
--skin-section-transform: none;
--skin-section-tracking: 0;
}
html[data-interface-skin="beos"][data-theme="light"] {
--skin-appbar-bg: #ffcb00;
--skin-control-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #999999;
}
html[data-interface-skin="beos"][data-theme="dark"] {
--skin-appbar-bg: #e0b000;
--skin-control-shadow: inset 1px 1px 0 #555555, inset -1px -1px 0 #111111;
}
-18
View File
@@ -280,12 +280,6 @@
text-shadow: 0 0 6px color-mix(in srgb, var(--accent) 55%, transparent);
}
:root[data-skin-depth="immersive"][data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] .app-brand strong {
background: linear-gradient(90deg, #ff7edb, #fede5d);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
@media (prefers-reduced-transparency: reduce) {
:root[data-skin-depth="immersive"][data-theme] :is(.appbar, .folder-pane, .message-pane, .reader-pane) {
@@ -293,15 +287,3 @@
backdrop-filter: none;
}
}
/* Floating panes are a little narrower than the flush layout, so drop the
reader toolbar's button labels a bit earlier than the standard 640px. */
@container reader-pane (max-width: 820px) {
:root[data-skin-depth="immersive"][data-theme] .reader-toolbar .button span {
display: none;
}
:root[data-skin-depth="immersive"][data-theme] .reader-toolbar .button {
padding-inline: 6px;
}
}
+448
View File
@@ -0,0 +1,448 @@
/*
* Style gradients (0.2.24): generated from one table.
*
* Active only with Appearance -> "Style gradients" on, at every style depth,
* for every predefined style except the RustBox custom style. Gradients are
* background-image layers over the colours chosen by the palette and depth;
* the defaults are translucent overlays, signature styles override them.
*/
:root[data-skin-gradients="true"] {
--grad-backdrop: radial-gradient(1100px 560px at 92% -12%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 62%);
--grad-appbar: linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 55%, rgba(0, 0, 0, .06));
--grad-toolbar: linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent);
--grad-sidebar: linear-gradient(180deg, transparent 35%, color-mix(in srgb, var(--accent) 10%, transparent));
--grad-selected: linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent), transparent 85%);
--grad-primary: linear-gradient(180deg, rgba(255, 255, 255, .26), rgba(255, 255, 255, 0) 52%, rgba(0, 0, 0, .10));
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme] {
--grad-primary: linear-gradient(color-mix(in srgb, var(--accent) 78%, white), var(--accent));
--grad-selected: linear-gradient(180deg, rgba(255, 255, 255, .22), rgba(0, 0, 0, .08));
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="dark"] {
--grad-appbar: linear-gradient(#3a3a3a, #292929);
--grad-toolbar: linear-gradient(#3a3a3a, #292929);
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-classic"][data-theme="light"] {
--grad-appbar: linear-gradient(#f4f4f4, #cccccc);
--grad-toolbar: linear-gradient(#f4f4f4, #cccccc);
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] {
--grad-primary: linear-gradient(180deg, #2c89a4, #005d76);
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme="dark"] {
--grad-appbar: linear-gradient(180deg, #24292c, #121517);
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme="light"] {
--grad-appbar: linear-gradient(180deg, #505050, #2c2c2c);
--grad-toolbar: linear-gradient(180deg, #ffffff, #e6e6e6);
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-elastic"][data-theme] {
--grad-toolbar: linear-gradient(180deg, rgba(255, 255, 255, .5), transparent);
}
:root[data-skin-gradients="true"][data-interface-skin="gmail"][data-theme] {
--grad-backdrop: radial-gradient(900px 480px at 0% 0%, color-mix(in srgb, #4285f4 14%, transparent), transparent 60%);
}
:root[data-skin-gradients="true"][data-interface-skin="outlook"][data-theme] {
--grad-appbar: linear-gradient(90deg, rgba(255, 255, 255, .12), rgba(255, 255, 255, 0) 60%);
--grad-primary: linear-gradient(135deg, color-mix(in srgb, var(--accent) 80%, white), var(--accent));
}
:root[data-skin-gradients="true"][data-interface-skin="yahoo"][data-theme] {
--grad-primary: linear-gradient(135deg, #7e1fff, #5f01d1);
}
:root[data-skin-gradients="true"][data-interface-skin="hey"][data-theme] {
--grad-primary: linear-gradient(180deg, color-mix(in srgb, var(--accent) 82%, white), var(--accent));
}
:root[data-skin-gradients="true"][data-interface-skin="nord"][data-theme] {
--grad-backdrop: radial-gradient(1000px 520px at 100% 0%, rgba(136, 192, 208, .18), transparent 60%);
}
:root[data-skin-gradients="true"][data-interface-skin="github"][data-theme="dark"] {
--grad-primary: linear-gradient(180deg, #2ea043, #238636);
}
:root[data-skin-gradients="true"][data-interface-skin="github"][data-theme="light"] {
--grad-primary: linear-gradient(180deg, #2c974b, #1f883d);
}
:root[data-skin-gradients="true"][data-interface-skin="discord"][data-theme] {
--grad-primary: linear-gradient(135deg, #7289da, #5865f2);
}
:root[data-skin-gradients="true"][data-interface-skin="slack"][data-skin-depth="immersive"][data-theme="dark"] {
--grad-sidebar: linear-gradient(180deg, #1f1b24, #19171d);
}
:root[data-skin-gradients="true"][data-interface-skin="slack"][data-skin-depth="immersive"][data-theme="light"] {
--grad-sidebar: linear-gradient(180deg, #4a154b, #3f0e40 60%, #350d36);
}
:root[data-skin-gradients="true"][data-interface-skin="reddit"][data-theme] {
--grad-primary: linear-gradient(135deg, #ff6a2b, #ff4500);
}
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="dark"] {
--grad-primary: linear-gradient(135deg, #bd93f9, #ff79c6);
--grad-backdrop: radial-gradient(1000px 520px at 100% 0%, rgba(255, 121, 198, .14), transparent 60%);
}
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="light"] {
--grad-primary: linear-gradient(135deg, #644ac9, #a3144d);
}
:root[data-skin-gradients="true"][data-interface-skin="catppuccin"][data-theme] {
--grad-primary: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #f5c2e7));
}
:root[data-skin-gradients="true"][data-interface-skin="solarized"][data-theme] {
--grad-selected: linear-gradient(90deg, rgba(181, 137, 0, .18), transparent 80%);
}
:root[data-skin-gradients="true"][data-interface-skin="gruvbox"][data-theme] {
--grad-selected: linear-gradient(90deg, rgba(250, 189, 47, .2), transparent 80%);
}
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="dark"] {
--grad-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(122, 162, 247, .16), transparent 60%);
--grad-primary: linear-gradient(135deg, #7aa2f7, #bb9af7);
}
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="light"] {
--grad-backdrop: radial-gradient(1200px 600px at 85% -10%, rgba(46, 125, 233, .16), transparent 60%);
--grad-primary: linear-gradient(135deg, #2e7de9, #9854f1);
}
:root[data-skin-gradients="true"][data-interface-skin="rose-pine"][data-theme] {
--grad-primary: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #c4a7e7));
}
:root[data-skin-gradients="true"][data-interface-skin="everforest"][data-theme] {
--grad-sidebar: linear-gradient(180deg, transparent, rgba(131, 192, 146, .12));
}
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] {
--grad-backdrop: linear-gradient(180deg, #1a1029 0%, #241038 55%, #3a1145 100%);
--grad-appbar: linear-gradient(90deg, rgba(255, 126, 219, .16), rgba(114, 241, 184, .08));
--grad-primary: linear-gradient(90deg, #ff7edb, #f97e72);
--grad-selected: linear-gradient(90deg, rgba(255, 126, 219, .28), rgba(114, 241, 184, .06));
}
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="light"] {
--grad-backdrop: linear-gradient(180deg, #f3e9ff 0%, #ffe3f3 100%);
--grad-primary: linear-gradient(90deg, #d6249f, #7c3aed);
}
:root[data-skin-gradients="true"][data-interface-skin="paper"][data-theme] {
--grad-toolbar: linear-gradient(180deg, rgba(255, 255, 255, .35), transparent);
--grad-backdrop: none;
}
:root[data-skin-gradients="true"][data-interface-skin="terminal"][data-theme] {
--grad-primary: linear-gradient(180deg, color-mix(in srgb, var(--accent) 18%, transparent), transparent);
}
:root[data-skin-gradients="true"][data-interface-skin="terminal"][data-theme="dark"] {
--grad-backdrop: repeating-linear-gradient(0deg, rgba(57, 255, 106, .035) 0 1px, transparent 1px 3px);
--grad-sidebar: repeating-linear-gradient(0deg, rgba(57, 255, 106, .025) 0 1px, transparent 1px 3px);
}
:root[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme] {
--grad-primary: linear-gradient(180deg, color-mix(in srgb, var(--accent) 72%, white), var(--accent));
}
:root[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="dark"] {
--grad-backdrop: radial-gradient(900px 520px at 8% 0%, rgba(74, 163, 255, .28), transparent 60%), radial-gradient(900px 620px at 100% 100%, rgba(175, 82, 222, .22), transparent 55%);
}
:root[data-skin-gradients="true"][data-interface-skin="aqua"][data-theme="light"] {
--grad-backdrop: radial-gradient(900px 520px at 8% 0%, #bfe0ff 0%, transparent 60%), radial-gradient(900px 620px at 100% 100%, #e8d6ff 0%, transparent 55%);
}
:root[data-skin-gradients="true"][data-interface-skin="linear"][data-theme] {
--grad-backdrop: radial-gradient(900px 480px at 50% -20%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 65%);
}
:root[data-skin-gradients="true"][data-interface-skin="material"][data-theme] {
--grad-primary: linear-gradient(180deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, 0));
}
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme] {
--grad-backdrop: none;
--grad-toolbar: none;
--grad-sidebar: none;
--grad-primary: none;
--grad-selected: none;
}
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="dark"] {
--grad-appbar: linear-gradient(90deg, #1c2f63, #3a6fd8);
}
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme="light"] {
--grad-appbar: linear-gradient(90deg, #000080, #1084d0);
}
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] {
--grad-primary: linear-gradient(180deg, #78c46b 0%, #3c9d29 48%, #2f8a1f 52%, #3a9a2a 100%);
}
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme="dark"] {
--grad-appbar: linear-gradient(180deg, #3a5fae, #1c3f8f 45%, #142c5c);
--grad-toolbar: linear-gradient(180deg, #2c3140, #232731);
--grad-sidebar: linear-gradient(180deg, #22304d, #1a2238);
}
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme="light"] {
--grad-appbar: linear-gradient(180deg, #0058ee 0%, #3a93ff 4%, #288eff 6%, #127dff 8%, #036ffc 10%, #0262ee 14%, #0057e5 20%, #0054e3 24%, #0055eb 56%, #005bf5 66%, #026afe 76%, #0062ef 86%, #0052d6 92%, #0040ab 94%, #003092 100%);
--grad-toolbar: linear-gradient(180deg, #fefefe, #ece9d8);
--grad-sidebar: linear-gradient(180deg, #d4e0fa, #a9bdee);
--grad-backdrop: linear-gradient(180deg, #5a8ee8 0%, #3c6fd6 40%, #6fb04a 100%);
}
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-skin-depth="immersive"][data-theme] {
--grad-selected: linear-gradient(180deg, #4a85e0, #2456c4);
}
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme] {
--grad-primary: linear-gradient(180deg, #a8d1ff 0%, #5ba2f5 46%, #2b7fe0 50%, #4ea5ff 100%);
}
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="dark"] {
--grad-appbar: linear-gradient(180deg, #3d3e43, #26272b);
--grad-toolbar: repeating-linear-gradient(0deg, rgba(255, 255, 255, .03) 0 2px, transparent 2px 4px);
--grad-backdrop: repeating-linear-gradient(0deg, rgba(255, 255, 255, .025) 0 2px, transparent 2px 4px);
}
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="light"] {
--grad-appbar: linear-gradient(180deg, #e8e8e8, #bdbdbd);
--grad-toolbar: repeating-linear-gradient(0deg, rgba(255, 255, 255, .6) 0 2px, rgba(0, 0, 0, .035) 2px 4px);
--grad-backdrop: repeating-linear-gradient(0deg, #f2f2f2 0 2px, #e4e4e4 2px 4px);
}
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-skin-depth="immersive"][data-theme] {
--grad-selected: linear-gradient(180deg, #6ca3ee, #3875d7);
}
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] {
--grad-primary: linear-gradient(180deg, #7fb2f0 0%, #4a8ae8 50%, #3b7ddf 51%, #2f6fd3 100%);
}
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme="dark"] {
--grad-appbar: linear-gradient(180deg, #4a5463 0%, #2f3640 50%, #262c35 51%, #1f242c 100%);
--grad-toolbar: linear-gradient(180deg, #323943, #262c34);
--grad-backdrop: repeating-linear-gradient(45deg, rgba(255, 255, 255, .025) 0 2px, transparent 2px 4px), repeating-linear-gradient(-45deg, rgba(0, 0, 0, .25) 0 2px, transparent 2px 4px);
--grad-sidebar: repeating-linear-gradient(45deg, rgba(255, 255, 255, .02) 0 2px, transparent 2px 4px), repeating-linear-gradient(-45deg, rgba(0, 0, 0, .2) 0 2px, transparent 2px 4px);
}
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme="light"] {
--grad-appbar: linear-gradient(180deg, #b0bccd 0%, #889bb3 50%, #8195af 51%, #6d84a2 100%);
--grad-toolbar: linear-gradient(180deg, #f3f5f8, #d6dce4);
--grad-backdrop: repeating-linear-gradient(90deg, #c5ccd4 0 5px, #cbd2d8 5px 7px);
}
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-skin-depth="immersive"][data-theme] {
--grad-selected: linear-gradient(180deg, #058cf5, #015fe6);
}
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme] {
--grad-appbar: linear-gradient(180deg, rgba(255, 255, 255, .45), rgba(255, 255, 255, .12) 50%, rgba(255, 255, 255, 0) 51%, rgba(255, 255, 255, .16));
--grad-primary: linear-gradient(180deg, #8fc6ff 0%, #4c9ae8 50%, #2f7fd6 51%, #3e94ee 100%);
}
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme="dark"] {
--grad-backdrop: radial-gradient(1200px 700px at 30% 110%, rgba(64, 160, 255, .35) 0%, transparent 55%), linear-gradient(160deg, #0a2347, #123a6e 50%, #06162e);
--grad-toolbar: linear-gradient(180deg, #22304a, #1a2537);
--grad-selected: linear-gradient(180deg, #2b4d78, #1f3b5e);
}
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme="light"] {
--grad-backdrop: radial-gradient(1200px 700px at 30% 110%, #7fd1ff 0%, transparent 55%), linear-gradient(160deg, #1c5db4 0%, #3a8ee6 45%, #0b3d8c 100%);
--grad-toolbar: linear-gradient(180deg, #ffffff, #e9f1fb);
--grad-selected: linear-gradient(180deg, #f1f8ff, #d5eaff);
}
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme] {
--grad-appbar: linear-gradient(180deg, rgba(255, 255, 255, .35), rgba(255, 255, 255, 0));
--grad-primary: linear-gradient(180deg, #bff0ff 0%, #4cc4ff 48%, #0e9be0 52%, #34b9ff 100%);
}
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="dark"] {
--grad-backdrop: radial-gradient(900px 500px at 80% 0%, rgba(57, 192, 255, .35), transparent 60%), radial-gradient(800px 500px at 0% 100%, rgba(110, 227, 110, .18), transparent 55%), linear-gradient(180deg, #03101b, #06263a);
--grad-selected: linear-gradient(180deg, #1b5a80, #134563);
}
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="light"] {
--grad-backdrop: radial-gradient(800px 400px at 15% 0%, rgba(255, 255, 255, .9), transparent 60%), radial-gradient(900px 500px at 100% 100%, #8be07a 0%, transparent 55%), linear-gradient(180deg, #5cc8ff 0%, #bfeaff 55%, #e9fbe9 100%);
--grad-selected: linear-gradient(180deg, #e6f8ff, #bfe8ff);
}
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="dark"] {
--grad-appbar: linear-gradient(180deg, #f5cf3a, #d6a800);
--grad-toolbar: linear-gradient(180deg, #363636, #262626);
--grad-primary: linear-gradient(180deg, #4a4a4a, #2c2c2c);
}
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="light"] {
--grad-appbar: linear-gradient(180deg, #ffe066 0%, #ffcb00 60%, #e5b300 100%);
--grad-toolbar: linear-gradient(180deg, #f4f4f4, #d0d0d0);
--grad-primary: linear-gradient(180deg, #ffffff, #d6d6d6);
}
/* Consumers ------------------------------------------------------------ */
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .app-view {
background-image: var(--grad-backdrop);
background-attachment: fixed;
}
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .appbar {
background-image: var(--grad-appbar);
}
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .pane-toolbar,
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .dialog-header {
background-image: var(--grad-toolbar);
}
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .folder-pane {
background-image: var(--grad-sidebar);
}
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .folder-button.active,
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .message-row.active {
background-image: var(--grad-selected);
}
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .button.primary,
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .button.compose-button {
background-image: var(--grad-primary);
}
:root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] :is(.button.primary, .button.compose-button) {
border-color: color-mix(in srgb, currentColor 18%, transparent);
text-shadow: 0 1px 0 rgba(0, 0, 0, .12);
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] .appbar,
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="roundcube-larry"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="yahoo"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="github"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="discord"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="reddit"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
color: #1d1530;
}
:root[data-skin-gradients="true"][data-interface-skin="dracula"][data-theme="light"] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
color: #16161e;
}
:root[data-skin-gradients="true"][data-interface-skin="tokyo-night"][data-theme="light"] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
color: #2a0d23;
}
:root[data-skin-gradients="true"][data-interface-skin="synthwave"][data-theme="light"] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme] .appbar,
:root[data-skin-gradients="true"][data-interface-skin="retro"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] .appbar,
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="winxp"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="osx"][data-theme="light"] :is(.button.primary, .button.compose-button) {
color: #0a1d33;
}
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] .appbar,
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="ios6"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="win7"][data-theme] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
color: #02121d;
}
:root[data-skin-gradients="true"][data-interface-skin="frutiger-aero"][data-theme="light"] :is(.button.primary, .button.compose-button) {
color: #04304a;
}
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme] .appbar,
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme] .appbar :is(.icon-button, .account-summary, .brand-icon, #connection-label) {
color: #000000;
}
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="dark"] :is(.button.primary, .button.compose-button) {
color: #ffffff;
}
:root[data-skin-gradients="true"][data-interface-skin="beos"][data-theme="light"] :is(.button.primary, .button.compose-button) {
color: #000000;
}
:root[data-skin-gradients="true"][data-skin-depth="immersive"][data-interface-skin="synthwave"][data-theme="dark"] .app-brand strong {
background: linear-gradient(90deg, #ff7edb, #fede5d);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
+1
View File
@@ -8,4 +8,5 @@
56-interface-styles.css
57-skin-tokens.css
58-skin-immersive.css
59-skin-gradients.css
60-responsive.css
+7 -1
View File
@@ -68,5 +68,11 @@
"skin_depth_standard_note": "Colors plus the style's layout: density, row height, pane widths and type size.",
"skin_depth_immersive_note": "Everything in Standard plus the style's shapes, typography, floating panes, app bar, selection markers and backdrop.",
"skin_gradients": "Style gradients",
"skin_gradients_note": "Allow predefined styles to use gradients (backdrops, app bars, buttons). When off, every style uses flat colors."
"skin_gradients_note": "Adds each predefined style's gradients (app bar, toolbars, sidebar, selection, buttons, backdrop) at any style depth. Not used by the RustBox custom style.",
"theme_schedule": "By time of day",
"theme_light_from": "Light from",
"theme_dark_from": "Dark from",
"account_color": "Account color",
"account_color_note": "A stripe on the app bar and a dot next to the address show which account is active.",
"contrast_warning": "Low contrast"
}
+142 -19
View File
@@ -125,12 +125,8 @@
"Light": "Jasny",
"System": "Systemowy",
"Interface style": "Styl interfejsu",
"RustBox (custom)": "RustBox (niestandardowy)",
"RustBox (custom)": "RustBox (własny)",
"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",
@@ -633,10 +629,6 @@
"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.",
@@ -644,12 +636,6 @@
"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.",
"GitHub inspired": "Inspirowany GitHub",
"GitLab inspired": "Inspirowany GitLab",
"Discord inspired": "Inspirowany Discord",
"Slack inspired": "Inspirowany Slack",
"Reddit inspired": "Inspirowany Reddit",
"Notion inspired": "Inspirowany Notion",
"Hide fields": "Ukryj pola",
"Show fields": "Pokaż pola",
"Hide addressing fields": "Ukryj pola adresowe",
@@ -658,13 +644,144 @@
"Signature insertion": "Wstawianie podpisu",
"Quietly when sending": "Cicho przy wysyłce",
"Show in composer": "Pokaż w edytorze",
"Linear inspired": "Inspirowany Linear",
"Material You inspired": "Inspirowany Material You",
"Aqua Glass": "Szkło Aqua",
"Paper": "Papier",
"Terminal": "Terminal",
"Retro 95": "Retro 95",
"Style depth": "Głębokość stylu"
"Style depth": "Głębokość stylu",
"Text on panels": "Tekst na panelach",
"Text on the background": "Tekst na tle",
"Text on the selection": "Tekst na zaznaczeniu",
"Secondary text": "Tekst pomocniczy",
"Accent on panels": "Akcent na panelach",
"Button text on the accent": "Tekst przycisku na akcencie",
"10 minutes": "10 minut",
"2 hours": "2 godziny",
"1 day before": "1 dzień wcześniej",
"2 days before": "2 dni wcześniej",
"1 week before": "Tydzień wcześniej",
"Second reminder": "Drugie przypomnienie",
"Calendar reminder for new events": "Przypomnienie dla nowych wydarzeń",
"Second reminder for new events": "Drugie przypomnienie dla nowych wydarzeń",
"All-day events are reminded at the start of your working day (default 09:00), e.g. one day before.": "Wydarzenia całodniowe przypominane są na początku dnia pracy (domyślnie 09:00), np. dzień wcześniej.",
"Calendar reminder defaults saved": "Zapisano domyślne przypomnienia kalendarza",
"Clean": "Czysty",
"Minimal": "Minimalny",
"Graphite": "Grafit",
"RustBox & classic": "RustBox i klasyczne",
"Inspired by mail apps": "Inspirowane pocztą",
"Inspired by apps": "Inspirowane aplikacjami",
"Editor themes": "Motywy edytorów",
"Atmospheric": "Nastrojowe",
"+ Add exception": "+ Dodaj wyjątek",
"Actions": "Akcje",
"Add label": "Dodaj etykietę",
"Also apply to messages already in “{folder}”": "Zastosuj też do wiadomości już w „{folder}”",
"Always move messages from this sender": "Zawsze przenoś wiadomości od tego nadawcy",
"Always move messages from this sender…": "Zawsze przenoś wiadomości od tego nadawcy…",
"Apply now": "Zastosuj teraz",
"Apply rule to existing messages": "Zastosuj regułę do istniejących wiadomości",
"Apply this rule to messages that are already in a folder": "Zastosuj tę regułę do wiadomości, które już są w folderze",
"Apply to existing messages": "Zastosuj do istniejących wiadomości",
"Apply to {count} messages": "Zastosuj (wiadomości: {count})",
"Applying… {done}/{total}": "Stosowanie… {done}/{total}",
"Cc address": "Adres DW",
"To address": "Adres Do",
"Change the rule visually; the live preview shows exactly what will be written to Sieve.": "Zmień regułę wizualnie; podgląd na żywo pokazuje dokładnie, co zostanie zapisane w Sieve.",
"Check matches": "Sprawdź dopasowania",
"Checking…": "Sprawdzanie…",
"Choose the folder whose messages “{rule}” should be applied to.": "Wybierz folder, do którego wiadomości ma zostać zastosowana reguła „{rule}”.",
"Continue": "Dalej",
"Copy to": "Kopiuj do",
"Move to": "Przenieś do",
"Created {folders}": "Utworzono {folders}",
"Discard the message": "Odrzuć wiadomość",
"Edit filter": "Edytuj filtr",
"Every additional condition needs a value": "Każdy dodatkowy warunek wymaga wartości",
"Except if": "Z wyjątkiem gdy",
"except if": "z wyjątkiem gdy",
"Filter added to {script}": "Filtr dodany do {script}",
"Filter validation failed": "Walidacja filtru nie powiodła się",
"Flag mail from a VIP": "Oflaguj pocztę od VIP",
"Folder does not exist": "Folder nie istnieje",
"Folder name": "Nazwa folderu",
"Forward a copy": "Przekaż kopię",
"High priority": "Wysoki priorytet",
"Size larger than": "Rozmiar większy niż",
"Size smaller than": "Rozmiar mniejszy niż",
"Join": "Połącz",
"Keep in the inbox": "Zostaw w skrzynce",
"Keep rule as is": "Zostaw regułę bez zmian",
"Large attachments": "Duże załączniki",
"List-ID": "List-ID",
"Mail sent to an alias": "Poczta na alias",
"ManageSieve unavailable": "ManageSieve niedostępny",
"Move mail from a sender": "Przenoś pocztę od nadawcy",
"Move to Trash": "Przenieś do kosza",
"NOT": "NIE",
"AND": "I",
"OR": "LUB",
"New folder": "Nowy folder",
"New folder…": "Nowy folder…",
"New mail from {sender} will be moved automatically to:": "Nowa poczta od {sender} będzie automatycznie przenoszona do:",
"Newsletters to a folder": "Newslettery do folderu",
"No action": "Brak akcji",
"No action parsed": "Nie rozpoznano akcji",
"No messages in “{folder}” match this rule": "Żadna wiadomość w „{folder}” nie pasuje do tej reguły",
"None of this rule's actions can be applied to existing messages": "Żadnej akcji tej reguły nie da się zastosować do istniejących wiadomości",
"Only the first {count} will be processed.": "Przetworzonych zostanie tylko pierwszych {count}.",
"Pick a common rule and fill in the blanks.": "Wybierz typową regułę i uzupełnij pola.",
"Redirect a copy to": "Przekieruj kopię do",
"Redirect to": "Przekieruj do",
"Remove": "Usuń",
"Rule applied to {count} messages": "Reguła zastosowana (wiadomości: {count})",
"Rule value is required": "Wartość reguły jest wymagana",
"Sieve only runs on new mail. Check which messages already in a folder match and apply the actions to them now.": "Sieve działa tylko na nowej poczcie. Sprawdź, które wiadomości w folderze pasują, i zastosuj do nich akcje teraz.",
"Size must look like 500K, 2M or 1G": "Rozmiar podaj jak 500K, 2M lub 1G",
"Skipped for existing messages": "Pominięte dla istniejących wiadomości",
"Start from a template": "Zacznij od szablonu",
"Stop processing more rules": "Zatrzymaj przetwarzanie kolejnych reguł",
"Subject contains words": "Temat zawiera słowa",
"The rule changed — check matches again.": "Reguła się zmieniła — sprawdź dopasowania ponownie.",
"The rule is skipped when any exception matches.": "Reguła jest pomijana, gdy pasuje którykolwiek wyjątek.",
"The rule moves mail to {folders}, which does not exist yet. Create it now?": "Reguła przenosi pocztę do {folders}, który jeszcze nie istnieje. Utworzyć go teraz?",
"This message has no sender address": "Ta wiadomość nie ma adresu nadawcy",
"This rule uses Sieve syntax that cannot be applied to existing messages": "Ta reguła używa składni Sieve, której nie da się zastosować do istniejących wiadomości",
"Unsupported action": "Nieobsługiwana akcja",
"Work": "Praca",
"a value is required": "wymagana jest wartość",
"choose a condition": "wybierz warunek",
"custom header name is invalid": "nieprawidłowa nazwa nagłówka",
"target folder is required": "wymagany jest folder docelowy",
"redirect address is required": "wymagany jest adres przekierowania",
"choose an existing label": "wybierz istniejącą etykietę",
"IMAP flag is required": "wymagana jest flaga IMAP",
"IMAP flags are required": "wymagane są flagi IMAP",
"IMAP flag(s)": "Flaga(i) IMAP",
"Additional condition": "Dodatkowy warunek",
"Exception": "Wyjątek",
"Header": "Nagłówek",
"optional": "opcjonalnie",
"value": "wartość",
"set flags": "ustaw flagi",
"{count} messages in “{folder}” match this rule.": "Wiadomości w „{folder}” pasujące do tej reguły: {count}.",
"{count} more could not be checked exactly and will be left untouched.": "Pominięte, bo nie dało się ich sprawdzić dokładnie: {count}.",
"{count} operations failed": "nieudane operacje: {count}",
"Server does not advertise": "Serwer nie ogłasza",
"Requires": "Wymaga",
"No optional extension required": "Nie wymaga opcjonalnych rozszerzeń",
"ON": "WŁ.",
"OFF": "WYŁ.",
"Edit": "Edytuj",
"Disable": "Wyłącz",
"Enable": "Włącz",
"Uses syntax not fully supported by the visual editor; source is preserved unchanged.": "Używa składni nie w pełni obsługiwanej przez edytor wizualny; źródło pozostaje bez zmian.",
"Snooze": "Odłóż",
"Sender": "Nadawca",
"More": "Więcej",
"Snooze…": "Odłóż…",
"View snoozed messages…": "Pokaż odłożone wiadomości…",
"Open full screen": "Otwórz na pełnym ekranie"
},
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję presetu poniżej, aby przy pierwszej zmianie utworzyć jego edytowalną kopię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
@@ -687,5 +804,11 @@
"skin_depth_standard_note": "Kolory oraz układ stylu: gęstość, wysokość wierszy, szerokości paneli i rozmiar tekstu.",
"skin_depth_immersive_note": "Wszystko ze standardowej oraz kształty, typografia, pływające panele, pasek aplikacji, znaczniki zaznaczenia i tło stylu.",
"skin_gradients": "Gradienty w stylach",
"skin_gradients_note": "Pozwala gotowym stylom używać gradientów (tła, paski aplikacji, przyciski). Po wyłączeniu każdy styl używa jednolitych kolorów."
"skin_gradients_note": "Dodaje gradienty gotowego stylu (pasek aplikacji, paski narzędzi, panel boczny, zaznaczenie, przyciski, tło) przy każdej głębokości stylu. Nie dotyczy własnego stylu RustBox.",
"theme_schedule": "Według pory dnia",
"theme_light_from": "Jasny od",
"theme_dark_from": "Ciemny od",
"account_color": "Kolor konta",
"account_color_note": "Pasek u góry aplikacji i kropka przy adresie pokazują, które konto jest aktywne.",
"contrast_warning": "Niski kontrast"
}
+117 -40
View File
@@ -629,46 +629,80 @@
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
<option value="schedule" data-i18n="theme_schedule">By time of day</option>
</select></label>
<div id="pref-theme-schedule-row" class="theme-schedule-row hidden">
<label><span data-i18n="theme_light_from">Light from</span><input id="pref-theme-light-from" type="time"
value="07:00" step="60"></label>
<label><span data-i18n="theme_dark_from">Dark from</span><input id="pref-theme-dark-from" type="time"
value="19:00" step="60"></label>
</div>
<div id="pref-account-color-row" class="account-color-row"><span data-i18n="account_color">Account
color</span>
<div class="color-input"><input id="pref-account-color-enabled" type="checkbox"
aria-label="Show account color"><input id="pref-account-color" type="color" value="#4d75e6"
aria-label="Account color"></div><small class="muted" data-i18n="account_color_note">A stripe on the
app bar and a dot next to the address show which account is active.</small>
</div>
<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>
<option value="github">GitHub inspired</option>
<option value="gitlab">GitLab inspired</option>
<option value="discord">Discord inspired</option>
<option value="slack">Slack inspired</option>
<option value="reddit">Reddit inspired</option>
<option value="notion">Notion inspired</option>
<option value="dracula">Dracula</option>
<option value="catppuccin">Catppuccin</option>
<option value="solarized">Solarized</option>
<option value="gruvbox">Gruvbox</option>
<option value="tokyo-night">Tokyo Night</option>
<option value="rose-pine">Rosé Pine</option>
<option value="everforest">Everforest</option>
<option value="synthwave">Synthwave</option>
<option value="paper">Paper</option>
<option value="terminal">Terminal</option>
<option value="aqua">Aqua Glass</option>
<option value="linear">Linear inspired</option>
<option value="material">Material You inspired</option>
<option value="retro">Retro 95</option>
<optgroup label="RustBox & classic">
<option value="rustbox">RustBox (custom)</option>
<option value="clean">Clean</option>
<option value="minimal">Minimal</option>
<option value="graphite">Graphite</option>
<option value="high-contrast">High contrast</option>
</optgroup>
<optgroup label="Roundcube">
<option value="roundcube-classic">Roundcube Classic</option>
<option value="roundcube-larry">Roundcube Larry</option>
<option value="roundcube-elastic">Roundcube Elastic</option>
</optgroup>
<optgroup label="Inspired by mail apps">
<option value="gmail">Gmail</option>
<option value="outlook">Outlook</option>
<option value="apple-mail">Apple Mail</option>
<option value="fastmail">Fastmail</option>
<option value="proton">Proton</option>
<option value="zoho">Zoho</option>
<option value="yahoo">Yahoo</option>
<option value="hey">HEY</option>
</optgroup>
<optgroup label="Inspired by apps">
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="reddit">Reddit</option>
<option value="notion">Notion</option>
<option value="linear">Linear</option>
<option value="material">Material You</option>
</optgroup>
<optgroup label="Editor themes">
<option value="nord">Nord</option>
<option value="dracula">Dracula</option>
<option value="catppuccin">Catppuccin</option>
<option value="solarized">Solarized</option>
<option value="gruvbox">Gruvbox</option>
<option value="tokyo-night">Tokyo Night</option>
<option value="rose-pine">Rosé Pine</option>
<option value="everforest">Everforest</option>
</optgroup>
<optgroup label="Atmospheric">
<option value="synthwave">Synthwave</option>
<option value="paper">Paper</option>
<option value="terminal">Terminal</option>
<option value="aqua">Aqua Glass</option>
<option value="frutiger-aero">Frutiger Aero</option>
</optgroup>
<optgroup label="Retro">
<option value="retro">Retro 95</option>
<option value="winxp">Windows XP</option>
<option value="osx">Mac OS X</option>
<option value="ios6">iOS 6</option>
<option value="win7">Windows 7</option>
<option value="beos">BeOS</option>
</optgroup>
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
spacing, panes and typography.</small></label>
<label><span data-i18n="skin_depth">Style depth</span><select id="pref-skin-depth">
@@ -679,8 +713,9 @@
height, pane widths and type size.</small></label>
<label id="pref-skin-gradients-row" class="checkbox-row"><input id="pref-skin-gradients"
type="checkbox"><span data-i18n="skin_gradients">Style gradients</span><small class="muted"
data-i18n="skin_gradients_note">Allow predefined styles to use gradients (backdrops, app bars,
buttons). When off, every style uses flat colors.</small></label>
data-i18n="skin_gradients_note">Adds each predefined style's gradients (app bar, toolbars,
sidebar, selection, buttons, backdrop) at any style depth. Not used by the RustBox custom
style.</small></label>
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow editing predefined style</span><small
class="muted">When enabled, the first appearance change creates and saves a RustBox custom copy of the
@@ -724,6 +759,7 @@
<div class="color-input"><input id="pref-text-color" type="color" value="#edf0ec"><output
id="pref-text-color-value">#edf0ec</output></div>
</label>
<p id="pref-contrast-warning" class="contrast-warning hidden" role="status" aria-live="polite"></p>
<label><span data-i18n="density">Density</span><select id="pref-density">
<option value="compact">Compact</option>
<option value="comfortable">Comfortable</option>
@@ -1102,6 +1138,15 @@
<div class="settings-grid"><label><span>DND from</span><input id="notify-dnd-start" type="time"
value="22:00"></label><label><span>DND until</span><input id="notify-dnd-end" type="time"
value="07:00"></label></div>
<div class="settings-grid calendar-reminder-defaults"><label><span>Calendar reminder for new
events</span><select id="calendar-default-reminder">
<option value="">None</option>
</select></label><label><span>Second reminder for new events</span><select
id="calendar-default-reminder-extra">
<option value="">None</option>
</select></label></div>
<p class="muted calendar-reminder-note">All-day events are reminded at the start of your working day
(default 09:00), e.g. one day before.</p>
<div><span class="field-label">Folders</span>
<div id="notify-folders" class="notification-folder-list"></div>
</div>
@@ -1442,6 +1487,13 @@
<div class="sieve-rule-dialog-body">
<div class="sieve-builder sieve-builder-modal">
<div class="sieve-builder-body">
<section class="sieve-builder-section sieve-templates-section">
<div class="sieve-section-heading">
<div><strong>Start from a template</strong><small class="muted">Pick a common rule and fill in the
blanks.</small></div>
</div>
<div id="sieve-templates" class="sieve-templates"></div>
</section>
<section class="sieve-builder-section">
<div class="sieve-section-heading">
<div><strong>Rule</strong><small class="muted">Give it a readable name and decide whether it is
@@ -1536,6 +1588,17 @@
class="button quiet small" type="button">+ Add condition</button></div>
</div>
</section>
<section class="sieve-builder-section">
<div class="sieve-section-heading">
<div><strong>Except if</strong><small class="muted">The rule is skipped when any exception
matches.</small></div>
</div>
<div class="sieve-builder-grid">
<div id="sieve-builder-exceptions" class="sieve-builder-dynamic-list sieve-builder-wide"></div>
<div class="inline-actions sieve-builder-wide"><button id="sieve-builder-add-exception"
class="button quiet small" type="button">+ Add exception</button></div>
</div>
</section>
<section class="sieve-builder-section">
<div class="sieve-section-heading">
<div><strong>Then</strong><small class="muted">Actions run in the order shown below.</small></div>
@@ -1554,7 +1617,7 @@
</select></label>
<label id="sieve-builder-target-wrap"><span id="sieve-builder-target-label">Folder</span><input
id="sieve-builder-target" list="sieve-folder-options" placeholder="Archive"><datalist
id="sieve-folder-options"></datalist></label>
id="sieve-folder-options"></datalist><datalist id="sieve-tag-options"></datalist></label>
<label><span>Second action</span><select id="sieve-builder-action-2">
<option value="">None</option>
<option value="move">Move to folder</option>
@@ -1584,6 +1647,18 @@
</div>
<pre id="sieve-builder-preview-code" class="sieve-rule-preview-code"></pre>
</section>
<section class="sieve-builder-section sieve-apply-now" aria-live="polite">
<div class="sieve-section-heading">
<div><strong>Apply to existing messages</strong><small class="muted">Sieve only runs on new mail.
Check which messages already in a folder match and apply the actions to them now.</small></div>
</div>
<div class="sieve-apply-row">
<label><span>Folder</span><select id="sieve-apply-folder"></select></label>
<button id="sieve-apply-check" class="button quiet" type="button">Check matches</button>
<button id="sieve-apply-run" class="button" type="button" disabled>Apply now</button>
</div>
<div id="sieve-apply-result" class="sieve-apply-result muted"></div>
</section>
<div class="sieve-builder-footer">
<div class="inline-actions"><button id="sieve-builder-append" class="button primary" type="button">Add
rule</button><button id="sieve-builder-replace" class="button quiet" type="button" disabled>Update
@@ -1838,6 +1913,8 @@
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="1440">1 day</option>
</select></label><label><span>Second reminder</span><select id="calendar-reminder-extra">
<option value="">None</option>
</select></label></div>
<div class="settings-grid"><label><span>Organizer</span><input id="calendar-organizer"
type="email"></label><label><span>Color</span><input id="calendar-color" type="color"
+2 -1
View File
@@ -81,6 +81,7 @@
imapSyncPending: 0,
imapSyncInventoryPending: 0,
calendarReminderTimer: null,
calendarSettings: null,
composeDraftId: null,
composeRemoteDraft: null,
composeAttachments: [],
@@ -102,7 +103,7 @@
imageEditorBlob: null,
draftSaveTimer: null,
prefs: {
theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", skinDepth: "standard", skinGradients: false, presetScaleUnlocked: false, density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", skinDepth: "standard", skinGradients: false, themeLightFrom: "07:00", themeDarkFrom: "19:00", accountColor: "", 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,
+3 -3
View File
@@ -278,7 +278,7 @@ function appDialog({ title = "RustBox", message = "", input = null, selectOption
header.append(el("strong", "", title));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", cancelLabel);
close.setAttribute("aria-label", trText(cancelLabel));
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "app-prompt-body");
@@ -300,9 +300,9 @@ function appDialog({ title = "RustBox", message = "", input = null, selectOption
body.append(control);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const cancel = el("button", "button quiet", cancelLabel);
const cancel = el("button", "button quiet", trText(cancelLabel));
cancel.type = "button";
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
const confirm = el("button", danger ? "button danger" : "button primary", trText(confirmLabel));
confirm.type = "submit";
actions.append(cancel, confirm);
form.append(header, body, actions);
+142 -23
View File
@@ -1,5 +1,22 @@
function clockMinutes(value, fallback) {
const match = /^(\d{2}):(\d{2})$/.exec(String(value || ""));
return match ? Number(match[1]) * 60 + Number(match[2]) : fallback;
}
// "schedule": light between theme_light_from and theme_dark_from (local time),
// dark otherwise; the window may wrap past midnight.
function scheduledTheme(prefs = state.prefs, now = new Date()) {
const lightFrom = clockMinutes(prefs.themeLightFrom, 7 * 60);
const darkFrom = clockMinutes(prefs.themeDarkFrom, 19 * 60);
const minute = now.getHours() * 60 + now.getMinutes();
if (lightFrom === darkFrom) return "light";
const light = lightFrom < darkFrom ? minute >= lightFrom && minute < darkFrom : minute >= lightFrom || minute < darkFrom;
return light ? "light" : "dark";
}
function resolveTheme(theme) {
if (theme === "schedule") return scheduledTheme();
if (theme !== "system") return theme;
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
@@ -14,6 +31,7 @@ const preferenceMap = {
dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height",
readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style",
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background", adaptiveEmailColors: "adaptive_email_colors", skinDepth: "skin_depth", skinGradients: "skin_gradients",
themeLightFrom: "theme_light_from", themeDarkFrom: "theme_dark_from", accountColor: "account_color",
};
const preferenceTimers = new Map();
let presetPromotionTimer = null;
@@ -40,19 +58,19 @@ const interfaceStyleProfiles = {
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#dce9ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
gmail: {
label: "Gmail inspired",
label: "Gmail",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 248, messagePaneWidth: 440, borderRadius: 10, contrast: "soft", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#d3e3fd", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
proton: {
label: "Proton inspired",
label: "Proton",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 242, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#e7e1ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
zoho: {
label: "Zoho inspired",
label: "Zoho",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 420, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "compact", sidebarItemHeight: 30, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#e1ebff", unreadFontWeight: 710, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
outlook: {
label: "Outlook inspired",
label: "Outlook",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 232, messagePaneWidth: 420, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "compact", sidebarItemHeight: 30, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#deecf9", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
minimal: {
@@ -64,19 +82,19 @@ const interfaceStyleProfiles = {
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 400, borderRadius: 2, contrast: "normal", iconSize: 17, toolbarDensity: "compact", sidebarItemHeight: 27, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#32373c", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
fastmail: {
label: "Fastmail inspired",
label: "Fastmail",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 240, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#d9e8ff", unreadFontWeight: 710, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
"apple-mail": {
label: "Apple Mail inspired",
label: "Apple Mail",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 54, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 9, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 920, composerWidth: 920, selectionColor: "#dcebff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
yahoo: {
label: "Yahoo Mail inspired",
label: "Yahoo",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 10, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#ede3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
hey: {
label: "HEY inspired",
label: "HEY",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "humanist", fontSize: 14, rowHeight: 62, folderPaneWidth: 250, messagePaneWidth: 450, borderRadius: 12, contrast: "normal", iconSize: 19, toolbarDensity: "spacious", sidebarItemHeight: 36, readerMaxWidth: 900, composerWidth: 930, selectionColor: "#fff0a8", unreadFontWeight: 760, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
nord: {
@@ -88,27 +106,27 @@ const interfaceStyleProfiles = {
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
github: {
label: "GitHub inspired",
label: "GitHub",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#ddf4ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
gitlab: {
label: "GitLab inspired",
label: "GitLab",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#eee7ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
discord: {
label: "Discord inspired",
label: "Discord",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 246, messagePaneWidth: 440, borderRadius: 8, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 950, selectionColor: "#e0e3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
slack: {
label: "Slack inspired",
label: "Slack",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#f3e6f5", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
reddit: {
label: "Reddit inspired",
label: "Reddit",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 31, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#fff0e8", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
notion: {
label: "Notion inspired",
label: "Notion",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 58, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 5, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 900, composerWidth: 920, selectionColor: "#efefef", unreadFontWeight: 700, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
dracula: {
@@ -156,17 +174,41 @@ const interfaceStyleProfiles = {
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 10, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#1e3a5f", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
linear: {
label: "Linear inspired",
label: "Linear",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 46, folderPaneWidth: 232, messagePaneWidth: 440, borderRadius: 6, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 28, readerMaxWidth: 860, composerWidth: 940, selectionColor: "#1d1f33", unreadFontWeight: 640, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
material: {
label: "Material You inspired",
label: "Material You",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 256, messagePaneWidth: 440, borderRadius: 12, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 40, readerMaxWidth: 960, composerWidth: 940, selectionColor: "#4a4458", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
retro: {
label: "Retro 95",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 220, messagePaneWidth: 420, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#000080", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
winxp: {
label: "Windows XP",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 48, folderPaneWidth: 232, messagePaneWidth: 430, borderRadius: 3, contrast: "normal", iconSize: 16, toolbarDensity: "normal", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#c1d2ee", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
osx: {
label: "Mac OS X",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 430, borderRadius: 5, contrast: "normal", iconSize: 16, toolbarDensity: "normal", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#c5d8f4", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
ios6: {
label: "iOS 6",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 62, folderPaneWidth: 250, messagePaneWidth: 440, borderRadius: 5, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 40, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cfe0f7", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
win7: {
label: "Windows 7",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 3, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 960, composerWidth: 900, selectionColor: "#cce8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
"frutiger-aero": {
label: "Frutiger Aero",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 244, messagePaneWidth: 440, borderRadius: 12, contrast: "normal", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 36, readerMaxWidth: 900, composerWidth: 940, selectionColor: "#c9ecff", unreadFontWeight: 700, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
beos: {
label: "BeOS",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 46, folderPaneWidth: 224, messagePaneWidth: 430, borderRadius: 2, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 26, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#d0dcf2", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
};
function resolvedInterfaceSkin(prefs = state.prefs) {
@@ -226,10 +268,10 @@ function translateScopedUiText(locale) {
const trailing = original.match(/\s*$/)?.[0] || "";
node.nodeValue = `${leading}${translated}${trailing}`;
}
const nodes = [scope, ...scope.querySelectorAll("[title],[aria-label],[placeholder],[data-placeholder]")];
const nodes = [scope, ...scope.querySelectorAll("[title],[aria-label],[placeholder],[data-placeholder],optgroup[label]")];
for (const node of nodes) {
const originals = localizedAttributes.get(node) || {};
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) {
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder", "label"]) {
if (!node.hasAttribute(attribute)) continue;
if (!(attribute in originals)) originals[attribute] = node.getAttribute(attribute);
const translated = map[originals[attribute]];
@@ -267,6 +309,7 @@ function applyPreferences() {
const paletteStyle = activeInterfaceStyle(p);
const fixedStyle = layoutInterfaceStyle(p);
const skinDepth = resolvedSkinDepth(p);
const previousLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`;
const visual = fixedStyle
? { ...p, ...fixedStyle.preferences }
: p;
@@ -274,6 +317,22 @@ function applyPreferences() {
root.dataset.interfaceSkin = resolvedInterfaceSkin(p);
root.dataset.skinDepth = skinDepth;
root.dataset.skinGradients = String(Boolean(p.skinGradients));
if (/^#[0-9a-f]{6}$/i.test(p.accountColor || "")) {
root.dataset.accountColor = "true";
root.style.setProperty("--account-color", p.accountColor);
} else {
delete root.dataset.accountColor;
root.style.removeProperty("--account-color");
}
const nextLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`;
if (root.dataset.lookReady === "true" && previousLook !== nextLook && !p.reduceMotion) {
root.classList.add("theme-transition");
clearTimeout(applyPreferences.transitionTimer);
applyPreferences.transitionTimer = setTimeout(() => root.classList.remove("theme-transition"), 360);
}
root.dataset.lookReady = "true";
const scheduleRow = $("#pref-theme-schedule-row");
if (scheduleRow) scheduleRow.classList.toggle("hidden", p.theme !== "schedule");
root.dataset.density = visual.density;
root.dataset.preview = visual.preview;
root.dataset.scale = visual.scale;
@@ -316,7 +375,11 @@ function applyPreferences() {
if (lockedRoundcubeSkin) {
["--selected", "--folder-bg", "--message-bg", "--reader-bg"].forEach((name) => root.style.removeProperty(name));
} else {
root.style.setProperty("--selected", visual.selectionColor || "#28352e");
// The stored default (#28352e) was picked for the dark theme; treat it as
// "automatic" so the light theme keeps its own readable selection colour.
const selection = String(visual.selectionColor || "").toLowerCase();
if (!selection || selection === "#28352e") root.style.removeProperty("--selected");
else root.style.setProperty("--selected", visual.selectionColor);
root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)");
root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)");
root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)");
@@ -339,7 +402,7 @@ function applyPreferences() {
const displayedMessageBackground = presetColor("--message-bg", p.messageBackground || "#181c19");
const displayedReaderBackground = presetColor("--reader-bg", p.readerBackground || "#181c19");
const values = {
"#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-skin-depth": skinDepth, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
"#pref-theme": p.theme, "#pref-theme-light-from": p.themeLightFrom, "#pref-theme-dark-from": p.themeDarkFrom, "#pref-account-color": p.accountColor || "#4d75e6", "#pref-interface-skin": p.interfaceSkin, "#pref-skin-depth": skinDepth, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
"#pref-language": p.language, "#pref-color-preset": paletteStyle ? "custom" : p.colorPreset, "#pref-accent": displayedAccent,
"#pref-background": displayedBackground, "#pref-panel": displayedPanel, "#pref-text-color": displayedText,
"#pref-font-family": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight,
@@ -350,7 +413,7 @@ function applyPreferences() {
"#pref-selection-color": visual.selectionColor, "#pref-unread-weight": visual.unreadFontWeight, "#pref-unread-badge": visual.unreadBadgeStyle, "#pref-scrollbar-density": visual.scrollbarDensity,
};
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
const checks = { "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-skin-gradients": p.skinGradients, "#pref-adaptive-email-colors": p.adaptiveEmailColors, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
const checks = { "#pref-account-color-enabled": Boolean(p.accountColor), "#pref-preset-scale-unlocked": p.presetScaleUnlocked, "#pref-skin-gradients": p.skinGradients, "#pref-adaptive-email-colors": p.adaptiveEmailColors, "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); });
const outputs = { "#pref-accent-value": displayedAccent, "#pref-background-value": displayedBackground, "#pref-panel-value": displayedPanel, "#pref-text-color-value": displayedText, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) };
Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; });
@@ -382,6 +445,7 @@ function applyPreferences() {
immersive: tr("skin_depth_immersive_note", "Everything in Standard plus the style's shapes, typography, floating panes, app bar, selection markers and backdrop."),
})[skinDepth];
}
updateContrastWarning(Boolean(paletteStyle));
const skinNote = $("#pref-interface-skin-note");
if (skinNote) {
const styleLabel = state.prefs.language === "pl" ? (state.translations._text?.[paletteStyle?.label] || paletteStyle?.label) : paletteStyle?.label;
@@ -391,11 +455,66 @@ function applyPreferences() {
}
}
let contrastProbe = null;
function resolvedCssColor(expression) {
if (!contrastProbe) {
contrastProbe = document.createElement("span");
contrastProbe.setAttribute("aria-hidden", "true");
contrastProbe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none";
document.body.append(contrastProbe);
}
contrastProbe.style.color = "";
contrastProbe.style.color = expression;
const value = getComputedStyle(contrastProbe).color;
const rgb = value.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
if (rgb) return rgb.slice(1, 4).map((part) => Number(part) / 255);
const srgb = value.match(/color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
return srgb ? srgb.slice(1, 4).map(Number) : null;
}
function contrastRatio(first, second) {
const luminance = (rgb) => {
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const [a, b] = [luminance(first), luminance(second)].sort((x, y) => y - x);
return (a + 0.05) / (b + 0.05);
}
// Warns while the user's own colours make text hard to read (WCAG AA, 4.5:1
// for text, 3:1 for the accent against panels). Predefined palettes are
// checked at release time, so the hint is only shown for custom colours.
function updateContrastWarning(usingPredefinedPalette) {
const warning = $("#pref-contrast-warning");
if (!warning) return;
if (usingPredefinedPalette || !document.body) { warning.classList.add("hidden"); return; }
const color = (name) => resolvedCssColor(`var(${name})`);
const checks = [
["text", "panel", 4.5, "Text on panels"],
["text", "bg", 4.5, "Text on the background"],
["text", "selected", 4.5, "Text on the selection"],
["muted", "panel", 3, "Secondary text"],
["accent", "panel", 3, "Accent on panels"],
["accent-ink", "accent", 4.5, "Button text on the accent"],
];
const problems = [];
for (const [fg, bg, minimum, label] of checks) {
const a = color(`--${fg}`);
const b = color(`--${bg}`);
if (!a || !b) continue;
const ratio = contrastRatio(a, b);
if (ratio < minimum) problems.push(`${trText(label)} ${ratio.toFixed(1)}:1`);
}
warning.classList.toggle("hidden", problems.length === 0);
warning.textContent = problems.length ? `${tr("contrast_warning", "Low contrast")}: ${problems.join(" · ")}` : "";
}
function normalizeServerPreferences(data) {
if (!data) return;
state.prefs = {
...state.prefs,
theme: data.theme || root.dataset.defaultTheme || "system", interfaceSkin: data.interface_skin || "default", skinDepth: skinDepths.includes(data.skin_depth) ? data.skin_depth : "standard", skinGradients: Boolean(data.skin_gradients), presetScaleUnlocked: Boolean(data.preset_scale_unlocked), density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
theme: data.theme || root.dataset.defaultTheme || "system", interfaceSkin: data.interface_skin || "default", skinDepth: skinDepths.includes(data.skin_depth) ? data.skin_depth : "standard", skinGradients: Boolean(data.skin_gradients), themeLightFrom: data.theme_light_from || "07:00", themeDarkFrom: data.theme_dark_from || "19:00", accountColor: data.account_color || "", presetScaleUnlocked: Boolean(data.preset_scale_unlocked), density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false,
scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage",
accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13,
@@ -660,7 +779,7 @@ function savePreference(key, value) {
}
async function resetAppearance() {
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", skin_depth: "standard", skin_gradients: false, preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "", adaptive_email_colors: false };
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", skin_depth: "standard", skin_gradients: false, theme_light_from: "07:00", theme_dark_from: "19:00", account_color: "", preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "", adaptive_email_colors: false };
try {
normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }));
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: true }) });
+10 -1
View File
@@ -35,6 +35,15 @@ function updateMessageLoadControls() {
}
}
function messageListSkeleton(rows = 8) {
const widths = [[38, 72], [46, 58], [30, 80], [52, 64], [34, 70], [44, 54], [40, 76], [28, 62]];
const items = Array.from({ length: rows }, (_, index) => {
const [from, subject] = widths[index % widths.length];
return `<div class="message-skeleton-row" aria-hidden="true"><span class="skeleton-dot"></span><span class="skeleton-line" style="--w:${from}%"></span><span class="skeleton-line skeleton-date"></span><span class="skeleton-line skeleton-wide" style="--w:${subject}%"></span></div>`;
}).join("");
return `<div class="message-skeleton" role="status"><span class="sr-only">Loading messages…</span>${items}</div>`;
}
async function loadMessages(reset = false, options = {}) {
const list = $("#message-list");
const mode = mailboxLoadingMode();
@@ -49,7 +58,7 @@ async function loadMessages(reset = false, options = {}) {
state.selectedMessages.clear();
state.selectionAnchorKey = null;
clearActiveSearchContext();
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
list.innerHTML = messageListSkeleton();
}
}
$("#folder-title").textContent = state.folder;
+110 -38
View File
@@ -677,27 +677,84 @@ async function deleteFolder() {
}
function hideContextMenu() {
closeContextSubmenu();
const menu = $("#context-menu");
menu.classList.add("hidden");
menu.replaceChildren();
}
function showContextMenu(x, y, items) {
const menu = $("#context-menu");
menu.replaceChildren();
function contextMenuButtons(menu, items, { submenuHost = null } = {}) {
for (const item of items) {
if (item === "separator") { menu.append(el("div", "separator")); continue; }
const button = el("button", item.danger ? "danger" : "", trText(item.label));
button.type = "button";
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
button.setAttribute("role", "menuitem");
if (item.submenu) {
button.classList.add("has-submenu");
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.append(el("span", "context-menu-arrow", "›"));
const open = () => openContextSubmenu(button, item.submenu);
button.addEventListener("mouseenter", open);
button.addEventListener("click", (event) => { event.stopPropagation(); open(); });
button.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
$("#context-submenu button")?.focus();
}
});
} else {
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
if (submenuHost) button.addEventListener("mouseenter", () => closeContextSubmenu());
}
menu.append(button);
}
menu.classList.remove("hidden");
}
function placeContextMenu(menu, x, y) {
menu.style.maxHeight = `${Math.max(160, innerHeight - 12)}px`;
const rect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(6, Math.min(x, innerWidth - rect.width - 6))}px`;
menu.style.top = `${Math.max(6, Math.min(y, innerHeight - rect.height - 6))}px`;
}
function closeContextSubmenu() {
$("#context-submenu")?.remove();
$$("#context-menu .has-submenu[aria-expanded='true']").forEach((node) => node.setAttribute("aria-expanded", "false"));
}
function openContextSubmenu(anchor, items) {
if (anchor.getAttribute("aria-expanded") === "true" && $("#context-submenu")) return;
closeContextSubmenu();
anchor.setAttribute("aria-expanded", "true");
const submenu = el("div", "context-menu context-submenu");
submenu.id = "context-submenu";
submenu.setAttribute("role", "menu");
contextMenuButtons(submenu, items);
submenu.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "Escape") { event.preventDefault(); closeContextSubmenu(); anchor.focus(); }
});
document.body.append(submenu);
const anchorRect = anchor.getBoundingClientRect();
const parentRect = anchor.closest(".context-menu").getBoundingClientRect();
submenu.style.maxHeight = `${Math.max(160, innerHeight - 12)}px`;
const rect = submenu.getBoundingClientRect();
const fitsRight = parentRect.right + rect.width + 2 <= innerWidth - 6;
const left = fitsRight ? parentRect.right + 2 : Math.max(6, parentRect.left - rect.width - 2);
submenu.style.left = `${left}px`;
submenu.style.top = `${Math.max(6, Math.min(anchorRect.top - 4, innerHeight - rect.height - 6))}px`;
}
function showContextMenu(x, y, items) {
const menu = $("#context-menu");
closeContextSubmenu();
menu.replaceChildren();
contextMenuButtons(menu, items, { submenuHost: true });
menu.classList.remove("hidden");
placeContextMenu(menu, x, y);
}
async function ensureMessageOpen(message) {
const folder = message.folder || state.folder;
if (!state.currentDetail || state.currentDetail.uid !== message.uid || state.currentDetail.folder !== folder) {
@@ -715,40 +772,56 @@ function openMessageContextMenu(x, y, message) {
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || "");
const seen = flags.includes("\\Seen");
const flagged = flags.includes("\\Flagged");
const folder = message.folder || state.folder;
const sieveEnabled = state.authPolicy.sieveEnabled !== false;
showContextMenu(x, y, [
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
{ label: "Open", action: () => openMessage(folder, message.uid) },
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
"separator",
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
{ label: "Forward", submenu: [
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
] },
"separator",
{ label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } },
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
{ label: "Labels", submenu: [
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
] },
{ label: "Snooze", submenu: [
{ label: "Snooze\u2026", action: async () => { await ensureMessageOpen(message); await promptSnoozeCurrent(); } },
{ label: "View snoozed messages\u2026", action: async () => { await showSnoozedMessages(); } },
] },
"separator",
{ label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } },
{ label: "Snooze\u2026", action: async () => { await ensureMessageOpen(message); await promptSnoozeCurrent(); } },
{ label: "View snoozed messages\u2026", action: async () => { await showSnoozedMessages(); } },
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } },
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } },
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
{ label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } },
...(state.authPolicy.sieveEnabled === false ? [] : [
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", folder); if (target) await moveCurrentTo(target); } },
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", folder); if (target) await copyCurrentTo(target); } },
{ label: isJunkFolder(folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } },
"separator",
...(sieveEnabled ? [{ label: "Filters", submenu: [
{ label: "Always move messages from this sender…", action: () => alwaysMoveFromSender(message) },
"separator",
{ label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } },
{ label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } },
{ label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } },
]),
{ label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
] }] : []),
{ label: "Sender", submenu: [
{ label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
{ label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } },
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
] },
{ label: "More", submenu: [
{ label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } },
{ label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } },
{ label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } },
{ label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } },
{ label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } },
] },
"separator",
{ label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } },
{ label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } },
{ label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } },
{ label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } },
{ label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } },
{ label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } },
{ label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } },
]);
}
@@ -1251,6 +1324,7 @@ async function prepareQuickFilterAdvanced(config) {
$("#sieve-builder-condition-2").value = "";
$("#sieve-builder-condition-3").value = "";
$("#sieve-builder-extra-conditions").replaceChildren();
$("#sieve-builder-exceptions")?.replaceChildren();
$("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : "";
$("#sieve-builder-extra-actions").replaceChildren();
updateSieveBuilder();
@@ -1329,6 +1403,8 @@ async function createFilterFromCurrentMessage(kind = "sender") {
const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true;
const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags");
const activate = document.createElement("input"); activate.type = "checkbox"; activate.checked = !activeScript;
const applyExisting = document.createElement("input"); applyExisting.type = "checkbox";
const sourceFolder = detail.folder || state.folder;
const targetField = makeField("Destination folder", target);
grid.append(
@@ -1342,7 +1418,8 @@ async function createFilterFromCurrentMessage(kind = "sender") {
const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read"));
if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags";
const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving"));
toggles.append(stopLabel, readLabel, activateLabel);
const applyLabel = el("label", "checkbox-row"); applyLabel.append(applyExisting, el("span", "", fmtText("Also apply to messages already in “{folder}”", { folder: sourceFolder })));
toggles.append(stopLabel, readLabel, activateLabel, applyLabel);
body.append(toggles);
const preview = el("div", "quick-filter-preview"); body.append(preview);
const hint = el("div", "muted quick-filter-hint"); body.append(hint);
@@ -1370,7 +1447,7 @@ async function createFilterFromCurrentMessage(kind = "sender") {
preview.textContent = `If ${conditionLabel} ${match.value === "contains" ? "contains" : "is"} “${displayValue}” → ${actionLabel}${needsFolder && target.value ? ` “${target.value}”` : ""}${markRead.checked && action.value !== "mark-read" ? " + mark as read" : ""}.`;
hint.textContent = missing.length
? `Server does not advertise: ${missing.join(", ")}`
: `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. It applies to new incoming mail; existing messages are not changed.`;
: `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. ${applyExisting.checked ? `Matching messages already in “${sourceFolder}” are processed after you confirm.` : "It applies to new incoming mail; existing messages are not changed."}`;
save.disabled = missing.length > 0;
};
condition.addEventListener("change", () => { const next = sourceValues[condition.value]; value.value = next.value; match.value = next.match; update(); });
@@ -1381,6 +1458,7 @@ async function createFilterFromCurrentMessage(kind = "sender") {
script.addEventListener("change", update);
activate.addEventListener("change", update);
markRead.addEventListener("change", update);
applyExisting.addEventListener("change", update);
let settled = false;
const cleanup = () => { if (settled) return; settled = true; if (dialog.open) dialog.close(); else dialog.remove(); };
@@ -1399,17 +1477,11 @@ async function createFilterFromCurrentMessage(kind = "sender") {
setBusy(save, true);
try {
const config = { kind: condition.value, match: match.value, value: value.value.trim(), action: action.value, target: target.value, stop: stop.checked, markRead: markRead.checked };
const built = buildQuickFilterRule(config);
const existing = state.sieveScripts.find((item) => item.name === script.value);
let current = "";
if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method: "GET" })).content || "";
const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
const validation = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content }) });
if (!validation.valid) throw new Error(validation.error || "Filter validation failed");
await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method: "PUT", body: JSON.stringify({ content }) });
if (activate.checked || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(script.value)}/activate`, { method: "POST", body: "{}" });
if (["move", "copy"].includes(config.action) && !await ensureSieveTargetFolders(quickFilterActions(config))) return;
await appendRuleToSieveScript(buildQuickFilterRule(config), script.value, activate.checked);
cleanup();
toast(`Filter added to ${script.value}`, "success");
toast(fmtText("Filter added to {script}", { script: script.value }), "success");
if (applyExisting.checked) await applySieveRuleToFolder({ folder: sourceFolder, tree: quickFilterTree(config), actions: quickFilterActions(config) });
} catch (error) {
hint.textContent = error.message;
toast(error.message, "error");
+51 -3
View File
@@ -204,7 +204,53 @@ function updateCalendarRecurrenceUi() {
$("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom");
}
const calendarReminderChoices = [
["", "None"], ["0", "At start"], ["5", "5 minutes"], ["10", "10 minutes"], ["15", "15 minutes"], ["30", "30 minutes"],
["60", "1 hour"], ["120", "2 hours"], ["1440", "1 day before"], ["2880", "2 days before"], ["10080", "1 week before"],
];
function fillCalendarReminderSelects() {
for (const selector of ["#calendar-reminder", "#calendar-reminder-extra", "#calendar-default-reminder", "#calendar-default-reminder-extra"]) {
const select = $(selector);
if (!select) continue;
const current = select.value;
select.replaceChildren(...calendarReminderChoices.map(([value, label]) => new Option(trText(label), value)));
select.value = current;
}
}
function setReminderSelect(selector, minutes) {
const select = $(selector);
if (!select) return;
const value = minutes == null ? "" : String(minutes);
select.value = Array.from(select.options).some((option) => option.value === value) ? value : "";
}
function reminderSelectValue(selector) {
const value = $(selector)?.value ?? "";
return value === "" ? null : Number(value);
}
async function loadCalendarReminderDefaults() {
try {
state.calendarSettings = await request("/calendar/settings", { method: "GET" });
} catch (_) {
state.calendarSettings = state.calendarSettings || null;
}
fillCalendarReminderSelects();
setReminderSelect("#calendar-default-reminder", state.calendarSettings?.default_reminder_minutes);
setReminderSelect("#calendar-default-reminder-extra", state.calendarSettings?.default_extra_reminder_minutes);
}
async function saveCalendarReminderDefault(field, selector) {
try {
state.calendarSettings = await request("/calendar/settings", { method: "PUT", body: JSON.stringify({ [field]: reminderSelectValue(selector) }) });
toast(trText("Calendar reminder defaults saved"));
} catch (error) { toast(error.message, "error"); }
}
function editCalendarEvent(event = null) {
fillCalendarReminderSelects();
$("#calendar-form").classList.remove("hidden");
$("#calendar-event-id").value = event?.id || "";
$("#calendar-title").value = event?.title || "";
@@ -226,8 +272,9 @@ function editCalendarEvent(event = null) {
$("#calendar-description").value = event?.description || "";
const color = String(event?.color || "");
$("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280";
const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes);
$("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : "";
// New events start with the reminders chosen in Settings -> Notifications.
setReminderSelect("#calendar-reminder", event ? event.reminder_minutes : state.calendarSettings?.default_reminder_minutes);
setReminderSelect("#calendar-reminder-extra", event ? event.reminder_extra_minutes : state.calendarSettings?.default_extra_reminder_minutes);
$("#calendar-delete").disabled = !event;
}
@@ -253,7 +300,8 @@ async function saveCalendarEvent(event) {
status: $("#calendar-status").value || "confirmed",
meeting_url: $("#calendar-meeting-url").value.trim() || null,
color: $("#calendar-color").value || null,
reminder_minutes: $("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value),
reminder_minutes: reminderSelectValue("#calendar-reminder"),
reminder_extra_minutes: reminderSelectValue("#calendar-reminder-extra"),
};
const payload = id ? base : { ...base, source: "manual", source_uid: null, source_message_uid: null, source_message_folder: null };
try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); }
+31 -12
View File
@@ -97,6 +97,7 @@ async function loadCombinedNotificationPreferences() {
}
async function loadNotificationPreferences() {
void loadCalendarReminderDefaults();
const prefs = await loadNotificationPreferencesForAccount(state.accountId, { silent: false });
if (!prefs) return;
$("#notify-enabled").checked = Boolean(prefs.enabled);
@@ -404,8 +405,24 @@ function notifyNewMail(data, accountId) {
if (prefs.sound) playNotificationSound();
}
function calendarReminderStorageKey(event) {
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}`;
function calendarReminderStorageKey(event, minutes) {
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}.${minutes}`;
}
// Timed events: start minus the reminder. All-day events: the reminder counts
// from the start of the working day (calendar settings, default 09:00) of the
// event's date, so "1 day before" fires the previous morning, not at midnight.
function calendarReminderTrigger(event, minutes) {
const offset = Math.max(0, Number(minutes) || 0) * 60000;
if (event.all_day) {
const day = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(event.start_time || ""));
if (!day) return NaN;
const [hours, mins] = String(state.calendarSettings?.working_hours_start || "09:00").split(":").map(Number);
const base = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]), Number.isFinite(hours) ? hours : 9, Number.isFinite(mins) ? mins : 0);
return base.getTime() - offset;
}
const startMs = new Date(event.start_time).getTime();
return Number.isFinite(startMs) ? startMs - offset : NaN;
}
function fireCalendarReminder(event) {
@@ -424,19 +441,21 @@ async function checkCalendarReminders() {
if (!state.accountId) return;
const now = Date.now();
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
const end = new Date(now + 2 * 24 * 60 * 60 * 1000);
// Look far enough ahead for the longest reminder choice (1 week before).
const end = new Date(now + 8 * 24 * 60 * 60 * 1000);
try {
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
for (const event of data.events || []) {
if (event.reminder_minutes == null || String(event.status || "").toLowerCase() === "cancelled") continue;
const startMs = new Date(event.start_time).getTime();
if (!Number.isFinite(startMs)) continue;
const trigger = startMs - Math.max(0, Number(event.reminder_minutes) || 0) * 60000;
if (trigger > now || now - trigger > 10 * 60 * 1000) continue;
const key = calendarReminderStorageKey(event);
if (localStorage.getItem(key)) continue;
localStorage.setItem(key, String(now));
fireCalendarReminder(event);
if (String(event.status || "").toLowerCase() === "cancelled") continue;
for (const minutes of [event.reminder_minutes, event.reminder_extra_minutes]) {
if (minutes == null) continue;
const trigger = calendarReminderTrigger(event, minutes);
if (!Number.isFinite(trigger) || trigger > now || now - trigger > 10 * 60 * 1000) continue;
const key = calendarReminderStorageKey(event, minutes);
if (localStorage.getItem(key)) continue;
localStorage.setItem(key, String(now));
fireCalendarReminder(event);
}
}
} catch (_) { }
}
+339 -141
View File
@@ -31,12 +31,110 @@ function sieveQuote(value) {
function sieveBuilderRequirements(condition, action) {
const requirements = new Set();
if (["move", "copy"].includes(action)) requirements.add("fileinto");
if (["move", "copy", "trash"].includes(action)) requirements.add("fileinto");
if (["copy", "redirect-copy"].includes(action)) requirements.add("copy");
if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags");
if (["mark-read", "add-flag", "set-flags", "label"].includes(action)) requirements.add("imap4flags");
if (condition === "has-attachment") requirements.add("mime");
return [...requirements];
}
// One catalogue for every condition / action select in the filter editor.
const sieveConditionKinds = [
["from", "From address"], ["from-domain", "From domain"], ["to", "To / Cc address"], ["to-only", "To address"],
["cc-only", "Cc address"], ["subject", "Subject"], ["list-id", "List-ID"], ["has-attachment", "Has attachment"],
["size-over", "Size larger than"], ["size-under", "Size smaller than"], ["priority-high", "High priority"],
["header-from", "From header"], ["header-to", "To header"], ["header", "Custom header"],
];
const valuelessSieveKinds = new Set(["has-attachment", "priority-high"]);
const sizeSieveKinds = new Set(["size-over", "size-under"]);
const sieveActionKinds = [
["move", "Move to folder"], ["trash", "Move to Trash"], ["copy", "Copy to folder"], ["label", "Add label"],
["mark-read", "Mark read"], ["add-flag", "Add IMAP flag"], ["set-flags", "Set IMAP flags"], ["redirect", "Redirect"],
["redirect-copy", "Redirect and keep copy"], ["discard", "Discard"], ["keep", "Keep"],
];
function sieveOptionsHtml(kinds, includeNone = false) {
const none = includeNone ? `<option value="">${escapeHtml(trText("None"))}</option>` : "";
return none + kinds.map(([value, label]) => `<option value="${value}">${escapeHtml(trText(label))}</option>`).join("");
}
function fillSieveKindSelects() {
const fill = (select, kinds, includeNone) => {
if (!select) return;
const current = select.value;
select.innerHTML = sieveOptionsHtml(kinds, includeNone);
if ([...select.options].some((option) => option.value === current)) select.value = current;
};
fill($("#sieve-builder-condition"), sieveConditionKinds, false);
fill($("#sieve-builder-condition-2"), sieveConditionKinds, true);
fill($("#sieve-builder-condition-3"), sieveConditionKinds, true);
fill($("#sieve-builder-action"), sieveActionKinds, false);
fill($("#sieve-builder-action-2"), sieveActionKinds, true);
}
function normalizeSieveSize(value) {
const match = String(value || "").trim().toUpperCase().match(/^(\d+)\s*([KMG])?B?$/);
return match ? `${match[1]}${match[2] || ""}` : null;
}
function sieveLabelKeyword(tagId) {
return `RustBox-${String(tagId || "").replaceAll("-", "")}`;
}
function sieveTagByKeyword(keyword) {
return (state.tags || []).find((tag) => sieveLabelKeyword(tag.id).toLowerCase() === String(keyword || "").toLowerCase()) || null;
}
function sieveConditionSummary({ condition, match, value, header, negate }) {
const label = trText(sieveConditionLabel(condition, header));
let text;
if (valuelessSieveKinds.has(condition)) text = label;
else if (sizeSieveKinds.has(condition)) text = `${label} ${value}`;
else text = `${label} ${trText(sieveMatchLabel(match))} “${value}”`;
return negate ? `${trText("NOT")} ${text}` : text;
}
// Shared by the first/second/third conditions, the dynamic rows and the
// exceptions: one Sieve test plus the JSON tree used by "Apply now".
function sieveTestFromParts({ condition, match = "contains", value = "", header = "", negate = false }, label = "Condition") {
let text = String(value || "").trim();
if (!condition) throw new Error(`${trText(label)}: ${trText("choose a condition")}`);
if (!valuelessSieveKinds.has(condition) && !text) throw new Error(`${trText(label)}: ${trText("a value is required")}`);
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
const quoted = `"${sieveQuote(text)}"`;
let test;
let customHeader = "";
if (condition === "from") test = `address ${matchToken} "from" ${quoted}`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" ${quoted}`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] ${quoted}`;
else if (condition === "to-only") test = `address ${matchToken} "to" ${quoted}`;
else if (condition === "cc-only") test = `address ${matchToken} "cc" ${quoted}`;
else if (condition === "header-from") test = `header ${matchToken} "from" ${quoted}`;
else if (condition === "header-to") test = `header ${matchToken} "to" ${quoted}`;
else if (condition === "subject") test = `header ${matchToken} "subject" ${quoted}`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" ${quoted}`;
else if (sizeSieveKinds.has(condition)) {
const size = normalizeSieveSize(text);
if (!size) throw new Error(trText("Size must look like 500K, 2M or 1G"));
text = size;
test = `size :${condition === "size-over" ? "over" : "under"} ${size}`;
} else if (condition === "has-attachment") test = 'header :mime :anychild :contains "Content-Disposition" "attachment"';
else if (condition === "priority-high") test = 'header :matches ["x-priority", "importance"] ["1*", "2*", "high"]';
else {
customHeader = String(header || "").trim();
if (!/^[A-Za-z0-9-]+$/.test(customHeader)) throw new Error(`${trText(label)}: ${trText("custom header name is invalid")}`);
test = `header ${matchToken} "${sieveQuote(customHeader)}" ${quoted}`;
}
if (negate) test = `not ${test}`;
const leaf = { test: { kind: condition, match, value: text, header: customHeader } };
return {
test, condition, match, value: text, header: customHeader, negate,
summary: sieveConditionSummary({ condition, match, value: text, header: customHeader, negate }),
requirements: sieveBuilderRequirements(condition, ""),
tree: negate ? { not: leaf } : leaf,
};
}
function mergeSieveRequirements(script, requirements) {
const found = new Set(requirements);
const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => {
@@ -56,87 +154,86 @@ function sieveMatchLabel(match) {
}
function sieveConditionLabel(condition, header = "") {
if (condition === "from") return "From address";
if (condition === "from-domain") return "From domain";
if (condition === "to") return "To / Cc address";
if (condition === "header-from") return "From header";
if (condition === "header-to") return "To header";
if (condition === "subject") return "Subject";
if (condition === "list-id") return "List-ID";
return header || "Header";
if (condition === "header") return header || "Header";
return sieveConditionKinds.find(([value]) => value === condition)?.[1] || header || "Header";
}
function buildSieveCondition(suffix = "", required = true) {
const condition = $(`#sieve-builder-condition${suffix}`).value;
const value = $(`#sieve-builder-value${suffix}`).value.trim();
if (!condition || !value) {
if (required) throw new Error("Rule value is required");
if (!condition || (!value && !valuelessSieveKinds.has(condition))) {
if (required) throw new Error(trText("Rule value is required"));
return null;
}
const match = $(`#sieve-builder-match${suffix}`).value;
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
let test;
let header = "";
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
else {
header = $(`#sieve-builder-header${suffix}`).value.trim();
if (!/^[A-Za-z0-9-]+$/.test(header)) {
const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom";
throw new Error(`${label} header name is invalid`);
}
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
}
const negate = $(`#sieve-builder-negate${suffix}`).checked;
if (negate) test = `not ${test}`;
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`;
return { test, condition, match, value, header, negate, summary };
const label = suffix === "-3" ? "Third condition" : suffix === "-2" ? "Second condition" : "Condition";
return sieveTestFromParts({
condition,
match: $(`#sieve-builder-match${suffix}`).value,
value,
header: $(`#sieve-builder-header${suffix}`)?.value || "",
negate: $(`#sieve-builder-negate${suffix}`).checked,
}, label);
}
function sieveFlagValues(target) {
return String(target || "").split(",").map((value) => value.trim()).filter(Boolean);
}
function sieveActionSummary(action, target = "") {
if (action === "move") return `${trText("Move to")} “${target}”`;
if (action === "trash") return trText("Move to Trash");
if (action === "copy") return `${trText("Copy to")} “${target}”`;
if (action === "redirect") return `${trText("Redirect to")} ${target}`;
if (action === "redirect-copy") return `${trText("Redirect a copy to")} ${target}`;
if (action === "discard") return trText("Discard the message");
if (action === "mark-read") return trText("Mark as read");
if (action === "label") return `${trText("Add label")} “${target}”`;
if (action === "add-flag") return `${trText("Add IMAP flag")} ${target}`;
if (action === "set-flags") return `${trText("Set IMAP flags")} ${target}`;
if (action === "stop") return trText("Stop processing more rules");
return trText("Keep in the inbox");
}
function buildSieveActionFromValues(action, target, label = "Action") {
let command;
let summary;
let resolvedTarget = target;
let tagId = "";
const missing = (what) => new Error(`${trText(label)}: ${trText(what)}`);
if (action === "move") {
if (!target) throw new Error(`${label} target folder is required`);
if (!target) throw missing("target folder is required");
command = `fileinto "${sieveQuote(target)}";`;
summary = `Move to “${target}”`;
} else if (action === "trash") {
resolvedTarget = detectSpecialFolder("trash") || "Trash";
command = `fileinto "${sieveQuote(resolvedTarget)}";`;
} else if (action === "copy") {
if (!target) throw new Error(`${label} target folder is required`);
if (!target) throw missing("target folder is required");
command = `fileinto :copy "${sieveQuote(target)}";`;
summary = `Copy to “${target}”`;
} else if (action === "redirect" || action === "redirect-copy") {
if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`);
if (!target || !target.includes("@")) throw missing("redirect address is required");
command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`;
summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`;
} else if (action === "discard") {
command = "discard;";
summary = "Discard the message";
} else if (action === "mark-read") {
command = 'addflag "\\Seen";';
summary = "Mark as read";
} else if (action === "label") {
const wanted = String(target || "").trim().toLowerCase();
const tag = (state.tags || []).find((item) => item.name.toLowerCase() === wanted || item.id === target);
if (!tag) throw missing("choose an existing label");
resolvedTarget = tag.name;
tagId = tag.id;
command = `addflag "${sieveQuote(sieveLabelKeyword(tag.id))}";`;
} else if (action === "add-flag") {
if (!target) throw new Error(`${label} IMAP flag is required`);
if (!target) throw missing("IMAP flag is required");
command = `addflag "${sieveQuote(target)}";`;
summary = `Add IMAP flag ${target}`;
} else if (action === "set-flags") {
const flags = sieveFlagValues(target);
if (!flags.length) throw new Error(`${label} IMAP flags are required`);
if (!flags.length) throw missing("IMAP flags are required");
resolvedTarget = flags.join(", ");
command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`;
summary = `Set IMAP flags ${flags.join(", ")}`;
} else {
command = "keep;";
summary = "Keep in the inbox";
}
return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) };
return { action, target: resolvedTarget, tagId, command, summary: sieveActionSummary(action, resolvedTarget), requirements: sieveBuilderRequirements("", action) };
}
function buildSieveAction(suffix = "", required = true) {
@@ -254,6 +351,15 @@ function parseSieveConditionToken(value) {
let raw = String(value || "").trim();
const negate = /^not\s+/i.test(raw);
if (negate) raw = raw.replace(/^not\s+/i, "").trim();
const special = (kind, extra = {}) => {
const parsed = { raw: value, supported: true, kind, header: "", match: "contains", value: "", negate, ...extra };
parsed.summary = sieveConditionSummary({ condition: kind, match: parsed.match, value: parsed.value, header: "", negate });
return parsed;
};
const size = raw.match(/^size\s+:(over|under)\s+(\d+[KMG]?)$/i);
if (size) return special(size[1].toLowerCase() === "over" ? "size-over" : "size-under", { value: size[2].toUpperCase() });
if (/^header\s+:mime\s+:anychild\s+:contains\s+"content-disposition"\s+"attachment"$/i.test(raw)) return special("has-attachment");
if (/^header\s+:matches\s+\[\s*"x-priority"\s*,\s*"importance"\s*\]\s+\[\s*"1\*"\s*,\s*"2\*"\s*,\s*"high"\s*\]$/i.test(raw)) return special("priority-high");
const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i);
if (!match) return { raw: value, supported: false, summary: String(value || "").trim() || "Unsupported condition" };
const values = sieveQuotedValues(match[4]);
@@ -265,14 +371,19 @@ function parseSieveConditionToken(value) {
let header = primaryField;
const testType = match[1].toLowerCase();
if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from";
else if (testType === "address" && fields.length === 1 && primaryField === "to" && !match[2]) kind = "to-only";
else if (testType === "address" && fields.length === 1 && primaryField === "cc" && !match[2]) kind = "cc-only";
else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to";
else if (testType === "header" && primaryField === "from") kind = "header-from";
else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to";
else if (primaryField === "subject") kind = "subject";
else if (primaryField === "list-id") kind = "list-id";
const comparator = match[3].toLowerCase();
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`;
return { raw: value, supported: true, kind, header, match: comparator, value: targetValue, negate, summary };
const summary = sieveConditionSummary({ condition: kind, match: comparator, value: targetValue, header, negate });
// Only shapes RustBox itself writes can be re-evaluated by "Apply now".
const valueList = match[4].match(/\[([^\]]*)\]\s*$/);
const treeSafe = !(valueList && sieveQuotedValues(valueList[1]).length > 1) && (!match[2] || kind === "from-domain") && (kind !== "header" || fields.length === 1);
return { raw: value, supported: true, kind, header, match: comparator, value: targetValue, negate, summary, treeSafe };
}
function parseSieveConditionExpression(value) {
@@ -280,7 +391,7 @@ function parseSieveConditionExpression(value) {
const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i);
if (!group) {
const condition = parseSieveConditionToken(raw);
return { logic: "allof", conditions: [condition], supported: condition.supported, mixed: false, summary: condition.summary };
return { logic: "allof", conditions: [condition], supported: condition.supported, mixed: false, summary: condition.summary, tree: sieveConditionTree(condition) };
}
const logic = group[1].toLowerCase();
const parts = splitSieveTopLevel(group[2]);
@@ -288,48 +399,66 @@ function parseSieveConditionExpression(value) {
let supported = true;
let mixed = false;
const summaries = [];
const trees = [];
for (const part of parts) {
const parsed = parseSieveConditionExpression(part);
trees.push(parsed.tree);
if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true;
conditions.push(...parsed.conditions);
supported = supported && parsed.supported;
mixed = mixed || parsed.mixed;
summaries.push(parsed.summary);
}
return { logic, conditions, supported, mixed, summary: summaries.join(logic === "anyof" ? " OR " : " AND ") };
const tree = trees.every(Boolean) ? { op: logic, args: trees } : null;
return { logic, conditions, supported, mixed, summary: summaries.join(` ${trText(logic === "anyof" ? "OR" : "AND")} `), tree };
}
// JSON form of one parsed Sieve test, as understood by POST /filters/match.
function sieveConditionTree(condition) {
if (!condition?.supported || !condition.kind || condition.treeSafe === false) return null;
const leaf = { test: { kind: condition.kind, match: condition.match || "contains", value: condition.value || "", header: condition.kind === "header" ? condition.header || "" : "" } };
return condition.negate ? { not: leaf } : leaf;
}
function parseSieveActionStatement(statement) {
const raw = String(statement || "").trim().replace(/;\s*$/, "");
const parsed = (action, target = "", extra = {}) => ({ action, target, supported: true, summary: sieveActionSummary(action, target), ...extra });
let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
if (match) {
const target = sieveUnescape(match[2]);
const action = match[1] ? "copy" : "move";
return { action, target, supported: true, summary: action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` };
if (!match[1] && target === (detectSpecialFolder("trash") || "Trash")) return parsed("trash", target);
return parsed(match[1] ? "copy" : "move", target);
}
match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
if (match) {
const target = sieveUnescape(match[2]);
const action = match[1] ? "redirect-copy" : "redirect";
return { action, target, supported: true, summary: action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` };
}
if (match) return parsed(match[1] ? "redirect-copy" : "redirect", sieveUnescape(match[2]));
match = raw.match(/^addflag\s+([\s\S]+)$/i);
if (match) {
const flags = sieveQuotedValues(match[1]);
const target = flags.join(", ");
const markRead = flags.length === 1 && flags[0] === "\\Seen";
return { action: markRead ? "mark-read" : "add-flag", target: markRead ? "" : target, supported: Boolean(flags.length), summary: markRead ? "Mark as read" : `Add IMAP flag ${target}` };
if (flags.length === 1 && flags[0] === "\\Seen") return parsed("mark-read");
const tag = flags.length === 1 ? sieveTagByKeyword(flags[0]) : null;
if (tag) return parsed("label", tag.name, { tagId: tag.id });
return { ...parsed("add-flag", flags.join(", ")), supported: Boolean(flags.length) };
}
match = raw.match(/^setflag\s+([\s\S]+)$/i);
if (match) {
const flags = sieveQuotedValues(match[1]);
const target = flags.join(", ");
return { action: "set-flags", target, supported: Boolean(flags.length), summary: `Set IMAP flags ${target}` };
return { ...parsed("set-flags", flags.join(", ")), supported: Boolean(flags.length) };
}
if (/^discard$/i.test(raw)) return { action: "discard", target: "", supported: true, summary: "Discard the message" };
if (/^keep$/i.test(raw)) return { action: "keep", target: "", supported: true, summary: "Keep in the inbox" };
if (/^stop$/i.test(raw)) return { action: "stop", target: "", supported: true, stop: true, summary: "Stop processing more rules" };
return { action: "unsupported", target: "", supported: false, summary: raw || "Unsupported action", raw };
if (/^discard$/i.test(raw)) return parsed("discard");
if (/^keep$/i.test(raw)) return parsed("keep");
if (/^stop$/i.test(raw)) return parsed("stop", "", { stop: true });
return { action: "unsupported", target: "", supported: false, summary: raw || trText("Unsupported action"), raw };
}
// RustBox writes exceptions as `allof (<conditions>, not anyof (<exceptions>))`.
function splitSieveExceptions(condition) {
const raw = String(condition || "").trim();
const group = raw.match(/^allof\s*\(([\s\S]*)\)$/i);
if (!group) return { main: raw, exceptions: [] };
const parts = splitSieveTopLevel(group[1]);
const exception = parts.length === 2 ? parts[1].match(/^not\s+anyof\s*\(([\s\S]*)\)$/i) : null;
if (!exception) return { main: raw, exceptions: [] };
return { main: parts[0], exceptions: splitSieveTopLevel(exception[1]) };
}
function parseSieveRules(script = $("#sieve-editor")?.value || "") {
@@ -356,7 +485,19 @@ function parseSieveRules(script = $("#sieve-editor")?.value || "") {
const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i);
const enabled = !disabledMatch;
const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim();
const condition = parseSieveConditionExpression(actualCondition);
const split = splitSieveExceptions(actualCondition);
const condition = parseSieveConditionExpression(split.main);
condition.exceptions = split.exceptions.map(parseSieveConditionToken);
if (condition.exceptions.length) {
const exceptionTrees = condition.exceptions.map(sieveConditionTree);
condition.tree = condition.tree && exceptionTrees.every(Boolean)
? { op: "allof", args: [condition.tree, { not: { op: "anyof", args: exceptionTrees } }] }
: null;
}
if (condition.exceptions.length) {
condition.supported = condition.supported && condition.exceptions.every((item) => item.supported && !item.negate);
condition.summary = `${condition.summary} — ${trText("except if")} ${condition.exceptions.map((item) => item.summary).join(` ${trText("OR")} `)}`;
}
const body = source.slice(openBrace + 1, closeBrace);
const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement);
const stop = parsedActions.some((action) => action.stop);
@@ -415,10 +556,12 @@ function setSieveDynamicAction(row, action) {
function showSieveRuleDialog(mode = "new", ruleName = "") {
const dialog = $("#sieve-rule-dialog");
if (!dialog) return;
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter";
sieveApplyFolderOptions($("#sieve-apply-folder"), state.folder);
resetSieveApplyPanel();
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `${trText("Edit filter")} · ${ruleName}` : trText("Create filter");
$("#sieve-rule-dialog-subtitle").textContent = mode === "edit"
? "Change the rule visually; the live preview shows exactly what will be written to Sieve."
: "Build the rule visually and verify the preview before applying it.";
? trText("Change the rule visually; the live preview shows exactly what will be written to Sieve.")
: trText("Build the rule visually and verify the preview before applying it.");
if (!dialog.open) dialog.showModal();
}
@@ -431,6 +574,7 @@ function openNewSieveRuleDialog() {
function loadSieveRuleIntoBuilder(rule) {
if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning");
state.sieveEditingRuleId = rule.id;
fillSieveKindSelects();
$("#sieve-builder-name").value = rule.name;
$("#sieve-builder-enabled").checked = rule.enabled;
$("#sieve-builder-stop").checked = rule.stop;
@@ -454,6 +598,7 @@ function loadSieveRuleIntoBuilder(rule) {
setSieveDynamicAction(row, action);
$("#sieve-builder-extra-actions").append(row);
}
$("#sieve-builder-exceptions")?.replaceChildren(...(rule.condition.exceptions || []).map((item) => createSieveExceptionRow(item)));
$("#sieve-builder-replace").disabled = false;
updateSieveBuilder();
renderManagedSieveRules();
@@ -466,6 +611,8 @@ function clearSieveRuleBuilder() {
$("#sieve-builder-name").value = "";
$("#sieve-builder-enabled").checked = true;
$("#sieve-builder-stop").checked = true;
$("#sieve-builder-exceptions")?.replaceChildren();
fillSieveKindSelects();
$("#sieve-builder-condition").value = "from";
$("#sieve-builder-match").value = "contains";
$("#sieve-builder-value").value = "";
@@ -490,8 +637,8 @@ function clearSieveRuleBuilder() {
updateSieveBuilder();
renderManagedSieveRules();
if ($("#sieve-rule-dialog")?.open) {
$("#sieve-rule-dialog-title").textContent = "Create filter";
$("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it.";
$("#sieve-rule-dialog-title").textContent = trText("Create filter");
$("#sieve-rule-dialog-subtitle").textContent = trText("Build the rule visually and verify the preview before applying it.");
}
}
@@ -551,21 +698,24 @@ function renderManagedSieveRules() {
const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`);
const main = el("div", "sieve-managed-rule-main");
const title = el("div", "sieve-managed-rule-title");
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF"));
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, trText(rule.enabled ? "ON" : "OFF")));
const summary = el("div", "sieve-managed-rule-summary");
const when = el("div"); when.innerHTML = `<b>When:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed";
const then = el("div"); then.innerHTML = `<b>Then:</b> ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`;
const when = el("div"); when.innerHTML = `<b>${escapeHtml(trText("When"))}:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : trText("No action parsed");
const then = el("div"); then.innerHTML = `<b>${escapeHtml(trText("Then"))}:</b> ${escapeHtml(actionText)}${rule.stop ? `; ${escapeHtml(trText("Stop processing more rules").toLowerCase())}` : ""}`;
summary.append(when, then);
main.append(title, summary);
if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged."));
if (!rule.editable) main.append(el("div", "sieve-rule-warning", trText("Uses syntax not fully supported by the visual editor; source is preserved unchanged.")));
const actions = el("div", "sieve-managed-rule-actions");
const edit = el("button", "button quiet small", "Edit"); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
const edit = el("button", "button quiet small", trText("Edit")); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
const toggle = el("button", "button quiet small", trText(rule.enabled ? "Disable" : "Enable")); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
const up = el("button", "button quiet small", "↑"); up.type = "button"; up.title = "Move rule up"; up.disabled = index === 0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
const down = el("button", "button quiet small", "↓"); down.type = "button"; down.title = "Move rule down"; down.disabled = index === rules.length - 1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
const remove = el("button", "button quiet small danger", "Delete"); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule));
actions.append(edit, toggle, up, down, remove);
const remove = el("button", "button quiet small danger", trText("Delete")); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule));
const runNow = el("button", "button quiet small", trText("Apply now")); runNow.type = "button"; runNow.disabled = !rule.editable;
runNow.title = trText("Apply this rule to messages that are already in a folder");
runNow.addEventListener("click", () => runParsedSieveRuleNow(rule));
actions.append(edit, runNow, toggle, up, down, remove);
item.append(main, actions);
list.append(item);
});
@@ -573,12 +723,8 @@ function renderManagedSieveRules() {
function createSieveDynamicCondition() {
const row = el("div", "sieve-dynamic-row sieve-dynamic-condition");
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind"><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => {
const kind = row.querySelector(".sieve-dynamic-kind").value;
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header");
updateSieveBuilder();
};
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind">${sieveOptionsHtml(sieveConditionKinds)}</select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => { syncSieveRowValueFields(row); updateSieveBuilder(); };
row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh);
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
@@ -588,31 +734,58 @@ function createSieveDynamicCondition() {
function buildSieveDynamicCondition(row) {
const condition = row.querySelector(".sieve-dynamic-kind").value;
const value = row.querySelector(".sieve-dynamic-value").value.trim();
if (!condition || !value) throw new Error("Every additional condition needs a value");
const match = row.querySelector(".sieve-dynamic-match").value;
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
let test;
let header = "";
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
else {
header = row.querySelector(".sieve-dynamic-header").value.trim();
if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid");
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
if (!condition || (!value && !valuelessSieveKinds.has(condition))) throw new Error(trText("Every additional condition needs a value"));
const built = sieveTestFromParts({
condition,
match: row.querySelector(".sieve-dynamic-match").value,
value,
header: row.querySelector(".sieve-dynamic-header").value,
negate: row.querySelector(".sieve-dynamic-negate").checked,
}, "Additional condition");
return { ...built, logic: row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof" };
}
function createSieveExceptionRow(exception = null) {
const row = el("div", "sieve-dynamic-row sieve-exception-row");
row.innerHTML = `<label><span>${escapeHtml(trText("Except if"))}</span><select class="sieve-dynamic-kind">${sieveOptionsHtml(sieveConditionKinds)}</select></label><label><span>${escapeHtml(trText("Match"))}</span><select class="sieve-dynamic-match"><option value="contains">${escapeHtml(trText("contains"))}</option><option value="is">${escapeHtml(trText("is exactly"))}</option><option value="matches">${escapeHtml(trText("wildcard matches"))}</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>${escapeHtml(trText("Header name"))}</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label class="sieve-dynamic-value-wrap"><span>${escapeHtml(trText("Value"))}</span><input class="sieve-dynamic-value" placeholder="${escapeHtml(trText("value"))}"></label><button class="button quiet small sieve-dynamic-remove" type="button">${escapeHtml(trText("Remove"))}</button>`;
if (exception) {
row.querySelector(".sieve-dynamic-kind").value = exception.kind || "subject";
row.querySelector(".sieve-dynamic-match").value = exception.match || "contains";
row.querySelector(".sieve-dynamic-header").value = exception.kind === "header" ? exception.header || "" : "";
row.querySelector(".sieve-dynamic-value").value = exception.value || "";
} else {
row.querySelector(".sieve-dynamic-kind").value = "subject";
}
const negate = row.querySelector(".sieve-dynamic-negate").checked;
if (negate) test = `not ${test}`;
return { test, condition, match, value, header, negate, logic: row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary: `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` };
row.querySelector(".sieve-dynamic-kind").addEventListener("change", () => { syncSieveRowValueFields(row); updateSieveBuilder(); });
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
syncSieveRowValueFields(row);
return row;
}
function syncSieveRowValueFields(row) {
const kind = row.querySelector(".sieve-dynamic-kind").value;
row.querySelector(".sieve-dynamic-header-wrap")?.classList.toggle("hidden", kind !== "header");
const value = row.querySelector(".sieve-dynamic-value");
const valueWrap = value?.closest("label");
valueWrap?.classList.toggle("hidden", valuelessSieveKinds.has(kind));
if (value) value.placeholder = sizeSieveKinds.has(kind) ? "2M" : trText("value");
const match = row.querySelector(".sieve-dynamic-match");
if (match) match.disabled = valuelessSieveKinds.has(kind) || sizeSieveKinds.has(kind);
}
function buildSieveException(row) {
return sieveTestFromParts({
condition: row.querySelector(".sieve-dynamic-kind").value,
match: row.querySelector(".sieve-dynamic-match").value,
value: row.querySelector(".sieve-dynamic-value").value,
header: row.querySelector(".sieve-dynamic-header").value,
}, "Exception");
}
function createSieveDynamicAction() {
const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row");
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action"><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action">${sieveOptionsHtml(sieveActionKinds)}</select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); };
row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh);
row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder);
@@ -626,12 +799,7 @@ function configureSieveDynamicAction(row) {
const wrap = row.querySelector(".sieve-dynamic-target-wrap");
const label = row.querySelector(".sieve-dynamic-target-label");
const target = row.querySelector(".sieve-dynamic-target");
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
wrap.classList.toggle("hidden", !needsTarget);
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
target.removeAttribute("list");
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
configureSieveTargetField(action, wrap, label, target);
}
function buildSieveDynamicAction(row) {
@@ -651,34 +819,39 @@ function buildSieveVisualRule() {
const primary = buildSieveCondition("", true);
const secondary = buildSieveCondition("-2", false);
const tertiary = buildSieveCondition("-3", false);
const requirements = new Set(primary.requirements);
let test = primary.test;
let summary = primary.summary;
if (secondary) {
const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof";
test = `${logic} (${test}, ${secondary.test})`;
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`;
}
if (tertiary) {
const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof";
test = `${logic} (${test}, ${tertiary.test})`;
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`;
}
let tree = primary.tree;
const join = (logic, next) => {
test = `${logic} (${test}, ${next.test})`;
summary = `${summary} ${trText(logic === "anyof" ? "OR" : "AND")} ${next.summary}`;
tree = { op: logic, args: [tree, next.tree] };
for (const requirement of next.requirements) requirements.add(requirement);
};
if (secondary) join($("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof", secondary);
if (tertiary) join($("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof", tertiary);
for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) {
const extra = buildSieveDynamicCondition(row);
test = `${extra.logic} (${test}, ${extra.test})`;
summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`;
join(extra.logic, extra);
}
const exceptions = $$("#sieve-builder-exceptions .sieve-exception-row").map(buildSieveException);
if (exceptions.length) {
test = `allof (${test}, not anyof (${exceptions.map((item) => item.test).join(", ")}))`;
summary = `${summary} — ${trText("except if")} ${exceptions.map((item) => item.summary).join(` ${trText("OR")} `)}`;
tree = { op: "allof", args: [tree, { not: { op: "anyof", args: exceptions.map((item) => item.tree) } }] };
for (const item of exceptions) for (const requirement of item.requirements) requirements.add(requirement);
}
const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean);
const commands = actions.map((item) => item.command);
const stop = $("#sieve-builder-stop").checked;
if (stop) commands.push("stop;");
const requirements = new Set();
for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement);
const conditionCode = enabled ? test : `false # ${test}`;
return {
name, enabled, stop, requirements: [...requirements], conditionSummary: summary,
actionSummary: actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""),
name, enabled, stop, requirements: [...requirements], conditionSummary: summary, tree, actions,
actionSummary: actions.map((item) => item.summary).join("; ") + (stop ? `; ${trText("Stop processing more rules").toLowerCase()}` : ""),
rule: `# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`,
};
}
@@ -688,23 +861,33 @@ function configureSieveActionFields(suffix = "") {
const wrap = $(`#sieve-builder-target-wrap${suffix}`);
const label = $(`#sieve-builder-target-label${suffix}`);
const target = $(`#sieve-builder-target${suffix}`);
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
configureSieveTargetField(action, wrap, label, target);
}
function configureSieveTargetField(action, wrap, label, target) {
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags", "label"].includes(action);
wrap?.classList.toggle("hidden", !needsTarget);
if (!target || !label) return;
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
label.textContent = trText(["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : action === "label" ? "Label" : "Folder");
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : action === "label" ? trText("Work") : "Archive";
target.removeAttribute("list");
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
if (action === "label") target.setAttribute("list", "sieve-tag-options");
}
function applySieveCapabilityMatrix(extensions) {
const supports = (name) => extensions.has(name.toLowerCase());
const actionAllowed = (action) => {
if (["move", "copy"].includes(action) && !supports("fileinto")) return false;
if (["move", "copy", "trash"].includes(action) && !supports("fileinto")) return false;
if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false;
if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false;
if (["mark-read", "add-flag", "set-flags", "label"].includes(action) && !supports("imap4flags")) return false;
return true;
};
const conditionAllowed = (condition) => condition !== "has-attachment" || !state.sieveInfo || supports("mime");
for (const select of [$("#sieve-builder-condition"), $("#sieve-builder-condition-2"), $("#sieve-builder-condition-3"), ...$$("#sieve-rule-dialog .sieve-dynamic-kind")]) {
if (!select) continue;
for (const option of select.options) option.disabled = Boolean(option.value) && !conditionAllowed(option.value);
};
for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) {
if (!select) continue;
for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value);
@@ -720,7 +903,7 @@ function renderSieveBuilderPreview() {
if (!preview || !code || !statePill) return;
try {
const built = buildSieveVisualRule();
preview.innerHTML = `<div><strong>When:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>Then:</strong> ${escapeHtml(built.actionSummary || "No action")}</div>`;
preview.innerHTML = `<div><strong>${escapeHtml(trText("When"))}:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>${escapeHtml(trText("Then"))}:</strong> ${escapeHtml(built.actionSummary || trText("No action"))}</div>`;
code.textContent = built.rule;
statePill.textContent = built.enabled ? "Enabled" : "Disabled";
statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`;
@@ -735,12 +918,23 @@ function renderSieveBuilderPreview() {
function updateSieveBuilder() {
const condition = $("#sieve-builder-condition").value;
$("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header");
for (const suffix of ["", "-2", "-3"]) {
const kind = $(`#sieve-builder-condition${suffix}`).value;
const value = $(`#sieve-builder-value${suffix}`);
value.closest("label")?.classList.toggle("hidden", valuelessSieveKinds.has(kind));
if (sizeSieveKinds.has(kind)) value.placeholder = "2M";
else value.placeholder = suffix ? trText("optional") : "example.com";
if (!suffix) $("#sieve-builder-match").disabled = valuelessSieveKinds.has(kind) || sizeSieveKinds.has(kind);
}
$$("#sieve-rule-dialog .sieve-dynamic-condition, #sieve-rule-dialog .sieve-exception-row").forEach(syncSieveRowValueFields);
const tagList = $("#sieve-tag-options");
if (tagList) tagList.replaceChildren(...(state.tags || []).map((tag) => new Option(tag.name, tag.name)));
for (const suffix of ["-2", "-3"]) {
const nextCondition = $(`#sieve-builder-condition${suffix}`).value;
const enabled = Boolean(nextCondition);
$(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header");
$(`#sieve-builder-match${suffix}`).disabled = !enabled;
$(`#sieve-builder-match${suffix}`).disabled = !enabled || valuelessSieveKinds.has(nextCondition) || sizeSieveKinds.has(nextCondition);
$(`#sieve-builder-value${suffix}`).disabled = !enabled;
$(`#sieve-builder-negate${suffix}`).disabled = !enabled;
const customHeader = $(`#sieve-builder-header${suffix}`);
@@ -769,10 +963,13 @@ function updateSieveBuilder() {
.concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean));
const requirements = new Set();
for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement);
const conditionKinds = [$("#sieve-builder-condition").value, $("#sieve-builder-condition-2").value, $("#sieve-builder-condition-3").value]
.concat($$("#sieve-rule-dialog .sieve-dynamic-kind").map((node) => node.value));
for (const kind of conditionKinds) for (const requirement of sieveBuilderRequirements(kind, "")) requirements.add(requirement);
const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase()));
$("#sieve-builder-capability").textContent = missing.length
? `Server does not advertise: ${missing.join(", ")}`
: requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required";
? `${trText("Server does not advertise")}: ${missing.join(", ")}`
: requirements.size ? `${trText("Requires")}: ${[...requirements].join(", ")}` : trText("No optional extension required");
const vacationSupported = extensions.has("vacation");
const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value);
const scheduleSupported = extensions.has("date") && extensions.has("relational");
@@ -835,6 +1032,7 @@ async function appendSieveVacation() {
async function applySieveVisualRule(updateSelected) {
try {
const built = buildSieveVisualRule();
if (!await ensureSieveTargetFolders(built.actions)) return;
const editor = $("#sieve-editor");
let body = editor.value;
if (updateSelected) {
+391
View File
@@ -0,0 +1,391 @@
// Filter conveniences on top of the visual Sieve editor: templates, creating
// missing target folders, and "Apply now" — running a rule's actions on mail
// that is already in a folder (Sieve itself only sees newly delivered mail).
function fmtText(template, values = {}) {
return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
}
// ---------------------------------------------------------------- templates
const sieveRuleTemplates = [
{ id: "sender-folder", label: "Move mail from a sender", name: "from_sender", condition: "from", match: "is", action: "move", focus: "value" },
{ id: "subject-folder", label: "Subject contains words", name: "subject_words", condition: "subject", match: "contains", action: "move", focus: "value" },
{ id: "vip-flag", label: "Flag mail from a VIP", name: "vip", condition: "from", match: "is", action: "add-flag", target: "\\Flagged", stop: false, focus: "value" },
{ id: "newsletters", label: "Newsletters to a folder", name: "newsletters", condition: "list-id", match: "matches", value: "*", action: "move", target: "Newsletters", focus: "target" },
{ id: "alias-folder", label: "Mail sent to an alias", name: "alias", condition: "to-only", match: "is", action: "move", focus: "value" },
{ id: "large-attachments", label: "Large attachments", name: "large_attachments", condition: "has-attachment", second: { condition: "size-over", value: "10M" }, action: "label", focus: "target" },
{ id: "forward-copy", label: "Forward a copy", name: "forward_copy", condition: "from", match: "contains", action: "redirect-copy", stop: false, focus: "value" },
];
function renderSieveTemplates() {
const host = $("#sieve-templates");
if (!host || host.childElementCount) return;
for (const template of sieveRuleTemplates) {
const chip = el("button", "sieve-template-chip", trText(template.label));
chip.type = "button";
chip.dataset.template = template.id;
chip.addEventListener("click", () => applySieveTemplate(template));
host.append(chip);
}
}
function applySieveTemplate(template) {
clearSieveRuleBuilder();
$("#sieve-builder-name").value = template.name;
$("#sieve-builder-stop").checked = template.stop !== false;
$("#sieve-builder-condition").value = template.condition;
$("#sieve-builder-match").value = template.match || "contains";
$("#sieve-builder-value").value = template.value || "";
if (template.second) {
$("#sieve-builder-condition-2").value = template.second.condition;
$("#sieve-builder-value-2").value = template.second.value || "";
$("#sieve-builder-logic").value = "allof";
}
$("#sieve-builder-action").value = template.action;
$("#sieve-builder-target").value = template.target || "";
updateSieveBuilder();
resetSieveApplyPanel();
const focus = template.focus === "target" ? $("#sieve-builder-target") : $("#sieve-builder-value");
setTimeout(() => focus?.focus(), 20);
}
// ------------------------------------------------------- missing folders
function folderExists(name) {
return state.folders.some((folder) => folder.name === name);
}
// Offers to create move/copy targets that do not exist yet. Returns false
// when the user cancels.
async function ensureSieveTargetFolders(actions) {
const missing = [...new Set(actions
.filter((item) => ["move", "copy"].includes(item.action) && item.target)
.map((item) => item.target)
.filter((name) => !folderExists(name)))];
if (!missing.length) return true;
const list = missing.map((name) => `“${name}”`).join(", ");
const create = await appDialog({
title: trText("Folder does not exist"),
message: fmtText("The rule moves mail to {folders}, which does not exist yet. Create it now?", { folders: list }),
confirmLabel: trText("Create folder"),
cancelLabel: trText("Keep rule as is"),
});
if (!create) return true;
try {
for (const name of missing) await request("/folders", { method: "POST", body: JSON.stringify({ name }) });
await refreshFolderListOnly({ forceServer: true });
toast(fmtText("Created {folders}", { folders: list }), "success");
} catch (error) {
toast(error.message, "error");
return false;
}
return true;
}
// ------------------------------------------------------------- apply now
const SIEVE_APPLY_CONCURRENCY = 4;
const SIEVE_LABEL_CHUNK = 200;
async function matchSieveRule(folder, tree) {
return request("/filters/match", { method: "POST", body: JSON.stringify({ folder, rule: tree }) });
}
// Splits a rule's actions into what can be done to existing messages.
function planSieveActions(actions) {
const plan = { flags: new Set(), labels: [], copies: [], move: null, skipped: [] };
for (const item of actions) {
if (item.action === "mark-read") plan.flags.add("\\Seen");
else if (item.action === "add-flag") sieveFlagValues(item.target).forEach((flag) => plan.flags.add(flag));
else if (item.action === "label") {
const tag = (state.tags || []).find((entry) => entry.id === item.tagId || entry.name === item.target);
if (tag) plan.labels.push(tag);
else plan.skipped.push(item.summary);
} else if (item.action === "copy" && item.target) plan.copies.push(item.target);
else if (["move", "trash"].includes(item.action) && item.target) plan.move = item.target;
else if (!["keep", "stop"].includes(item.action)) plan.skipped.push(item.summary);
}
return plan;
}
function sievePlanSummary(plan) {
const parts = [];
if (plan.flags.size) parts.push(`${trText("set flags")} ${[...plan.flags].join(", ")}`);
for (const tag of plan.labels) parts.push(`${trText("Add label")} “${tag.name}”`);
for (const target of plan.copies) parts.push(`${trText("Copy to")} “${target}”`);
if (plan.move) parts.push(`${trText("Move to")} “${plan.move}”`);
return parts.join("; ");
}
async function runWithConcurrency(items, worker, onProgress) {
let next = 0;
let done = 0;
const failures = [];
const lane = async () => {
while (next < items.length) {
const item = items[next++];
try { await worker(item); } catch (error) { failures.push(error); }
done += 1;
onProgress?.(done);
}
};
await Promise.all(Array.from({ length: Math.min(SIEVE_APPLY_CONCURRENCY, items.length) }, lane));
return failures;
}
// Applies a plan to `uids` in `folder`: flags and labels first, then copies,
// then the single move, so every step still finds the message in place.
async function executeSievePlan(folder, uids, plan, onProgress) {
const steps = (plan.flags.size ? 1 : 0) + plan.labels.length + plan.copies.length + (plan.move && plan.move !== folder ? 1 : 0);
const total = Math.max(1, steps * uids.length);
let base = 0;
const progress = (done) => onProgress?.(Math.min(total, base + done), total);
const failures = [];
if (plan.flags.size) {
const flags = [...plan.flags];
failures.push(...await runWithConcurrency(uids, (uid) => request(`/messages/${encodeURIComponent(folder)}/${uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags, add: true }) }), progress));
base += uids.length;
}
for (const tag of plan.labels) {
for (let index = 0; index < uids.length; index += SIEVE_LABEL_CHUNK) {
const chunk = uids.slice(index, index + SIEVE_LABEL_CHUNK);
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: chunk.map((uid) => ({ uid, folder })) }) });
} catch (error) { failures.push(error); }
progress(Math.min(uids.length, index + chunk.length));
}
base += uids.length;
}
for (const target of plan.copies) {
if (target === folder) { base += uids.length; continue; }
failures.push(...await runWithConcurrency(uids, (uid) => request("/messages/copy", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: target, uid }) }), progress));
base += uids.length;
}
if (plan.move && plan.move !== folder) {
const release = suppressRealtimeNotificationsForFolder(plan.move);
try {
failures.push(...await runWithConcurrency(uids, (uid) => request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: plan.move, uid }) }), progress));
} finally { release?.(); }
}
await refreshFolderListOnly({ forceServer: true });
await refreshCurrentMessageView();
return { failures: failures.length, firstError: failures[0]?.message || "" };
}
function sieveMatchDescription(result, folder) {
let text = fmtText("{count} messages in “{folder}” match this rule.", { count: result.matched, folder });
if (result.uncertain) text += ` ${fmtText("{count} more could not be checked exactly and will be left untouched.", { count: result.uncertain })}`;
if (result.truncated) text += ` ${fmtText("Only the first {count} will be processed.", { count: result.uids.length })}`;
return text;
}
// Shared confirmation + execution for "Apply now" everywhere.
async function applySieveRuleToFolder({ folder, tree, actions, result = null, onStatus = null }) {
const status = (text) => { onStatus?.(text); };
const plan = planSieveActions(actions);
const summary = sievePlanSummary(plan);
if (!summary) {
toast(trText("None of this rule's actions can be applied to existing messages"), "warning");
return null;
}
const match = result || await matchSieveRule(folder, tree);
if (!match.uids.length) {
status(sieveMatchDescription(match, folder));
toast(fmtText("No messages in “{folder}” match this rule", { folder }), "info");
return match;
}
let message = `${sieveMatchDescription(match, folder)}\n\n${trText("Actions")}: ${summary}.`;
if (plan.skipped.length) message += `\n${trText("Skipped for existing messages")}: ${plan.skipped.join("; ")}.`;
const confirmed = await appConfirm(message, { title: trText("Apply rule to existing messages"), confirmLabel: fmtText("Apply to {count} messages", { count: match.uids.length }) });
if (!confirmed) return match;
if (plan.move && !folderExists(plan.move) && !await ensureSieveTargetFolders([{ action: "move", target: plan.move }])) return match;
status(fmtText("Applying… {done}/{total}", { done: 0, total: match.uids.length }));
const outcome = await executeSievePlan(folder, match.uids, plan, (done, total) => status(fmtText("Applying… {done}/{total}", { done, total })));
const applied = fmtText("Rule applied to {count} messages", { count: match.uids.length });
if (outcome.failures) {
status(`${applied} · ${fmtText("{count} operations failed", { count: outcome.failures })}: ${outcome.firstError}`);
toast(`${applied} · ${fmtText("{count} operations failed", { count: outcome.failures })}`, "warning");
} else {
status(applied);
toast(applied, "success");
}
return match;
}
function sieveApplyFolderOptions(select, preferred) {
if (!select) return;
const current = select.value;
select.replaceChildren(...orderedFolders().map((folder) => new Option(folder.name, folder.name)));
const wanted = current || preferred || state.folder || inboxFolder();
select.value = folderExists(wanted) ? wanted : inboxFolder();
}
function resetSieveApplyPanel() {
state.sieveApplyPreview = null;
const run = $("#sieve-apply-run");
if (run) run.disabled = true;
const result = $("#sieve-apply-result");
if (result) result.textContent = "";
}
function sieveApplyKey(built, folder) {
return JSON.stringify({ folder, tree: built.tree, actions: built.actions.map((item) => [item.action, item.target]) });
}
async function checkSieveApplyMatches() {
const result = $("#sieve-apply-result");
const button = $("#sieve-apply-check");
const folder = $("#sieve-apply-folder").value;
resetSieveApplyPanel();
let built;
try { built = buildSieveVisualRule(); } catch (error) { result.textContent = error.message; return; }
setBusy(button, true);
result.textContent = trText("Checking…");
try {
const match = await matchSieveRule(folder, built.tree);
state.sieveApplyPreview = { key: sieveApplyKey(built, folder), match };
const plan = planSieveActions(built.actions);
let text = sieveMatchDescription(match, folder);
if (plan.skipped.length) text += ` ${trText("Skipped for existing messages")}: ${plan.skipped.join("; ")}.`;
if (!sievePlanSummary(plan)) text += ` ${trText("None of this rule's actions can be applied to existing messages")}.`;
result.textContent = text;
$("#sieve-apply-run").disabled = !match.uids.length || !sievePlanSummary(plan);
} catch (error) {
result.textContent = error.message;
} finally { setBusy(button, false); }
}
async function runSieveApplyNow() {
const folder = $("#sieve-apply-folder").value;
const result = $("#sieve-apply-result");
let built;
try { built = buildSieveVisualRule(); } catch (error) { result.textContent = error.message; return; }
const preview = state.sieveApplyPreview;
if (!preview || preview.key !== sieveApplyKey(built, folder)) {
resetSieveApplyPanel();
result.textContent = trText("The rule changed — check matches again.");
return;
}
const button = $("#sieve-apply-run");
setBusy(button, true);
try {
await applySieveRuleToFolder({ folder, tree: built.tree, actions: built.actions, result: preview.match, onStatus: (text) => { result.textContent = text; } });
} catch (error) {
result.textContent = error.message;
} finally {
setBusy(button, false);
state.sieveApplyPreview = null;
button.disabled = true;
}
}
async function runParsedSieveRuleNow(rule) {
const tree = rule.condition?.tree;
if (!rule.editable || !tree) {
toast(trText("This rule uses Sieve syntax that cannot be applied to existing messages"), "warning");
return;
}
const folder = await appSelect(trText("Apply rule to existing messages"), orderedFolders().map((item) => ({ value: item.name, label: item.name })), {
message: fmtText("Choose the folder whose messages “{rule}” should be applied to.", { rule: rule.name }),
value: folderExists(state.folder) ? state.folder : inboxFolder(),
confirmLabel: trText("Check matches"),
});
if (!folder) return;
try {
await applySieveRuleToFolder({ folder, tree, actions: rule.actions });
} catch (error) { toast(error.message, "error"); }
}
// -------------------------------------------- quick filters from a message
function quickFilterTree(config) {
const kind = config.kind === "domain" ? "from-domain" : config.kind === "subject" ? "subject" : "from";
return { test: { kind, match: config.match === "contains" ? "contains" : "is", value: config.value, header: "" } };
}
function quickFilterActions(config) {
const actions = [];
if (config.action === "move" || config.action === "copy") actions.push({ action: config.action, target: config.target, summary: sieveActionSummary(config.action, config.target) });
else if (config.action === "mark-read") actions.push({ action: "mark-read", target: "", summary: sieveActionSummary("mark-read") });
else if (config.action === "flag") actions.push({ action: "add-flag", target: "\\Flagged", summary: sieveActionSummary("add-flag", "\\Flagged") });
else if (config.action === "discard") actions.push({ action: "discard", target: "", summary: sieveActionSummary("discard") });
if (config.markRead && config.action !== "mark-read") actions.unshift({ action: "mark-read", target: "", summary: sieveActionSummary("mark-read") });
return actions;
}
// Appends a rule to a Sieve script (creating it when needed) and validates it
// on the server before saving.
async function appendRuleToSieveScript(built, scriptName, activate) {
const scripts = state.sieveScripts || [];
const existing = scripts.find((item) => item.name === scriptName);
let current = "";
if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(scriptName)}`, { method: "GET" })).content || "";
const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
const validation = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content }) });
if (!validation.valid) throw new Error(validation.error || trText("Filter validation failed"));
await request(`/sieve/scripts/${encodeURIComponent(scriptName)}`, { method: "PUT", body: JSON.stringify({ content }) });
if (activate || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(scriptName)}/activate`, { method: "POST", body: "{}" });
}
async function loadSieveStatusForQuickFilter() {
const [sieveInfo, scripts] = await Promise.all([
request("/sieve/status", { method: "GET" }),
request("/sieve/scripts", { method: "GET" }),
]);
state.sieveInfo = sieveInfo;
state.sieveScripts = scripts.scripts || [];
return sieveInfo;
}
const NEW_FOLDER_CHOICE = "\u0000new";
// "Always move messages from this sender…" — one rule, optionally applied to
// what is already in the current folder.
async function alwaysMoveFromSender(message) {
if (state.authPolicy.sieveEnabled === false) return;
const sender = String(message?.from_address || "").trim();
if (!sender) return toast(trText("This message has no sender address"), "warning");
const sourceFolder = message.folder || state.folder;
try { await loadSieveStatusForQuickFilter(); } catch (error) { return toast(`${trText("ManageSieve unavailable")}: ${error.message}`, "error"); }
const folders = orderedFolders().filter((item) => item.name !== sourceFolder).map((item) => ({ value: item.name, label: item.name }));
folders.push({ value: NEW_FOLDER_CHOICE, label: trText("New folder…") });
let target = await appSelect(trText("Always move messages from this sender"), folders, {
message: fmtText("New mail from {sender} will be moved automatically to:", { sender }),
value: detectSpecialFolder("archive") || folders[0]?.value || "",
confirmLabel: trText("Continue"),
});
if (!target) return;
if (target === NEW_FOLDER_CHOICE) {
target = String(await appDialog({ title: trText("New folder"), input: { value: "", required: true, placeholder: trText("Folder name") }, confirmLabel: trText("Create folder") }) || "").trim();
if (!target) return;
try {
await request("/folders", { method: "POST", body: JSON.stringify({ name: target }) });
await refreshFolderListOnly({ forceServer: true });
} catch (error) { return toast(error.message, "error"); }
}
const config = { kind: "sender", match: "is", value: sender, action: "move", target, stop: true };
const scripts = state.sieveScripts || [];
const scriptName = scripts.find((item) => item.active)?.name || scripts.find((item) => item.name === "rustbox")?.name || "rustbox";
try {
await appendRuleToSieveScript(buildQuickFilterRule(config), scriptName, !scripts.some((item) => item.active));
toast(fmtText("Filter added to {script}", { script: scriptName }), "success");
} catch (error) { return toast(error.message, "error"); }
try {
await applySieveRuleToFolder({ folder: sourceFolder, tree: quickFilterTree(config), actions: quickFilterActions(config) });
} catch (error) { toast(error.message, "error"); }
}
function bindSieveApplyUi() {
renderSieveTemplates();
$("#sieve-builder-add-exception")?.addEventListener("click", () => {
$("#sieve-builder-exceptions").append(createSieveExceptionRow());
updateSieveBuilder();
});
$("#sieve-apply-check")?.addEventListener("click", checkSieveApplyMatches);
$("#sieve-apply-run")?.addEventListener("click", runSieveApplyNow);
$("#sieve-apply-folder")?.addEventListener("change", resetSieveApplyPanel);
const dialog = $("#sieve-rule-dialog");
dialog?.addEventListener("input", (event) => { if (!event.target.closest(".sieve-apply-now")) resetSieveApplyPanel(); });
dialog?.addEventListener("click", (event) => { if (event.target.closest(".sieve-dynamic-remove")) resetSieveApplyPanel(); });
}
+16 -2
View File
@@ -425,6 +425,10 @@ function bindEvents() {
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
$("#pref-skin-depth").addEventListener("change", (event) => savePreference("skinDepth", event.target.value));
$("#pref-skin-gradients").addEventListener("change", (event) => savePreference("skinGradients", event.target.checked));
$("#pref-theme-light-from").addEventListener("change", (event) => { if (event.target.value) savePreference("themeLightFrom", event.target.value); });
$("#pref-theme-dark-from").addEventListener("change", (event) => { if (event.target.value) savePreference("themeDarkFrom", event.target.value); });
$("#pref-account-color-enabled").addEventListener("change", (event) => savePreference("accountColor", event.target.checked ? ($("#pref-account-color").value || "#4d75e6") : ""));
$("#pref-account-color").addEventListener("input", (event) => { if ($("#pref-account-color-enabled").checked) savePreference("accountColor", 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));
@@ -597,6 +601,9 @@ function bindEvents() {
$("#contact-compose").addEventListener("click", composeToContact);
$("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); });
$("#calendar-form").addEventListener("submit", saveCalendarEvent);
fillCalendarReminderSelects();
$("#calendar-default-reminder").addEventListener("change", () => saveCalendarReminderDefault("default_reminder_minutes", "#calendar-default-reminder"));
$("#calendar-default-reminder-extra").addEventListener("change", () => saveCalendarReminderDefault("default_extra_reminder_minutes", "#calendar-default-reminder-extra"));
$("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click());
$("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files));
$("#calendar-export").addEventListener("click", exportCalendarIcs);
@@ -636,6 +643,8 @@ function bindEvents() {
$("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog);
$("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); });
$("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); });
fillSieveKindSelects();
bindSieveApplyUi();
$("#sieve-vacation-start").addEventListener("change", updateSieveBuilder);
$("#sieve-vacation-end").addEventListener("change", updateSieveBuilder);
$("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false));
@@ -659,11 +668,11 @@ function bindEvents() {
$("#sieve-delete").addEventListener("click", deleteSieve);
bindEditor();
document.addEventListener("click", (event) => {
if (!event.target.closest("#context-menu")) hideContextMenu();
if (!event.target.closest("#context-menu, #context-submenu")) hideContextMenu();
if (!event.target.closest(".toolbar-menu, .toolbar-menu-popover[data-toolbar-menu-owner]")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
});
window.addEventListener("blur", hideContextMenu);
document.addEventListener("scroll", hideContextMenu, true);
document.addEventListener("scroll", (event) => { if (!event.target?.closest?.(".context-menu")) hideContextMenu(); }, true);
document.addEventListener("keydown", (event) => {
const tag = document.activeElement?.tagName || "";
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
@@ -715,6 +724,11 @@ function bindEvents() {
else if (state.prefs.theme === "system") { applyPreferences(); if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail); }
});
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
setInterval(() => {
if (state.prefs.theme !== "schedule" || root.dataset.theme === scheduledTheme()) return;
applyPreferences();
if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail);
}, 60000);
window.addEventListener("popstate", async (event) => {
const full = event.state?.rustboxMailFullscreen === true;
if (!full) {
+1
View File
@@ -10,4 +10,5 @@
72-openpgp-identities.js
80-settings-realtime.js
90-sieve.js
91-sieve-apply.js
95-bindings-boot.js