diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index 20af9da5..5164d42e 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -329,7 +329,7 @@ impl LdapMappings { tracing::debug!( context = "ldap", - event = "fetch_princpal", + event = "fetch_principal", entry = ?entry, "LDAP entry" ); diff --git a/crates/imap-proto/src/parser/mod.rs b/crates/imap-proto/src/parser/mod.rs index ff7a90fd..61bf00f5 100644 --- a/crates/imap-proto/src/parser/mod.rs +++ b/crates/imap-proto/src/parser/mod.rs @@ -204,7 +204,12 @@ pub fn parse_date(value: &[u8]) -> Result { .trim(); NaiveDate::parse_from_str(date, "%d-%b-%Y") .map_err(|_| Cow::from(format!("Failed to parse date '{}'.", date))) - .map(|dt| dt.and_hms_opt(0, 0, 0).unwrap_or_default().timestamp()) + .map(|dt| { + dt.and_hms_opt(0, 0, 0) + .unwrap_or_default() + .and_utc() + .timestamp() + }) } pub fn parse_number(value: &[u8]) -> Result { diff --git a/crates/jmap/src/api/admin.rs b/crates/jmap/src/api/admin.rs index 7b51e4b6..7ba33118 100644 --- a/crates/jmap/src/api/admin.rs +++ b/crates/jmap/src/api/admin.rs @@ -78,8 +78,9 @@ pub enum UpdateSettings { prefix: String, }, Insert { - prefix: String, + prefix: Option, values: Vec<(String, String)>, + assert_empty: bool, }, } @@ -369,7 +370,7 @@ impl JMAP { })) .into_http_response() } - ("settings", None, &Method::GET) => { + ("settings", Some("group"), &Method::GET) => { // List settings let params = UrlParams::new(req.uri().query()); let prefix = params @@ -382,8 +383,8 @@ impl JMAP { } }) .unwrap_or_default(); - let groupby = params - .get("groupby") + let suffix = params + .get("suffix") .map(|s| { if !s.starts_with('.') { format!(".{s}") @@ -392,19 +393,20 @@ impl JMAP { } }) .unwrap_or_default(); + let field = params.get("field"); let filter = params.get("filter").unwrap_or_default(); let limit: usize = params.parse("limit").unwrap_or(0); let mut offset = params.parse::("page").unwrap_or(0).saturating_sub(1) * limit; let has_filter = !filter.is_empty(); - match self.store.config_list(&prefix).await { - Ok(settings) => if groupby.len() > 1 && !settings.is_empty() { + match self.store.config_list(&prefix, true).await { + Ok(settings) => if !suffix.is_empty() && !settings.is_empty() { // Obtain record ids let mut total = 0; let mut ids = Vec::new(); for (key, _) in &settings { - if let Some(id) = key.strip_suffix(&groupby) { + if let Some(id) = key.strip_suffix(&suffix) { if !id.is_empty() { if !has_filter { if offset == 0 { @@ -430,7 +432,9 @@ impl JMAP { record.insert("_id".to_string(), id.to_string()); for (k, v) in &settings { if let Some(k) = k.strip_prefix(&prefix) { - record.insert(k.to_string(), v.to_string()); + if field.map_or(true, |field| field == k) { + record.insert(k.to_string(), v.to_string()); + } } else if record.len() > 1 { break; } @@ -458,8 +462,7 @@ impl JMAP { "items": records, }, })) - } else if !groupby.is_empty() { - // groupby=. + } else { let total = settings.len(); let items = settings .into_iter() @@ -485,7 +488,34 @@ impl JMAP { "items": items, }, })) - } else { + } + .into_http_response(), + Err(err) => RequestError::blank( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Config fetch failed", + err.to_string(), + ) + .into_http_response(), + } + } + ("settings", Some("list"), &Method::GET) => { + // List settings + let params = UrlParams::new(req.uri().query()); + let prefix = params + .get("prefix") + .map(|p| { + if !p.ends_with('.') { + format!("{p}.") + } else { + p.to_string() + } + }) + .unwrap_or_default(); + let limit: usize = params.parse("limit").unwrap_or(0); + let offset = params.parse::("page").unwrap_or(0).saturating_sub(1) * limit; + + match self.store.config_list(&prefix, true).await { + Ok(settings) => { let total = settings.len(); let items = settings .into_iter() @@ -499,8 +529,8 @@ impl JMAP { "items": items, }, })) + .into_http_response() } - .into_http_response(), Err(err) => RequestError::blank( StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "Config fetch failed", @@ -509,18 +539,63 @@ impl JMAP { .into_http_response(), } } - ("settings", Some(key), &Method::GET) => match self.store.config_get(key).await { - Ok(value) => JsonResponse::new(json!({ - "data": value, - })) - .into_http_response(), - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Config fetch failed", - err.to_string(), - ) - .into_http_response(), - }, + ("settings", Some("keys"), &Method::GET) => { + // Obtain keys + let params = UrlParams::new(req.uri().query()); + let keys = params + .get("keys") + .map(|s| s.split(',').collect::>()) + .unwrap_or_default(); + let prefixes = params + .get("prefixes") + .map(|s| s.split(',').collect::>()) + .unwrap_or_default(); + let mut err = String::new(); + let mut results = AHashMap::with_capacity(keys.len()); + + for key in keys { + match self.store.config_get(key).await { + Ok(Some(value)) => { + results.insert(key.to_string(), value); + } + Ok(None) => {} + Err(err_) => { + err = err_.to_string(); + break; + } + } + } + for prefix in prefixes { + let prefix = if !prefix.ends_with('.') { + format!("{prefix}.") + } else { + prefix.to_string() + }; + match self.store.config_list(&prefix, false).await { + Ok(values) => { + results.extend(values); + } + Err(err_) => { + err = err_.to_string(); + break; + } + } + } + + if err.is_empty() { + JsonResponse::new(json!({ + "data": results, + })) + .into_http_response() + } else { + RequestError::blank( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Config fetch failed", + err.to_string(), + ) + .into_http_response() + } + } ("settings", Some(prefix), &Method::DELETE) if !prefix.is_empty() => { match self.store.config_clear(prefix).await { Ok(_) => JsonResponse::new(json!({ @@ -539,36 +614,66 @@ impl JMAP { if let Some(changes) = body.and_then(|body| serde_json::from_slice::>(&body).ok()) { - let mut result = Ok(()); + let mut result = Ok(true); 'next: for change in changes { match change { UpdateSettings::Delete { keys } => { for key in keys { - result = self.store.config_clear(key).await; + result = self.store.config_clear(key).await.map(|_| true); if result.is_err() { break 'next; } } } UpdateSettings::Clear { prefix } => { - result = self.store.config_clear_prefix(&prefix).await; + result = + self.store.config_clear_prefix(&prefix).await.map(|_| true); if result.is_err() { break; } } - UpdateSettings::Insert { prefix, values } => { + UpdateSettings::Insert { + prefix, + values, + assert_empty, + } => { + if assert_empty { + if let Some(prefix) = &prefix { + result = self + .store + .config_list(&format!("{prefix}."), true) + .await + .map(|items| items.is_empty()); + + if matches!(result, Ok(false) | Err(_)) { + break; + } + } else if let Some((key, _)) = values.first() { + result = self + .store + .config_get(key) + .await + .map(|items| items.is_none()); + + if matches!(result, Ok(false) | Err(_)) { + break; + } + } + } + result = self .store .config_set(values.into_iter().map(|(key, value)| ConfigKey { - key: if !prefix.is_empty() { + key: if let Some(prefix) = &prefix { format!("{prefix}.{key}") } else { key }, value, })) - .await; + .await + .map(|_| true); if result.is_err() { break; } @@ -577,10 +682,15 @@ impl JMAP { } match result { - Ok(_) => JsonResponse::new(json!({ + Ok(true) => JsonResponse::new(json!({ "data": (), })) .into_http_response(), + Ok(false) => JsonResponse::new(json!({ + "error": "assertFailed", + "details": "Failed to assert empty prefix", + })) + .into_http_response(), Err(err) => RequestError::blank( StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "Config update failed", diff --git a/crates/jmap/src/services/housekeeper.rs b/crates/jmap/src/services/housekeeper.rs index 4999e430..97ab964c 100644 --- a/crates/jmap/src/services/housekeeper.rs +++ b/crates/jmap/src/services/housekeeper.rs @@ -110,7 +110,7 @@ pub fn spawn_housekeeper( // for now, we just reload the blocked IP addresses let core = core.clone(); tokio::spawn(async move { - match core.store.config_list(BLOCKED_IP_PREFIX).await { + match core.store.config_list(BLOCKED_IP_PREFIX, true).await { Ok(settings) => { if let Err(err) = core .directory diff --git a/crates/store/src/dispatch/config.rs b/crates/store/src/dispatch/config.rs index bdb01e6f..364ff4ea 100644 --- a/crates/store/src/dispatch/config.rs +++ b/crates/store/src/dispatch/config.rs @@ -34,7 +34,11 @@ impl Store { .await } - pub async fn config_list(&self, prefix: &str) -> crate::Result> { + pub async fn config_list( + &self, + prefix: &str, + strip_prefix: bool, + ) -> crate::Result> { let key = prefix.as_bytes(); let from_key = ValueKey::from(ValueClass::Config(key.to_vec())); let to_key = ValueKey::from(ValueClass::Config( @@ -51,7 +55,7 @@ impl Store { std::str::from_utf8(key.get(1..).unwrap_or_default()).map_err(|_| { crate::Error::InternalError("Failed to deserialize config key".to_string()) })?; - if !prefix.is_empty() { + if strip_prefix && !prefix.is_empty() { key = key.strip_prefix(prefix).unwrap_or(key); } diff --git a/crates/utils/src/config/cron.rs b/crates/utils/src/config/cron.rs index a92ba936..98ca6aa2 100644 --- a/crates/utils/src/config/cron.rs +++ b/crates/utils/src/config/cron.rs @@ -23,7 +23,7 @@ use std::time::Duration; -use chrono::{Datelike, TimeZone, Timelike}; +use chrono::{Datelike, Local, TimeDelta, TimeZone, Timelike}; use super::utils::ParseValue; @@ -36,39 +36,40 @@ pub enum SimpleCron { impl SimpleCron { pub fn time_to_next(&self) -> Duration { - let now = chrono::Local::now(); + let now = Local::now(); let next = match self { SimpleCron::Day { hour, minute } => { - let next = chrono::Local + let next = Local .with_ymd_and_hms(now.year(), now.month(), now.day(), *hour, *minute, 0) .earliest() - .unwrap_or_else(|| now - chrono::Duration::seconds(1)); + .unwrap_or_else(|| now - TimeDelta::try_seconds(1).unwrap_or_default()); if next < now { - next + chrono::Duration::days(1) + next + TimeDelta::try_days(1).unwrap_or_default() } else { next } } SimpleCron::Week { day, hour, minute } => { - let next = chrono::Local + let next = Local .with_ymd_and_hms(now.year(), now.month(), now.day(), *hour, *minute, 0) .earliest() - .unwrap_or_else(|| now - chrono::Duration::seconds(1)); + .unwrap_or_else(|| now - TimeDelta::try_seconds(1).unwrap_or_default()); if next < now { - next + chrono::Duration::days( + next + TimeDelta::try_days( (7 - now.weekday().number_from_monday() + *day).into(), ) + .unwrap_or_default() } else { next } } SimpleCron::Hour { minute } => { - let next = chrono::Local + let next = Local .with_ymd_and_hms(now.year(), now.month(), now.day(), now.hour(), *minute, 0) .earliest() - .unwrap_or_else(|| now - chrono::Duration::seconds(1)); + .unwrap_or_else(|| now - TimeDelta::try_seconds(1).unwrap_or_default()); if next < now { - next + chrono::Duration::hours(1) + next + TimeDelta::try_hours(1).unwrap_or_default() } else { next } @@ -96,9 +97,11 @@ impl ParseValue for SimpleCron { )); } } else if pos == 1 { - if value.as_bytes().first().ok_or_else(|| { - format!("Invalid cron key {key:?}: failed to parse cron weekday") - })? == &b'*' + if value + .as_bytes() + .first() + .ok_or_else(|| format!("Invalid cron key {key:?}: failed to parse cron hour"))? + == &b'*' { return Ok(SimpleCron::Hour { minute }); } else { diff --git a/resources/config/common/server.toml b/resources/config/common/server.toml index 4de0ce0a..ad6824a9 100644 --- a/resources/config/common/server.toml +++ b/resources/config/common/server.toml @@ -7,7 +7,7 @@ hostname = "%{HOST}%" max-connections = 8192 #[server.proxy] -#trusted-networks = {"127.0.0.0/8", "::1", "10.0.0.0/8"} +#trusted-networks = ["127.0.0.0/8", "::1", "10.0.0.0/8"] [server.security] blocked-networks = {} diff --git a/resources/config/common/tls.toml b/resources/config/common/tls.toml index 33e8972b..2c5fe746 100644 --- a/resources/config/common/tls.toml +++ b/resources/config/common/tls.toml @@ -8,7 +8,6 @@ implicit = false timeout = "1m" certificate = "default" #acme = "letsencrypt" -#sni = [{subject = "", certificate = ""}] #protocols = ["TLSv1.2", "TLSv1.3"] #ciphers = [ "TLS13_AES_256_GCM_SHA384", "TLS13_AES_128_GCM_SHA256", # "TLS13_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", @@ -26,5 +25,6 @@ port = 443 renew-before = "30d" [certificate."default"] +sni-subjects = [] cert = "file://__CERT_PATH__" private-key = "file://__PK_PATH__" diff --git a/resources/config/common/tracing.toml b/resources/config/common/tracing.toml index 73b1c0d8..466172c9 100644 --- a/resources/config/common/tracing.toml +++ b/resources/config/common/tracing.toml @@ -2,20 +2,23 @@ # Tracing & logging configuration ############################################# -#[global.tracing] -#method = "stdout" -#level = "trace" +[tracing."stdout"] +method = "stdout" +level = "trace" +enable = false -#[global.tracing] -#method = "open-telemetry" -#transport = "http" -#endpoint = "https://127.0.0.1/otel" -#headers = ["Authorization: "] -#level = "debug" +[tracing."ot"] +method = "open-telemetry" +transport = "http" +endpoint = "https://127.0.0.1/otel" +headers = ["Authorization: "] +level = "debug" +enable = false -[global.tracing] +[tracing."log"] method = "log" path = "%{BASE_PATH}%/logs" prefix = "stalwart.log" rotate = "daily" level = "info" +enable = true diff --git a/resources/config/smtp/session.toml b/resources/config/smtp/session.toml index df16c13c..b72ba077 100644 --- a/resources/config/smtp/session.toml +++ b/resources/config/smtp/session.toml @@ -92,7 +92,9 @@ return-path = false key = ["remote_ip"] concurrency = 5 #rate = "5/1h" +enable = true [[session.throttle]] key = ["sender_domain", "rcpt"] rate = "25/1h" +enable = true diff --git a/tests/src/jmap/vacation_response.rs b/tests/src/jmap/vacation_response.rs index 942e41b5..eb3c1e44 100644 --- a/tests/src/jmap/vacation_response.rs +++ b/tests/src/jmap/vacation_response.rs @@ -21,7 +21,7 @@ * for more details. */ -use chrono::{Duration, Utc}; +use chrono::{TimeDelta, Utc}; use directory::backend::internal::manage::ManageDirectory; use jmap_proto::types::id::Id; @@ -138,7 +138,12 @@ pub async fn test(params: &mut JMAPTest) { // Vacation responses should honor the configured date ranges client - .vacation_response_set_dates((Utc::now() + Duration::days(1)).timestamp().into(), None) + .vacation_response_set_dates( + (Utc::now() + TimeDelta::try_days(1).unwrap_or_default()) + .timestamp() + .into(), + None, + ) .await .unwrap(); lmtp.ingest( @@ -157,7 +162,12 @@ pub async fn test(params: &mut JMAPTest) { expect_nothing(&mut smtp_rx).await; client - .vacation_response_set_dates((Utc::now() - Duration::days(1)).timestamp().into(), None) + .vacation_response_set_dates( + (Utc::now() - TimeDelta::try_days(1).unwrap_or_default()) + .timestamp() + .into(), + None, + ) .await .unwrap(); smtp_settings.lock().do_stop = true;