1 parent
beae94e932
commit
de4d70970c
51 files changed
+4034
-421
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,4 +8,5 @@
|
||||
56-interface-styles.css
|
||||
57-skin-tokens.css
|
||||
58-skin-immersive.css
|
||||
59-skin-gradients.css
|
||||
60-responsive.css
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(); });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,4 +10,5 @@
|
||||
72-openpgp-identities.js
|
||||
80-settings-realtime.js
|
||||
90-sieve.js
|
||||
91-sieve-apply.js
|
||||
95-bindings-boot.js
|
||||
Reference in new issue
Block a user