1 parent
5b22add1f3
commit
2c98b8f925
38 files changed
+915
-87
No files matched your search
+62
-12
@@ -218,6 +218,18 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub base_path: Option<String>,
|
||||
|
||||
#[serde(default = "default_feature_enabled")]
|
||||
pub sieve_enabled: bool,
|
||||
|
||||
#[serde(default = "default_feature_enabled")]
|
||||
pub diagnostics_enabled: bool,
|
||||
|
||||
#[serde(default = "default_feature_enabled")]
|
||||
pub about_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub security_headers_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub sieve_host: Option<String>,
|
||||
|
||||
@@ -406,6 +418,10 @@ fn default_interface_skin() -> String {
|
||||
"rustbox".to_string()
|
||||
}
|
||||
|
||||
fn default_feature_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_sieve_port() -> u16 {
|
||||
4190
|
||||
}
|
||||
@@ -470,6 +486,10 @@ impl Default for AppConfig {
|
||||
default_theme: default_theme(),
|
||||
default_interface_skin: default_interface_skin(),
|
||||
base_path: None,
|
||||
sieve_enabled: true,
|
||||
diagnostics_enabled: true,
|
||||
about_enabled: true,
|
||||
security_headers_enabled: false,
|
||||
sieve_host: None,
|
||||
sieve_port: default_sieve_port(),
|
||||
sieve_tls_mode: default_sieve_tls_mode(),
|
||||
@@ -654,8 +674,12 @@ impl AppConfig {
|
||||
{
|
||||
return Err("IMAP_HOST / imap_host must be configured".to_string());
|
||||
}
|
||||
if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 {
|
||||
return Err("network ports must be greater than zero".to_string());
|
||||
if self.port == 0
|
||||
|| self.imap_port == 0
|
||||
|| self.smtp_port == 0
|
||||
|| (self.sieve_enabled && self.sieve_port == 0)
|
||||
{
|
||||
return Err("enabled network ports must be greater than zero".to_string());
|
||||
}
|
||||
if let Some(log_dir) = self.log_dir.as_deref() {
|
||||
let log_dir = log_dir.trim();
|
||||
@@ -925,16 +949,18 @@ impl AppConfig {
|
||||
) {
|
||||
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
self.sieve_tls_mode.as_str(),
|
||||
"starttls" | "implicit" | "none"
|
||||
) {
|
||||
return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string());
|
||||
}
|
||||
if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth {
|
||||
tracing::warn!(
|
||||
"ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true"
|
||||
);
|
||||
if self.sieve_enabled {
|
||||
if !matches!(
|
||||
self.sieve_tls_mode.as_str(),
|
||||
"starttls" | "implicit" | "none"
|
||||
) {
|
||||
return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string());
|
||||
}
|
||||
if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth {
|
||||
tracing::warn!(
|
||||
"ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true"
|
||||
);
|
||||
}
|
||||
}
|
||||
self.parsed_outgoing_headers()?;
|
||||
if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 {
|
||||
@@ -993,6 +1019,10 @@ mod tests {
|
||||
assert_eq!(config.environment, "development");
|
||||
assert_eq!(config.default_theme, "system");
|
||||
assert_eq!(config.default_interface_skin, "rustbox");
|
||||
assert!(config.sieve_enabled);
|
||||
assert!(config.diagnostics_enabled);
|
||||
assert!(config.about_enabled);
|
||||
assert!(!config.security_headers_enabled);
|
||||
assert!(config.sieve_host.is_none());
|
||||
assert_eq!(config.sieve_port, 4190);
|
||||
assert_eq!(config.sieve_tls_mode, "starttls");
|
||||
@@ -1029,6 +1059,10 @@ mod tests {
|
||||
.merge(("environment", "production"))
|
||||
.merge(("default_theme", "light"))
|
||||
.merge(("default_interface_skin", "gmail"))
|
||||
.merge(("sieve_enabled", false))
|
||||
.merge(("diagnostics_enabled", false))
|
||||
.merge(("about_enabled", false))
|
||||
.merge(("security_headers_enabled", true))
|
||||
.merge(("sieve_host", "sieve.example.com"))
|
||||
.merge(("sieve_port", 4191u16))
|
||||
.merge(("sieve_tls_mode", "implicit"))
|
||||
@@ -1056,6 +1090,10 @@ mod tests {
|
||||
assert_eq!(config.environment, "production");
|
||||
assert_eq!(config.default_theme, "light");
|
||||
assert_eq!(config.default_interface_skin, "gmail");
|
||||
assert!(!config.sieve_enabled);
|
||||
assert!(!config.diagnostics_enabled);
|
||||
assert!(!config.about_enabled);
|
||||
assert!(config.security_headers_enabled);
|
||||
assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com"));
|
||||
assert_eq!(config.sieve_port, 4191);
|
||||
assert_eq!(config.sieve_tls_mode, "implicit");
|
||||
@@ -1082,6 +1120,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_sieve_skips_sieve_specific_validation() {
|
||||
let config = AppConfig {
|
||||
imap_host: Some("imap.example.com".to_string()),
|
||||
sieve_enabled: false,
|
||||
sieve_port: 0,
|
||||
sieve_tls_mode: "unsupported".to_string(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_default_theme_is_rejected() {
|
||||
let config = AppConfig {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod pgp;
|
||||
pub mod pool;
|
||||
pub mod response_templates;
|
||||
pub mod search_outbox;
|
||||
pub mod settings;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
pub mod trusted_senders;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::db::pool::Connection;
|
||||
|
||||
pub fn reset_all(conn: &mut Connection) -> Result<(), String> {
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| format!("Failed to start settings reset: {error}"))?;
|
||||
|
||||
for sql in [
|
||||
"DELETE FROM folder_preferences",
|
||||
"DELETE FROM special_folders",
|
||||
"DELETE FROM display_preferences",
|
||||
"DELETE FROM mail_preferences",
|
||||
"DELETE FROM notification_preferences",
|
||||
"DELETE FROM calendar_settings",
|
||||
"DELETE FROM pgp_preferences",
|
||||
"INSERT INTO display_preferences (id) VALUES (1)",
|
||||
"INSERT INTO mail_preferences (id) VALUES (1)",
|
||||
"INSERT INTO notification_preferences (id) VALUES (1)",
|
||||
"INSERT INTO calendar_settings (id) VALUES (1)",
|
||||
"INSERT INTO pgp_preferences (id) VALUES (1)",
|
||||
] {
|
||||
transaction
|
||||
.execute(sql, [])
|
||||
.map_err(|error| format!("Failed to reset settings: {error}"))?;
|
||||
}
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| format!("Failed to commit settings reset: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::display_preferences::{self, UpdateDisplayPreferences};
|
||||
use crate::db::folder_preferences::{self, UpdateFolderPreferences};
|
||||
use crate::db::folders;
|
||||
use crate::db::mail_preferences::{self, UpdateMailPreferences};
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn reset_restores_defaults_and_folder_overrides() {
|
||||
let mut conn = open_test_db();
|
||||
display_preferences::update_preferences(
|
||||
&conn,
|
||||
&UpdateDisplayPreferences {
|
||||
theme: Some("light".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
mail_preferences::update_preferences(
|
||||
&conn,
|
||||
&UpdateMailPreferences {
|
||||
messages_per_page: Some(100),
|
||||
mark_read_delay_ms: None,
|
||||
remote_images: None,
|
||||
preferred_body: None,
|
||||
thread_mode: None,
|
||||
collapse_quotes: None,
|
||||
show_snippets: None,
|
||||
default_sort: None,
|
||||
flagged_on_top: None,
|
||||
sync_imap_keywords: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
folders::insert_folder_if_new(&conn, "INBOX", Some("/"), "").unwrap();
|
||||
folder_preferences::update_preferences(
|
||||
&conn,
|
||||
"INBOX",
|
||||
&UpdateFolderPreferences {
|
||||
flagged_on_top: Some(true),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
reset_all(&mut conn).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
display_preferences::get_preferences(&conn).unwrap().theme,
|
||||
"system"
|
||||
);
|
||||
assert_eq!(
|
||||
mail_preferences::get_preferences(&conn)
|
||||
.unwrap()
|
||||
.messages_per_page,
|
||||
50
|
||||
);
|
||||
assert_eq!(
|
||||
folder_preferences::get_preferences(&conn, "INBOX")
|
||||
.unwrap()
|
||||
.flagged_on_top,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ pub struct AuthPolicyResponse {
|
||||
remember_me_days: u64,
|
||||
session_timeout_hours: u64,
|
||||
pgp_enabled: bool,
|
||||
sieve_enabled: bool,
|
||||
diagnostics_enabled: bool,
|
||||
about_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
||||
@@ -29,6 +32,9 @@ pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<A
|
||||
remember_me_days: config.remember_me_days,
|
||||
session_timeout_hours: config.session_timeout_hours,
|
||||
pgp_enabled: config.pgp_enabled,
|
||||
sieve_enabled: config.sieve_enabled,
|
||||
diagnostics_enabled: config.diagnostics_enabled,
|
||||
about_enabled: config.about_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+60
-8
@@ -19,6 +19,7 @@ pub mod quota;
|
||||
pub mod response_templates;
|
||||
pub mod search;
|
||||
pub mod send;
|
||||
pub mod settings;
|
||||
pub mod sieve;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
@@ -29,8 +30,10 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit};
|
||||
use axum::http::Request;
|
||||
use axum::http::{HeaderValue, Request};
|
||||
use axum::response::Response;
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
use axum::{Extension, Router, middleware};
|
||||
use tower_governor::GovernorError;
|
||||
@@ -101,6 +104,32 @@ impl KeyExtractor for ProxyAwareIpExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_security_headers(request: Request<Body>, next: middleware::Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
headers.insert(
|
||||
"permissions-policy",
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
headers.insert(
|
||||
"cross-origin-opener-policy",
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' ws: wss:",
|
||||
),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn create_router(
|
||||
config: Arc<AppConfig>,
|
||||
@@ -455,6 +484,9 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
delete(calendar::delete_meeting_template),
|
||||
)
|
||||
.route("/quota", get(quota::get_quota))
|
||||
.route("/settings/reset", post(settings::reset_all_settings));
|
||||
|
||||
let diagnostics_routes = Router::new()
|
||||
.route("/diagnostics/imap", get(diagnostics::imap_diagnostics))
|
||||
.route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics))
|
||||
.route("/diagnostics/sync", get(diagnostics::sync_diagnostics))
|
||||
@@ -466,7 +498,9 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
.route(
|
||||
"/diagnostics/search/rebuild",
|
||||
post(diagnostics::rebuild_search_index),
|
||||
)
|
||||
);
|
||||
|
||||
let sieve_routes = Router::new()
|
||||
.route("/sieve/status", get(sieve::status))
|
||||
.route("/sieve/scripts", get(sieve::list_scripts))
|
||||
.route(
|
||||
@@ -481,9 +515,20 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
)
|
||||
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
||||
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
||||
.route("/sieve/check", post(sieve::check_script))
|
||||
.layer(middleware::from_fn(auth_guard))
|
||||
.layer(middleware::from_fn(csrf_protection));
|
||||
.route("/sieve/check", post(sieve::check_script));
|
||||
|
||||
let protected_data = if config.diagnostics_enabled {
|
||||
protected_data.merge(diagnostics_routes)
|
||||
} else {
|
||||
protected_data
|
||||
};
|
||||
let protected_data = if config.sieve_enabled {
|
||||
protected_data.merge(sieve_routes)
|
||||
} else {
|
||||
protected_data
|
||||
}
|
||||
.layer(middleware::from_fn(auth_guard))
|
||||
.layer(middleware::from_fn(csrf_protection));
|
||||
|
||||
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
|
||||
|
||||
@@ -492,7 +537,8 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
.route("/ready", get(health::readiness_check))
|
||||
.nest("/auth", auth_router)
|
||||
.merge(ws_route)
|
||||
.merge(protected_data);
|
||||
.merge(protected_data)
|
||||
.fallback(|| async { axum::http::StatusCode::NOT_FOUND });
|
||||
|
||||
let inner = Router::new()
|
||||
.nest("/api", api_router)
|
||||
@@ -509,7 +555,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
}
|
||||
};
|
||||
|
||||
router
|
||||
let router = router
|
||||
.layer(Extension(idle_manager))
|
||||
.layer(Extension(event_bus))
|
||||
.layer(Extension(smtp_client))
|
||||
@@ -518,7 +564,13 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
.layer(Extension(imap_client))
|
||||
.layer(Extension(store))
|
||||
.layer(Extension(config.clone()))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if config.security_headers_enabled {
|
||||
router.layer(middleware::from_fn(add_security_headers))
|
||||
} else {
|
||||
router
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::session::SessionState;
|
||||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResetSettingsResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub async fn reset_all_settings(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
) -> Result<Response, AppError> {
|
||||
let mut conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|error| AppError::InternalError(format!("Database error: {error}")))?;
|
||||
|
||||
db::settings::reset_all(&mut conn).map_err(AppError::InternalError)?;
|
||||
|
||||
Ok(Json(ResetSettingsResponse { ok: true }).into_response())
|
||||
}
|
||||
@@ -277,6 +277,118 @@ async fn api_health_works_with_static_fallback() {
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_optional_features_are_not_registered() {
|
||||
let dir = setup_static_dir();
|
||||
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||
config.sieve_enabled = false;
|
||||
config.diagnostics_enabled = false;
|
||||
let app = create_router(
|
||||
Arc::new(config),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
|
||||
for uri in ["/api/sieve/status", "/api/diagnostics/imap"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_policy_exposes_optional_feature_switches() {
|
||||
let dir = setup_static_dir();
|
||||
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||
config.sieve_enabled = false;
|
||||
config.diagnostics_enabled = false;
|
||||
config.about_enabled = false;
|
||||
let app = create_router(
|
||||
Arc::new(config),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/policy")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["sieveEnabled"], false);
|
||||
assert_eq!(json["diagnosticsEnabled"], false);
|
||||
assert_eq!(json["aboutEnabled"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_headers_are_opt_in() {
|
||||
let dir = setup_static_dir();
|
||||
let app = create_router(
|
||||
test_config(dir.path().to_str().unwrap()),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.headers().get("content-security-policy").is_none());
|
||||
|
||||
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||
config.security_headers_enabled = true;
|
||||
let app = create_router(
|
||||
Arc::new(config),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers()["x-frame-options"], "DENY");
|
||||
assert!(
|
||||
response.headers()["content-security-policy"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("default-src 'self'")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_serves_embedded_rustbox_ui() {
|
||||
let dir = setup_static_dir();
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
|
||||
.settings-shortcuts {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.settings-panel[data-feature-disabled] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
min-height: 30px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
+8
-4
@@ -567,7 +567,7 @@
|
||||
data-i18n="filters">Filters</button><button class="settings-tab" data-tab="diagnostics"
|
||||
type="button">Diagnostics</button><button class="settings-tab" data-tab="account"
|
||||
type="button">Account</button><button class="settings-tab" data-tab="about" type="button"
|
||||
data-i18n="about">About</button></nav>
|
||||
data-i18n="about">About</button><button id="shortcuts-open" class="button quiet settings-shortcuts" type="button">Keyboard shortcuts</button></nav>
|
||||
<div class="settings-content">
|
||||
<section class="settings-panel" data-panel="appearance">
|
||||
<div class="settings-panel-heading">
|
||||
@@ -1120,7 +1120,7 @@
|
||||
</div>
|
||||
<div id="diagnostic-smtp-summary" class="capability-list"></div>
|
||||
</section>
|
||||
<section class="diagnostic-card">
|
||||
<section id="diagnostic-sieve-card" class="diagnostic-card">
|
||||
<div class="section-heading"><span>ManageSieve</span><strong id="diagnostic-sieve-status">Not
|
||||
checked</strong></div>
|
||||
<div id="diagnostic-sieve-summary" class="capability-list"></div>
|
||||
@@ -1162,6 +1162,12 @@
|
||||
sessions</button>
|
||||
</div>
|
||||
<div id="account-session-list" class="account-settings-list"></div>
|
||||
<div class="settings-panel-heading account-session-heading">
|
||||
<div>
|
||||
<h3>Reset settings</h3>
|
||||
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences. Messages, identities, keys and templates are kept.</p>
|
||||
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all settings</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-panel hidden" data-panel="about">
|
||||
<h2>RustBox __RUSTBOX_VERSION__</h2>
|
||||
@@ -1179,8 +1185,6 @@
|
||||
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
|
||||
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
||||
</dl>
|
||||
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
|
||||
shortcuts</button></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
pgpKeys: [],
|
||||
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
|
||||
pgpRecipientStatus: [],
|
||||
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true },
|
||||
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true },
|
||||
contacts: [],
|
||||
trustedSenders: new Set(),
|
||||
contactGroups: [],
|
||||
|
||||
@@ -458,13 +458,35 @@ function showApp() {
|
||||
$("#app-view").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function settingsTabAvailable(name) {
|
||||
if (name === "openpgp") return state.authPolicy.pgpEnabled !== false;
|
||||
if (name === "sieve") return state.authPolicy.sieveEnabled !== false;
|
||||
if (name === "diagnostics") return state.authPolicy.diagnosticsEnabled !== false;
|
||||
if (name === "about") return state.authPolicy.aboutEnabled !== false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFeatureAvailability() {
|
||||
for (const [name, enabled] of [
|
||||
["sieve", state.authPolicy.sieveEnabled !== false],
|
||||
["diagnostics", state.authPolicy.diagnosticsEnabled !== false],
|
||||
["about", state.authPolicy.aboutEnabled !== false],
|
||||
]) {
|
||||
$(`.settings-tab[data-tab="${name}"]`)?.classList.toggle("hidden", !enabled);
|
||||
$(`.settings-panel[data-panel="${name}"]`)?.toggleAttribute("data-feature-disabled", !enabled);
|
||||
}
|
||||
$("#diagnostic-sieve-card")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false);
|
||||
$("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false);
|
||||
}
|
||||
|
||||
async function loadAuthPolicy() {
|
||||
try {
|
||||
state.authPolicy = await request("/auth/policy", { method: "GET" });
|
||||
} catch (_) {
|
||||
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true };
|
||||
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true };
|
||||
}
|
||||
setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false);
|
||||
applyFeatureAvailability();
|
||||
const enabled = state.authPolicy.rememberMeEnabled !== false;
|
||||
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
|
||||
["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } });
|
||||
|
||||
@@ -500,9 +500,11 @@ function openMessageContextMenu(x, y, message) {
|
||||
{ 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(); } },
|
||||
{ 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"); } },
|
||||
...(state.authPolicy.sieveEnabled === false ? [] : [
|
||||
{ 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(); } },
|
||||
@@ -835,7 +837,9 @@ function openCurrentSenderMenu(x, y) {
|
||||
{ label: "Copy address", action: copySenderAddress },
|
||||
{ label: "Search messages", action: searchFromCurrentSender },
|
||||
"separator",
|
||||
{ label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") },
|
||||
...(state.authPolicy.sieveEnabled === false ? [] : [
|
||||
{ label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") },
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -985,6 +989,7 @@ async function prepareQuickFilterAdvanced(config) {
|
||||
}
|
||||
|
||||
async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
if (state.authPolicy.sieveEnabled === false) return;
|
||||
const detail = state.currentDetail;
|
||||
if (!detail) return;
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ async function loadQuota() {
|
||||
}
|
||||
|
||||
function openSettings(tab = "appearance") {
|
||||
if (tab === "openpgp" && state.authPolicy.pgpEnabled === false) tab = "appearance";
|
||||
if (!settingsTabAvailable(tab)) tab = "appearance";
|
||||
selectSettingsTab(tab);
|
||||
$("#settings-dialog").showModal();
|
||||
if (tab === "sieve") loadSieve();
|
||||
@@ -59,7 +59,7 @@ function openSettings(tab = "appearance") {
|
||||
}
|
||||
|
||||
function selectSettingsTab(name) {
|
||||
if (name === "openpgp" && state.authPolicy.pgpEnabled === false) name = "appearance";
|
||||
if (!settingsTabAvailable(name)) name = "appearance";
|
||||
$$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
|
||||
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name));
|
||||
}
|
||||
@@ -371,6 +371,20 @@ function disconnectRealtime() {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
|
||||
}
|
||||
|
||||
async function resetAllSettings() {
|
||||
if (!window.confirm("Reset all RustBox settings to defaults? Messages, identities, keys and templates will be kept.")) return;
|
||||
const button = $("#reset-all-settings");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await request("/settings/reset", { method: "POST", body: "{}" });
|
||||
["rustbox.calendarView", "rustbox.threadExpanded", "rustbox.composeFullscreen"].forEach((key) => localStorage.removeItem(key));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityBadge(label, supported) {
|
||||
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
|
||||
}
|
||||
@@ -410,13 +424,15 @@ async function loadDiagnostics() {
|
||||
smtpStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-smtp-summary").textContent = error.message;
|
||||
}
|
||||
try {
|
||||
const info = await request("/sieve/status", { method: "GET" });
|
||||
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
|
||||
} catch (error) {
|
||||
sieveStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-sieve-summary").textContent = error.message;
|
||||
if (state.authPolicy.sieveEnabled !== false) {
|
||||
try {
|
||||
const info = await request("/sieve/status", { method: "GET" });
|
||||
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
|
||||
} catch (error) {
|
||||
sieveStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-sieve-summary").textContent = error.message;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/sync", { method: "GET" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
async function loadSieve() {
|
||||
if (state.authPolicy.sieveEnabled === false) return;
|
||||
const status = $("#sieve-status");
|
||||
status.textContent = "Connecting…";
|
||||
try {
|
||||
|
||||
@@ -246,6 +246,7 @@ function bindEvents() {
|
||||
$("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal());
|
||||
$("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); });
|
||||
$("#account-revoke-others").addEventListener("click", revokeOtherSessions);
|
||||
$("#reset-all-settings").addEventListener("click", resetAllSettings);
|
||||
$("#add-account-form").addEventListener("submit", addBrowserAccount);
|
||||
$("#mobile-folders").addEventListener("click", toggleFolderDrawer);
|
||||
$("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));
|
||||
|
||||
Reference in new issue
Block a user