diff --git a/Cargo.lock b/Cargo.lock index 309fef47..2c8c3236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "common" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -1498,7 +1498,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "argon2", @@ -2702,7 +2702,7 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "imap" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "common", @@ -2898,7 +2898,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.7.3" +version = "0.8.0" dependencies = [ "aes", "aes-gcm", @@ -2930,6 +2930,7 @@ dependencies = [ "nlp", "p256", "pkcs8", + "quick-xml 0.31.0", "rand", "rasn", "rasn-cms", @@ -3313,7 +3314,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.7.3" +version = "0.8.0" dependencies = [ "common", "directory", @@ -3331,7 +3332,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "bincode", @@ -3608,7 +3609,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "bincode", @@ -5646,7 +5647,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "bincode", @@ -5762,7 +5763,7 @@ dependencies = [ [[package]] name = "stalwart-cli" -version = "0.7.3" +version = "0.8.0" dependencies = [ "clap", "console", @@ -5793,7 +5794,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -6636,7 +6637,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "utils" -version = "0.7.3" +version = "0.8.0" dependencies = [ "ahash 0.8.11", "base64 0.22.0", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 19b1f930..1605e955 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.7.3" +version = "0.8.0" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7a442b94..7b811c91 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/common/src/manager/config.rs b/crates/common/src/manager/config.rs index 75dc17cb..77319a96 100644 --- a/crates/common/src/manager/config.rs +++ b/crates/common/src/manager/config.rs @@ -448,7 +448,12 @@ impl ConfigManager { } } - result.sort_unstable(); + // Sort by name, then tls and finally port + result.sort_unstable_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| b.2.cmp(&a.2)) + .then_with(|| a.1.cmp(&b.1)) + }); Ok(result) } diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 9538df7e..c8f38dc7 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index b67ef3fc..03f0591e 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index f92efc2d..248cd050 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" @@ -56,6 +56,7 @@ async-trait = "0.1.68" lz4_flex = { version = "0.11", default-features = false } rev_lines = "0.3.0" x509-parser = "0.16.0" +quick-xml = "0.31" [dev-dependencies] ece = "2.2" diff --git a/crates/jmap/src/api/autoconfig.rs b/crates/jmap/src/api/autoconfig.rs new file mode 100644 index 00000000..a4ce484f --- /dev/null +++ b/crates/jmap/src/api/autoconfig.rs @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::fmt::Write; + +use common::manager::webadmin::Resource; +use directory::QueryBy; +use jmap_proto::error::request::RequestError; +use quick_xml::events::Event; +use quick_xml::Reader; +use utils::url_params::UrlParams; + +use crate::{api::http::ToHttpResponse, JMAP}; + +use super::{HttpRequest, HttpResponse}; + +impl JMAP { + pub async fn handle_autoconfig_request(&self, req: &HttpRequest) -> HttpResponse { + // Obtain parameters + let params = UrlParams::new(req.uri().query()); + let emailaddress = params + .get("emailaddress") + .unwrap_or_default() + .to_lowercase(); + let (account_name, server_name, domain) = + match self.autoconfig_parameters(&emailaddress).await { + Ok(result) => result, + Err(err) => return err.into_http_response(), + }; + let services = match self.core.storage.config.get_services().await { + Ok(services) => services, + Err(err) => return err.into_http_response(), + }; + + // Build XML response + let mut config = String::with_capacity(1024); + config.push_str("\n"); + config.push_str("\n"); + let _ = writeln!(&mut config, "\t"); + let _ = writeln!(&mut config, "\t\t{domain}"); + let _ = writeln!(&mut config, "\t\t{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\t{domain}" + ); + for (protocol, port, is_tls) in services { + let tag = match protocol.as_str() { + "imap" | "pop3" => "incomingServer", + "smtp" if port != 25 => "outgoingServer", + _ => continue, + }; + let _ = writeln!(&mut config, "\t\t<{tag} type=\"{protocol}\">"); + let _ = writeln!(&mut config, "\t\t\t{server_name}"); + let _ = writeln!(&mut config, "\t\t\t{port}"); + let _ = writeln!( + &mut config, + "\t\t\t{}", + if is_tls { "SSL" } else { "STARTTLS" } + ); + let _ = writeln!( + &mut config, + "\t\t\tpassword-cleartext" + ); + let _ = writeln!(&mut config, "\t\t\t{account_name}"); + let _ = writeln!(&mut config, "\t\t"); + } + + config.push_str("\t\n"); + let _ = writeln!( + &mut config, + "\t" + ); + config.push_str("\n"); + + Resource { + content_type: "text/xml+autoconfig; charset=utf-8", + contents: config.into_bytes(), + } + .into_http_response() + } + + pub async fn handle_autodiscover_request(&self, body: Option>) -> HttpResponse { + // Obtain parameters + let emailaddress = match parse_autodiscover_request(body.as_deref().unwrap_or_default()) { + Ok(emailaddress) => emailaddress, + Err(err) => { + return RequestError::blank(400, "Failed to parse autodiscover request", err) + .into_http_response() + } + }; + let (account_name, server_name, _) = match self.autoconfig_parameters(&emailaddress).await { + Ok(result) => result, + Err(err) => return err.into_http_response(), + }; + let services = match self.core.storage.config.get_services().await { + Ok(services) => services, + Err(err) => return err.into_http_response(), + }; + + // Build XML response + let mut config = String::with_capacity(1024); + let _ = writeln!(&mut config, ""); + let _ = writeln!(&mut config, ""); + let _ = writeln!(&mut config, "\t"); + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!( + &mut config, + "\t\t\t{emailaddress}" + ); + let _ = writeln!( + &mut config, + "\t\t\t{emailaddress}" + ); + // DeploymentId is a required field of User but we are not a MS Exchange server so use a random value + let _ = writeln!( + &mut config, + "\t\t\t644560b8-a1ce-429c-8ace-23395843f701" + ); + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!(&mut config, "\t\t\temail"); + let _ = writeln!(&mut config, "\t\t\tsettings"); + for (protocol, port, is_tls) in services { + match protocol.as_str() { + "imap" | "pop3" => (), + "smtp" if port != 25 => (), + _ => continue, + } + + let _ = writeln!(&mut config, "\t\t\t"); + let _ = writeln!( + &mut config, + "\t\t\t\t{}", + protocol.to_uppercase() + ); + let _ = writeln!(&mut config, "\t\t\t\t{server_name}"); + let _ = writeln!(&mut config, "\t\t\t\t{port}"); + let _ = writeln!(&mut config, "\t\t\t\t{account_name}"); + let _ = writeln!(&mut config, "\t\t\t\ton"); + let _ = writeln!(&mut config, "\t\t\t\t0"); + let _ = writeln!(&mut config, "\t\t\t\t0"); + let _ = writeln!( + &mut config, + "\t\t\t\t{}", + if is_tls { "on" } else { "off" } + ); + if is_tls { + let _ = writeln!(&mut config, "\t\t\t\tTLS"); + } + let _ = writeln!(&mut config, "\t\t\t\toff"); + let _ = writeln!(&mut config, "\t\t\t"); + } + + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!(&mut config, "\t"); + let _ = writeln!(&mut config, ""); + + Resource { + content_type: "text/xml; charset=utf-8", + contents: config.into_bytes(), + } + .into_http_response() + } + + async fn autoconfig_parameters<'x>( + &self, + emailaddress: &'x str, + ) -> Result<(String, String, &'x str), RequestError> { + let domain = if let Some((_, domain)) = emailaddress.rsplit_once('@') { + domain + } else { + return Err(RequestError::invalid_parameters()); + }; + + // Obtain server name + let server_name = if let Ok(Some(server_name)) = self + .core + .storage + .config + .get("lookup.default.hostname") + .await + { + server_name + } else { + tracing::error!("Autoconfig request failed: Server name not configured"); + return Err(RequestError::internal_server_error()); + }; + + // Find the account name by e-mail address + let mut account_name = emailaddress.to_string(); + for id in self + .core + .storage + .directory + .email_to_ids(emailaddress) + .await + .unwrap_or_default() + { + if let Ok(Some(principal)) = self + .core + .storage + .directory + .query(QueryBy::Id(id), false) + .await + { + account_name = principal.name; + break; + } + } + + Ok((account_name, server_name, domain)) + } +} + +fn parse_autodiscover_request(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("Empty request body".to_string()); + } + + let mut reader = Reader::from_reader(bytes); + reader.trim_text(true); + let mut buf = Vec::with_capacity(128); + + 'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] { + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let found_tag_name = e.name(); + if tag_name + .as_bytes() + .eq_ignore_ascii_case(found_tag_name.as_ref()) + { + continue 'outer; + } else if tag_name == "EMailAddress" { + // Skip unsupported tags under Request, such as AcceptableResponseSchema + let mut tag_count = 0; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::End(_)) => { + if tag_count == 0 { + break; + } else { + tag_count -= 1; + } + } + Ok(Event::Start(_)) => { + tag_count += 1; + } + Ok(Event::Eof) => { + return Err(format!( + "Expected value, found unexpected EOF at position {}.", + reader.buffer_position() + )) + } + _ => (), + } + } + } else { + return Err(format!( + "Expected tag {}, found unexpected tag {} at position {}.", + tag_name, + String::from_utf8_lossy(found_tag_name.as_ref()), + reader.buffer_position() + )); + } + } + Err(e) => { + return Err(format!( + "Error at position {}: {:?}", + reader.buffer_position(), + e + )) + } + _ => { + return Err(format!( + "Expected tag {}, found unexpected EOF at position {}.", + tag_name, + reader.buffer_position() + )) + } + } + } + } + + if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf) { + if let Ok(text) = text.unescape() { + if text.contains('@') { + return Ok(text.trim().to_lowercase()); + } + } + } + + Err(format!( + "Expected email address, found unexpected value at position {}.", + reader.buffer_position() + )) +} diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 63a88f49..fb10f455 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -46,7 +46,7 @@ use jmap_proto::{ }; use crate::{ - auth::{oauth::OAuthMetadata, AccessToken}, + auth::oauth::OAuthMetadata, blob::{DownloadResponse, UploadResponse}, services::state, JMAP, @@ -72,7 +72,7 @@ impl JMAP { let mut path = req.uri().path().split('/'); path.next(); - match path.next().unwrap_or("") { + match path.next().unwrap_or_default() { "jmap" => { // Authenticate request let (_in_flight, access_token) = @@ -88,12 +88,15 @@ impl JMAP { Err(err) => return err.into_http_response(), }; - match (path.next().unwrap_or(""), req.method()) { + match (path.next().unwrap_or_default(), req.method()) { ("", &Method::POST) => { return match fetch_body( &mut req, - self.core.jmap.request_max_size, - &access_token, + if !access_token.is_super_user() { + self.core.jmap.upload_max_size + } else { + 0 + }, ) .await .ok_or_else(|| RequestError::limit(RequestLimitError::SizeRequest)) @@ -159,8 +162,11 @@ impl JMAP { { return match fetch_body( &mut req, - self.core.jmap.upload_max_size, - &access_token, + if !access_token.is_super_user() { + self.core.jmap.upload_max_size + } else { + 0 + }, ) .await { @@ -204,7 +210,7 @@ impl JMAP { _ => (), } } - ".well-known" => match (path.next().unwrap_or(""), req.method()) { + ".well-known" => match (path.next().unwrap_or_default(), req.method()) { ("jmap", &Method::GET) => { // Authenticate request let (_in_flight, access_token) = @@ -265,12 +271,22 @@ impl JMAP { return RequestError::not_found().into_http_response(); } } + ("mail-v1.xml", &Method::GET) => { + return self.handle_autoconfig_request(&req).await; + } + ("autoconfig", &Method::GET) => { + if path.next().unwrap_or_default() == "mail" + && path.next().unwrap_or_default() == "config-v1.1.xml" + { + return self.handle_autoconfig_request(&req).await; + } + } (_, &Method::OPTIONS) => { return ().into_http_response(); } _ => (), }, - "auth" => match (path.next().unwrap_or(""), req.method()) { + "auth" => match (path.next().unwrap_or_default(), req.method()) { ("device", &Method::POST) => { return match self.is_anonymous_allowed(&session.remote_ip).await { Ok(_) => { @@ -300,7 +316,7 @@ impl JMAP { // Authenticate user return match self.authenticate_headers(&req, session.remote_ip).await { Ok(Some((_, access_token))) => { - let body = fetch_body(&mut req, 8192, &access_token).await; + let body = fetch_body(&mut req, 1024 * 1024).await; self.handle_api_manage_request(&req, body, access_token) .await } @@ -308,6 +324,22 @@ impl JMAP { Err(err) => err.into_http_response(), }; } + "mail" => { + if req.method() == Method::GET + && path.next().unwrap_or_default() == "config-v1.1.xml" + { + return self.handle_autoconfig_request(&req).await; + } + } + "autodiscover" => { + if req.method() == Method::POST + && path.next().unwrap_or_default() == "autodiscover.xml" + { + return self + .handle_autodiscover_request(fetch_body(&mut req, 8192).await) + .await; + } + } _ => { let path = req.uri().path(); return match self @@ -456,16 +488,11 @@ impl HttpSessionData { } } -pub async fn fetch_body( - req: &mut HttpRequest, - max_size: usize, - access_token: &AccessToken, -) -> Option> { +pub async fn fetch_body(req: &mut HttpRequest, max_size: usize) -> Option> { let mut bytes = Vec::with_capacity(1024); while let Some(Ok(frame)) = req.frame().await { if let Some(data) = frame.data_ref() { - if bytes.len() + data.len() <= max_size || max_size == 0 || access_token.is_super_user() - { + if bytes.len() + data.len() <= max_size || max_size == 0 { bytes.extend_from_slice(data); } else { return None; diff --git a/crates/jmap/src/api/management/domain.rs b/crates/jmap/src/api/management/domain.rs index cee4b5ae..dbd64445 100644 --- a/crates/jmap/src/api/management/domain.rs +++ b/crates/jmap/src/api/management/domain.rs @@ -217,19 +217,12 @@ impl JMAP { content: "v=spf1 a ra=postmaster -all".to_string(), }); } - records.push(DnsRecord { typ: "TXT".to_string(), name: format!("{domain_name}."), content: "v=spf1 mx ra=postmaster -all".to_string(), }); - records.push(DnsRecord { - typ: "CNAME".to_string(), - name: format!("autoconfig.{domain_name}."), - content: format!("{server_name}."), - }); - let mut has_https = false; for (protocol, port, is_tls) in self .core @@ -260,22 +253,27 @@ impl JMAP { content: format!("0 1 {port} {server_name}."), }); } - ("http", port @ 1..=u16::MAX) => { - if is_tls { - has_https = true; - records.push(DnsRecord { - typ: "SRV".to_string(), - name: format!("_autodiscover._tcp.{domain_name}."), - content: format!("0 1 {port} {server_name}."), - }); - } + ("http", _) if is_tls => { + has_https = true; } _ => (), } } - // Add MTA-STS record if has_https { + // Add autoconfig and autodiscover records + records.push(DnsRecord { + typ: "CNAME".to_string(), + name: format!("autoconfig.{domain_name}."), + content: format!("{server_name}."), + }); + records.push(DnsRecord { + typ: "CNAME".to_string(), + name: format!("autodiscover.{domain_name}."), + content: format!("{server_name}."), + }); + + // Add MTA-STS records if let Some(policy) = self.core.build_mta_sts_policy() { records.push(DnsRecord { typ: "CNAME".to_string(), diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 85924c07..d963150b 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -28,6 +28,7 @@ use utils::map::vec_map::VecMap; use crate::JmapInstance; +pub mod autoconfig; pub mod event_source; pub mod http; pub mod management; diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index 4ff8f066..61092e9f 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -23,11 +23,13 @@ use std::collections::HashMap; -use http_body_util::BodyExt; use hyper::{header::CONTENT_TYPE, StatusCode}; use serde::{Deserialize, Serialize}; -use crate::api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse}; +use crate::api::{ + http::{fetch_body, ToHttpResponse}, + HtmlResponse, HttpRequest, HttpResponse, +}; pub mod auth; pub mod token; @@ -272,17 +274,3 @@ impl FormData { self.fields.remove(key) } } - -pub async fn fetch_body(req: &mut HttpRequest, max_len: usize) -> Option> { - let mut bytes = Vec::with_capacity(1024); - while let Some(Ok(frame)) = req.frame().await { - if let Some(data) = frame.data_ref() { - if bytes.len() + data.len() <= max_len { - bytes.extend_from_slice(data); - } else { - return None; - } - } - } - bytes.into() -} diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index a37385b3..e279c437 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 89a66137..fc970663 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index cf6b920a..b397492c 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index bcc5a565..b8b55689 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index d7965685..fb3e757d 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2" diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 2bb353d4..22a36ada 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.7.3" +version = "0.8.0" edition = "2021" resolver = "2"