diff --git a/CHANGELOG.md b/CHANGELOG.md index a4660f18..4fae8fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.8.2] - 2024-06-22 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin and spam filter versions. + +## Added +- Webhooks support (#480) +- MTA Hooks (like milter but over HTTP) +- Manually train and test spam classifier (#473 #264 #257 #471) +- Allow configuring default mailbox names, roles and subscriptions (#125 #290 #458 #498) +- Include `robots.txt` (#542) + +### Changed +- Milter support on all SMTP stages (#183) +- Do not announce `STARTTLS` if the listener does not support it. + +### Fixed +- Incoming reports stored in the wrong subspace (#543) +- Return `OK` after a successful ManageSieve SASL authentication flow (#187) +- Case-insensitive search in settings API (#487) +- Fix `session.rcpt.script` default variable name (#502) + ## [0.8.1] - 2024-05-23 To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin and spam filter versions. diff --git a/SECURITY.md b/SECURITY.md index e1c6b18f..13e6c389 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ We provide security updates for the following versions of Stalwart Mail Server: | Version | Supported | | ------- | ------------------ | +| 0.8.x | :white_check_mark: | | 0.7.x | :white_check_mark: | -| 0.6.x | :white_check_mark: | -| < 0.5 | :x: | +| < 0.6 | :x: | ## Reporting a Vulnerability diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index 185d0e89..ce3927df 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -39,7 +39,7 @@ pub struct SessionConfig { pub mta_sts_policy: Option, pub milters: Vec, - pub hooks: Vec, + pub hooks: Vec, } #[derive(Default, Debug, Clone)] @@ -173,7 +173,7 @@ pub enum MilterVersion { } #[derive(Clone)] -pub struct FilterHook { +pub struct MTAHook { pub enable: IfBlock, pub url: String, pub timeout: Duration, @@ -582,7 +582,7 @@ fn parse_milter(config: &mut Config, id: &str, token_map: &TokenMap) -> Option Option { +fn parse_hooks(config: &mut Config, id: &str, token_map: &TokenMap) -> Option { let mut headers = HeaderMap::new(); for (header, value) in config @@ -627,7 +627,7 @@ fn parse_hooks(config: &mut Config, id: &str, token_map: &TokenMap) -> Option(format!("session.hook.{id}.enable"), [], "false") diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 160dd48c..a39dedc7 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -447,18 +447,18 @@ impl Session { } }; - // Run filter hooks + // Run MTA Hooks match self - .run_filter_hooks(Stage::Data, (&auth_message).into()) + .run_mta_hooks(Stage::Data, (&auth_message).into()) .await { Ok(modifications_) => { if !modifications_.is_empty() { tracing::debug!( parent: &self.span, - context = "filter_hook", + context = "mta_hook", event = "accept", - "FilterHook filter(s) accepted message."); + "MTAHook filter(s) accepted message."); modifications.retain(|m| !matches!(m, Modification::ReplaceBody { .. })); modifications.extend(modifications_); diff --git a/crates/smtp/src/inbound/ehlo.rs b/crates/smtp/src/inbound/ehlo.rs index 443362c9..5ce8d265 100644 --- a/crates/smtp/src/inbound/ehlo.rs +++ b/crates/smtp/src/inbound/ehlo.rs @@ -119,10 +119,10 @@ impl Session { return self.write(message.message.as_bytes()).await; } - // FilterHook filtering - if let Err(message) = self.run_filter_hooks(Stage::Ehlo, None).await { + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Ehlo, None).await { tracing::info!(parent: &self.span, - context = "filter_hook", + context = "mta_hook", event = "reject", domain = &self.data.helo_domain, reason = message.message.as_ref()); diff --git a/crates/smtp/src/inbound/hooks/client.rs b/crates/smtp/src/inbound/hooks/client.rs index e5c29e59..0a3f4639 100644 --- a/crates/smtp/src/inbound/hooks/client.rs +++ b/crates/smtp/src/inbound/hooks/client.rs @@ -21,21 +21,21 @@ * for more details. */ -use common::config::smtp::session::FilterHook; +use common::config::smtp::session::MTAHook; use super::{Request, Response}; -pub(super) async fn send_filter_hook_request( - filter_hook: &FilterHook, +pub(super) async fn send_mta_hook_request( + mta_hook: &MTAHook, request: Request, ) -> Result { let response = reqwest::Client::builder() - .timeout(filter_hook.timeout) - .danger_accept_invalid_certs(filter_hook.tls_allow_invalid_certs) + .timeout(mta_hook.timeout) + .danger_accept_invalid_certs(mta_hook.tls_allow_invalid_certs) .build() .map_err(|err| format!("Failed to create HTTP client: {}", err))? - .post(&filter_hook.url) - .headers(filter_hook.headers.clone()) + .post(&mta_hook.url) + .headers(mta_hook.headers.clone()) .body( serde_json::to_string(&request) .map_err(|err| format!("Failed to serialize Hook request: {}", err))?, @@ -47,7 +47,7 @@ pub(super) async fn send_filter_hook_request( if response.status().is_success() { if response .content_length() - .map_or(false, |len| len as usize > filter_hook.max_response_size) + .map_or(false, |len| len as usize > mta_hook.max_response_size) { return Err(format!( "Hook response too large ({} bytes)", diff --git a/crates/smtp/src/inbound/hooks/message.rs b/crates/smtp/src/inbound/hooks/message.rs index db324340..a7caee87 100644 --- a/crates/smtp/src/inbound/hooks/message.rs +++ b/crates/smtp/src/inbound/hooks/message.rs @@ -23,7 +23,7 @@ use ahash::AHashMap; use common::{ - config::smtp::session::{FilterHook, Stage}, + config::smtp::session::{MTAHook, Stage}, listener::SessionStream, DAEMON_NAME, }; @@ -40,33 +40,33 @@ use crate::{ }, }; -use super::{client::send_filter_hook_request, Action, Response}; +use super::{client::send_mta_hook_request, Action, Response}; impl Session { - pub async fn run_filter_hooks( + pub async fn run_mta_hooks( &self, stage: Stage, message: Option<&AuthenticatedMessage<'_>>, ) -> Result, FilterResponse> { - let filter_hooks = &self.core.core.smtp.session.hooks; - if filter_hooks.is_empty() { + let mta_hooks = &self.core.core.smtp.session.hooks; + if mta_hooks.is_empty() { return Ok(Vec::new()); } let mut modifications = Vec::new(); - for filter_hook in filter_hooks { - if !filter_hook.run_on_stage.contains(&stage) + for mta_hook in mta_hooks { + if !mta_hook.run_on_stage.contains(&stage) || !self .core .core - .eval_if(&filter_hook.enable, self) + .eval_if(&mta_hook.enable, self) .await .unwrap_or(false) { continue; } - match self.run_filter_hook(stage, filter_hook, message).await { + match self.run_mta_hook(stage, mta_hook, message).await { Ok(response) => { let mut new_modifications = Vec::with_capacity(response.modifications.len()); for modification in response.modifications { @@ -154,12 +154,12 @@ impl Session { Err(err) => { tracing::warn!( parent: &self.span, - filter_hook.url = &filter_hook.url, - context = "filter_hook", + mta_hook.url = &mta_hook.url, + context = "mta_hook", event = "error", reason = ?err, - "FilterHook filter failed"); - if filter_hook.tempfail_on_error { + "MTAHook filter failed"); + if mta_hook.tempfail_on_error { return Err(FilterResponse::server_failure()); } } @@ -169,10 +169,10 @@ impl Session { Ok(modifications) } - pub async fn run_filter_hook( + pub async fn run_mta_hook( &self, stage: Stage, - filter_hook: &FilterHook, + mta_hook: &MTAHook, message: Option<&AuthenticatedMessage<'_>>, ) -> Result { // Build request @@ -245,7 +245,7 @@ impl Session { }), }; - send_filter_hook_request(filter_hook, request).await + send_mta_hook_request(mta_hook, request).await } } diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs index b75a26d1..7875a2fe 100644 --- a/crates/smtp/src/inbound/mail.rs +++ b/crates/smtp/src/inbound/mail.rs @@ -179,10 +179,10 @@ impl Session { return self.write(message.message.as_bytes()).await; } - // FilterHook filtering - if let Err(message) = self.run_filter_hooks(Stage::Mail, None).await { + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Mail, None).await { tracing::info!(parent: &self.span, - context = "filter_hook", + context = "mta_hook", event = "reject", address = &self.data.mail_from.as_ref().unwrap().address, reason = message.message.as_ref()); diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs index ba8717b9..a8166fc2 100644 --- a/crates/smtp/src/inbound/rcpt.rs +++ b/crates/smtp/src/inbound/rcpt.rs @@ -143,10 +143,10 @@ impl Session { return self.write(message.message.as_bytes()).await; } - // FilterHook filtering - if let Err(message) = self.run_filter_hooks(Stage::Rcpt, None).await { + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Rcpt, None).await { tracing::info!(parent: &self.span, - context = "filter_hook", + context = "mta_hook", event = "reject", address = self.data.rcpt_to.last().unwrap().address, reason = message.message.as_ref()); diff --git a/crates/smtp/src/inbound/spawn.rs b/crates/smtp/src/inbound/spawn.rs index fae74232..b8a6c26a 100644 --- a/crates/smtp/src/inbound/spawn.rs +++ b/crates/smtp/src/inbound/spawn.rs @@ -131,11 +131,11 @@ impl Session { return false; } - // FilterHook filtering - if let Err(message) = self.run_filter_hooks(Stage::Connect, None).await { + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Connect, None).await { tracing::debug!(parent: &self.span, context = "connect", - event = "filter_hook-reject", + event = "mta_hook-reject", reason = message.message.as_ref()); let _ = self.write(message.message.as_bytes()).await; return false; diff --git a/tests/src/smtp/inbound/milter.rs b/tests/src/smtp/inbound/milter.rs index 9494f727..de7749d4 100644 --- a/tests/src/smtp/inbound/milter.rs +++ b/tests/src/smtp/inbound/milter.rs @@ -247,7 +247,7 @@ async fn milter_session() { } #[tokio::test] -async fn filter_hook_session() { +async fn mta_hook_session() { // Enable logging /*let disable = "true"; tracing::subscriber::set_global_default( @@ -258,11 +258,11 @@ async fn filter_hook_session() { .unwrap();*/ // Configure tests - let tmp_dir = TempDir::new("smtp_filter_hook_test", true); + let tmp_dir = TempDir::new("smtp_mta_hook_test", true); let mut config = Config::new(tmp_dir.update_config(CONFIG_JMILTER)).unwrap(); let stores = Stores::parse_all(&mut config).await; let core = Core::parse(&mut config, stores, Default::default()).await; - let _rx = spawn_mock_filter_hook_server(); + let _rx = spawn_mock_mta_hook_server(); tokio::time::sleep(Duration::from_millis(100)).await; let mut inner = Inner::default(); let mut qr = inner.init_test_queue(&core); @@ -785,7 +785,7 @@ async fn accept_milter( } } -pub fn spawn_mock_filter_hook_server() -> watch::Sender { +pub fn spawn_mock_mta_hook_server() -> watch::Sender { let (tx, rx) = watch::channel(true); let tests = Arc::new( serde_json::from_str::>( @@ -826,7 +826,7 @@ pub fn spawn_mock_filter_hook_server() -> watch::Sender { let request = serde_json::from_slice::(&fetch_body(&mut req, 1024 * 1024).await.unwrap()) .unwrap(); - let response = handle_filter_hook(request, tests); + let response = handle_mta_hook(request, tests); Ok::<_, hyper::Error>( Resource { @@ -856,7 +856,7 @@ pub fn spawn_mock_filter_hook_server() -> watch::Sender { tx } -fn handle_filter_hook(request: Request, tests: Arc>) -> hooks::Response { +fn handle_mta_hook(request: Request, tests: Arc>) -> hooks::Response { match request .envelope .unwrap()