Autoconfig and MS Autodiscover services (closes #336)

This commit is contained in:
mdecimus 2024-05-08 16:00:54 +02:00
parent baef85e55b
commit 1473d8cdcf
18 changed files with 411 additions and 73 deletions

23
Cargo.lock generated
View file

@ -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",

View file

@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
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"

View file

@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"

View file

@ -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)
}

View file

@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"

View file

@ -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"

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
config.push_str("<clientConfig version=\"1.1\">\n");
let _ = writeln!(&mut config, "\t<emailProvider id=\"{domain}\">");
let _ = writeln!(&mut config, "\t\t<domain>{domain}</domain>");
let _ = writeln!(&mut config, "\t\t<displayName>{emailaddress}</displayName>");
let _ = writeln!(
&mut config,
"\t\t<displayShortName>{domain}</displayShortName>"
);
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<hostname>{server_name}</hostname>");
let _ = writeln!(&mut config, "\t\t\t<port>{port}</port>");
let _ = writeln!(
&mut config,
"\t\t\t<socketType>{}</socketType>",
if is_tls { "SSL" } else { "STARTTLS" }
);
let _ = writeln!(
&mut config,
"\t\t\t<authentication>password-cleartext</authentication>"
);
let _ = writeln!(&mut config, "\t\t\t<username>{account_name}</username>");
let _ = writeln!(&mut config, "\t\t</{tag}>");
}
config.push_str("\t</emailProvider>\n");
let _ = writeln!(
&mut config,
"\t<clientConfigUpdate url=\"https://autoconfig.{domain}/.well-known/mail-v1.xml\"/>"
);
config.push_str("</clientConfig>\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<Vec<u8>>) -> 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, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let _ = writeln!(&mut config, "<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">");
let _ = writeln!(&mut config, "\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">");
let _ = writeln!(&mut config, "\t\t<User>");
let _ = writeln!(
&mut config,
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
);
let _ = writeln!(
&mut config,
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
);
// 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\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
);
let _ = writeln!(&mut config, "\t\t</User>");
let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
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<Protocol>");
let _ = writeln!(
&mut config,
"\t\t\t\t<Type>{}</Type>",
protocol.to_uppercase()
);
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{account_name}</LoginName>");
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
let _ = writeln!(
&mut config,
"\t\t\t\t<SSL>{}</SSL>",
if is_tls { "on" } else { "off" }
);
if is_tls {
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>TLS</Encryption>");
}
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
}
let _ = writeln!(&mut config, "\t\t</Account>");
let _ = writeln!(&mut config, "\t</Response>");
let _ = writeln!(&mut config, "</Autodiscover>");
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<String, String> {
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()
))
}

View file

@ -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<Vec<u8>> {
pub async fn fetch_body(req: &mut HttpRequest, max_size: usize) -> Option<Vec<u8>> {
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;

View file

@ -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(),

View file

@ -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;

View file

@ -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<Vec<u8>> {
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()
}

View file

@ -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"

View file

@ -1,6 +1,6 @@
[package]
name = "managesieve"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "nlp"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"

View file

@ -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"

View file

@ -1,6 +1,6 @@
[package]
name = "store"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "utils"
version = "0.7.3"
version = "0.8.0"
edition = "2021"
resolver = "2"