From 223bd59baba2a30a27cdd787b6cccdf2e0a0400c Mon Sep 17 00:00:00 2001 From: mdecimus Date: Mon, 1 Apr 2024 19:48:59 +0200 Subject: [PATCH] REST API cleanup --- .gitignore | 2 +- Cargo.lock | 25 +- crates/cli/Cargo.toml | 2 +- crates/cli/src/main.rs | 47 +- crates/common/Cargo.toml | 2 +- crates/common/src/config/manager.rs | 53 +- crates/common/src/config/scripts.rs | 2 +- crates/common/src/config/smtp/auth.rs | 5 +- crates/common/src/config/smtp/session.rs | 2 +- crates/directory/Cargo.toml | 2 +- crates/imap/Cargo.toml | 2 +- crates/install/Cargo.toml | 2 +- crates/jmap-proto/src/error/request.rs | 30 +- crates/jmap/Cargo.toml | 3 +- crates/jmap/src/api/admin.rs | 908 ------------- crates/jmap/src/api/http.rs | 41 +- crates/jmap/src/api/management/domain.rs | 102 ++ crates/jmap/src/api/management/mod.rs | 154 +++ crates/jmap/src/api/management/principal.rs | 407 ++++++ crates/jmap/src/api/management/queue.rs | 701 ++++++++++ crates/jmap/src/api/management/reload.rs | 95 ++ crates/jmap/src/api/management/report.rs | 401 ++++++ crates/jmap/src/api/management/settings.rs | 381 ++++++ crates/jmap/src/api/management/stores.rs | 62 + crates/jmap/src/api/mod.rs | 2 +- crates/jmap/src/email/crypto.rs | 329 ++--- crates/main/Cargo.toml | 2 +- crates/managesieve/Cargo.toml | 2 +- crates/nlp/Cargo.toml | 2 +- crates/smtp/Cargo.toml | 2 +- crates/smtp/src/core/management.rs | 1322 ------------------- crates/smtp/src/core/mod.rs | 12 - crates/smtp/src/reporting/dmarc.rs | 2 +- crates/smtp/src/reporting/mod.rs | 2 +- crates/smtp/src/reporting/tls.rs | 2 +- crates/store/Cargo.toml | 2 +- crates/utils/Cargo.toml | 2 +- crates/utils/src/config/mod.rs | 44 +- crates/utils/src/config/utils.rs | 34 +- resources/htx/crypto_disabled.htx | 1 - resources/htx/crypto_error.htx | 1 - resources/htx/crypto_footer.htx | 1 - resources/htx/crypto_form.htx | 1 - resources/htx/crypto_header.htx | 1 - resources/htx/crypto_success.htx | 1 - tests/src/jmap/crypto.rs | 7 +- tests/src/smtp/management/mod.rs | 15 +- tests/src/smtp/management/queue.rs | 8 +- tests/src/smtp/management/report.rs | 6 +- tests/src/smtp/outbound/mod.rs | 19 +- 50 files changed, 2722 insertions(+), 2531 deletions(-) delete mode 100644 crates/jmap/src/api/admin.rs create mode 100644 crates/jmap/src/api/management/domain.rs create mode 100644 crates/jmap/src/api/management/mod.rs create mode 100644 crates/jmap/src/api/management/principal.rs create mode 100644 crates/jmap/src/api/management/queue.rs create mode 100644 crates/jmap/src/api/management/reload.rs create mode 100644 crates/jmap/src/api/management/report.rs create mode 100644 crates/jmap/src/api/management/settings.rs create mode 100644 crates/jmap/src/api/management/stores.rs delete mode 100644 crates/smtp/src/core/management.rs delete mode 100644 resources/htx/crypto_disabled.htx delete mode 100644 resources/htx/crypto_error.htx delete mode 100644 resources/htx/crypto_footer.htx delete mode 100644 resources/htx/crypto_form.htx delete mode 100644 resources/htx/crypto_header.htx delete mode 100644 resources/htx/crypto_success.htx diff --git a/.gitignore b/.gitignore index f16e3f6c..d4c977ea 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ .vscode *.failed *_failed -stalwart.toml +/resources/config/config.toml run.sh _ignore .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 3c952eb8..8bcc87dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "common" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -1506,7 +1506,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "argon2", @@ -2674,7 +2674,7 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "imap" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "common", @@ -2870,7 +2870,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.6.0" +version = "0.7.0" dependencies = [ "aes", "aes-gcm", @@ -2894,6 +2894,7 @@ dependencies = [ "hyper-util", "jmap_proto", "lz4_flex", + "mail-auth", "mail-builder", "mail-parser", "mail-send", @@ -3280,7 +3281,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.6.0" +version = "0.7.0" dependencies = [ "common", "directory", @@ -3298,7 +3299,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "bincode", @@ -3566,7 +3567,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "bincode", @@ -5586,7 +5587,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "bincode", @@ -5702,7 +5703,7 @@ dependencies = [ [[package]] name = "stalwart-cli" -version = "0.6.0" +version = "0.7.0" dependencies = [ "clap", "console", @@ -5727,7 +5728,7 @@ dependencies = [ [[package]] name = "stalwart-install" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.0", "clap", @@ -5753,7 +5754,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -6619,7 +6620,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "utils" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ahash 0.8.11", "base64 0.21.7", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 028411e25..8983497d 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.6.0" +version = "0.7.0" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index dee44394..e17e4f9b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -23,6 +23,7 @@ use std::{ collections::HashMap, + fmt::Display, io::{BufRead, Write}, time::Duration, }; @@ -185,7 +186,19 @@ async fn oauth(url: &str) -> Credentials { #[serde(untagged)] pub enum Response { Data { data: T }, - Error { error: String, details: String }, + Error(ManagementApiError), +} + +#[derive(Deserialize)] +#[serde(tag = "error")] +pub enum ManagementApiError { + FieldAlreadyExists { field: String, value: String }, + FieldMissing { field: String }, + NotFound { item: String }, + Unsupported { details: String }, + AssertFailed, + Other { details: String }, + UnsupportedDirectoryOperation { class: String }, } impl Client { @@ -276,10 +289,38 @@ impl Client { .unwrap_result("deserialize response") { Response::Data { data } => Some(data), - Response::Error { error, details } => { - eprintln!("Request failed: {details} ({error:?})"); + Response::Error(error) => { + eprintln!("Request failed: {error})"); std::process::exit(1); } } } } + +impl Display for ManagementApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ManagementApiError::FieldAlreadyExists { field, value } => { + write!(f, "Field {} already exists with value {}.", field, value) + } + ManagementApiError::FieldMissing { field } => { + write!(f, "Field {} is missing.", field) + } + ManagementApiError::NotFound { item } => { + write!(f, "{} not found.", item) + } + ManagementApiError::Unsupported { details } => { + write!(f, "Unsupported: {}", details) + } + ManagementApiError::AssertFailed => { + write!(f, "Assertion failed.") + } + ManagementApiError::Other { details } => { + write!(f, "{}", details) + } + ManagementApiError::UnsupportedDirectoryOperation { class } => { + write!(f, "This operation is only available on internal directories. Your current directory is {class}.") + } + } + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index e8c74b34..b51b8963 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/common/src/config/manager.rs b/crates/common/src/config/manager.rs index 2e568894..0418e86b 100644 --- a/crates/common/src/config/manager.rs +++ b/crates/common/src/config/manager.rs @@ -196,12 +196,10 @@ impl ConfigManager { ) -> store::Result> { let mut results = self.db_list(prefix, strip_prefix).await?; for (key, value) in self.cfg_local.load().iter() { - if !strip_prefix || prefix.is_empty() { + if prefix.is_empty() || (!strip_prefix && key.starts_with(prefix)) { results.push((key.clone(), value.clone())); - } else if key.starts_with(prefix) { - if let Some(key) = key.strip_prefix(prefix) { - results.push((key.to_string(), value.clone())); - } + } else if let Some(key) = key.strip_prefix(prefix) { + results.push((key.to_string(), value.clone())); } } @@ -343,12 +341,46 @@ impl ConfigManager { if value == "true" || value == "false" || value.parse::().is_ok() { cfg_text.push_str(value); } else { - cfg_text.push('"'); - cfg_text.push_str(&value.replace('"', "\\\"")); - cfg_text.push('"'); - } + let mut needs_escape = false; + let mut has_lf = false; - cfg_text.push_str(value); + for ch in value.chars() { + match ch { + '"' | '\\' => { + needs_escape = true; + if has_lf { + break; + } + } + '\n' => { + has_lf = true; + if needs_escape { + break; + } + } + _ => {} + } + } + + if has_lf || (value.len() > 50 && needs_escape) { + cfg_text.push_str("'''"); + cfg_text.push_str(value); + cfg_text.push_str("'''"); + } else { + cfg_text.push('"'); + if needs_escape { + for ch in value.chars() { + if ch == '\\' || ch == '"' { + cfg_text.push('\\'); + } + cfg_text.push(ch); + } + } else { + cfg_text.push_str(value); + } + cfg_text.push('"'); + } + } cfg_text.push('\n'); } @@ -466,6 +498,7 @@ impl Core { .or_insert(cert.clone()); } core.tls.certificates.store(certificates.into()); + core.tls.self_signed_cert = self.tls.self_signed_cert.clone(); // Parser servers let mut servers = Servers::parse(&mut config); diff --git a/crates/common/src/config/scripts.rs b/crates/common/src/config/scripts.rs index bf9fd5a0..a97e1e01 100644 --- a/crates/common/src/config/scripts.rs +++ b/crates/common/src/config/scripts.rs @@ -299,7 +299,7 @@ impl Scripting { IfBlock::new::<()>( "sieve.trusted.from-name", [], - "'Mailer Daemon'", + "'Automated Message'", ) }), return_path: IfBlock::try_parse(config, "sieve.trusted.return-path", &token_map) diff --git a/crates/common/src/config/smtp/auth.rs b/crates/common/src/config/smtp/auth.rs index 5eabd90f..b782386a 100644 --- a/crates/common/src/config/smtp/auth.rs +++ b/crates/common/src/config/smtp/auth.rs @@ -90,8 +90,8 @@ impl Default for MailAuthConfig { sign: IfBlock::new::<()>( "auth.dkim.sign", [( - "local_port != 25", - "['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]", + "is_local_domain('*', sender_domain)", + "['rsa_' + sender_domain, 'ed_' + sender_domain]", )], "false", ), @@ -112,7 +112,6 @@ impl Default for MailAuthConfig { "disable", #[cfg(feature = "test_mode")] "relaxed", - ), verify_mail_from: IfBlock::new::( "auth.spf.verify.mail-from", diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index 2be34db5..663544ec 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -595,7 +595,7 @@ impl Default for SessionConfig { #[cfg(feature = "test_mode")] [], #[cfg(not(feature = "test_mode"))] - [("local_port != 25", "'*'")], + [("local_port != 25", "true")], "false", ), must_match_sender: IfBlock::new::<()>("session.auth.must-match-sender", [], "true"), diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 5a702a01..a4ec8a68 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 89cbcb18..4bb4114b 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml index 07c653d9..4ceb56e3 100644 --- a/crates/install/Cargo.toml +++ b/crates/install/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/mail-server" homepage = "https://github.com/stalwartlabs/mail-server" -version = "0.6.0" +version = "0.7.0" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/jmap-proto/src/error/request.rs b/crates/jmap-proto/src/error/request.rs index acec58d7..54540acf 100644 --- a/crates/jmap-proto/src/error/request.rs +++ b/crates/jmap-proto/src/error/request.rs @@ -23,37 +23,37 @@ use std::{borrow::Cow, fmt::Display}; -#[derive(Debug, Clone, Copy, serde::Serialize)] +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] pub enum RequestLimitError { - #[serde(rename(serialize = "maxSizeRequest"))] + #[serde(rename = "maxSizeRequest")] SizeRequest, - #[serde(rename(serialize = "maxSizeUpload"))] + #[serde(rename = "maxSizeUpload")] SizeUpload, - #[serde(rename(serialize = "maxCallsInRequest"))] + #[serde(rename = "maxCallsInRequest")] CallsIn, - #[serde(rename(serialize = "maxConcurrentRequests"))] + #[serde(rename = "maxConcurrentRequests")] ConcurrentRequest, - #[serde(rename(serialize = "maxConcurrentUpload"))] + #[serde(rename = "maxConcurrentUpload")] ConcurrentUpload, } -#[derive(Debug, serde::Serialize)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub enum RequestErrorType { - #[serde(rename(serialize = "urn:ietf:params:jmap:error:unknownCapability"))] + #[serde(rename = "urn:ietf:params:jmap:error:unknownCapability")] UnknownCapability, - #[serde(rename(serialize = "urn:ietf:params:jmap:error:notJSON"))] + #[serde(rename = "urn:ietf:params:jmap:error:notJSON")] NotJSON, - #[serde(rename(serialize = "urn:ietf:params:jmap:error:notRequest"))] + #[serde(rename = "urn:ietf:params:jmap:error:notRequest")] NotRequest, - #[serde(rename(serialize = "urn:ietf:params:jmap:error:limit"))] + #[serde(rename = "urn:ietf:params:jmap:error:limit")] Limit, - #[serde(rename(serialize = "about:blank"))] + #[serde(rename = "about:blank")] Other, } -#[derive(Debug, serde::Serialize)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct RequestError { - #[serde(rename(serialize = "type"))] + #[serde(rename = "type")] pub p_type: RequestErrorType, pub status: u16, #[serde(skip_serializing_if = "Option::is_none")] @@ -84,7 +84,7 @@ impl RequestError { "Internal Server Error", concat!( "There was a problem while processing your request. ", - "Please contact the system administrator." + "Please contact the system administrator if this problem persists." ), ) } diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index e51eda8e..0d7417e1 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" @@ -16,6 +16,7 @@ smtp-proto = { version = "0.1" } mail-parser = { version = "0.9", features = ["full_encoding", "serde_support", "ludicrous_mode"] } mail-builder = { version = "0.3", features = ["ludicrous_mode"] } mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] } +mail-auth = { version = "0.3" } sieve-rs = { version = "0.5" } serde = { version = "1.0", features = ["derive"]} serde_json = "1.0" diff --git a/crates/jmap/src/api/admin.rs b/crates/jmap/src/api/admin.rs deleted file mode 100644 index 4b7104fa..00000000 --- a/crates/jmap/src/api/admin.rs +++ /dev/null @@ -1,908 +0,0 @@ -/* - * 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::sync::Arc; - -use directory::{ - backend::internal::{lookup::DirectoryStore, manage::ManageDirectory, PrincipalUpdate}, - DirectoryError, ManagementError, Principal, QueryBy, Type, -}; -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method, StatusCode}; -use jmap_proto::error::request::RequestError; -use serde_json::json; -use store::ahash::AHashMap; -use utils::{config::ConfigKey, url_params::UrlParams}; - -use crate::{ - auth::{oauth::OAuthCodeRequest, AccessToken}, - JMAP, -}; - -use super::{http::ToHttpResponse, HttpRequest, JsonResponse}; - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct PrincipalResponse { - #[serde(default)] - pub id: u32, - #[serde(rename = "type")] - pub typ: Type, - #[serde(default)] - pub quota: u64, - #[serde(rename = "usedQuota")] - #[serde(default)] - pub used_quota: u64, - #[serde(default)] - pub name: String, - #[serde(default)] - pub emails: Vec, - #[serde(default)] - pub secrets: Vec, - #[serde(rename = "memberOf")] - #[serde(default)] - pub member_of: Vec, - #[serde(default)] - pub members: Vec, - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -#[serde(tag = "type")] -pub enum UpdateSettings { - Delete { - keys: Vec, - }, - Clear { - prefix: String, - }, - Insert { - prefix: Option, - values: Vec<(String, String)>, - assert_empty: bool, - }, -} - -impl JMAP { - pub async fn handle_api_manage_request( - &self, - req: &HttpRequest, - body: Option>, - access_token: Arc, - ) -> hyper::Response> { - let mut path = req.uri().path().split('/'); - path.next(); - path.next(); - - match (path.next().unwrap_or(""), path.next(), req.method()) { - ("principal", None, &Method::POST) => { - // Create principal - if let Some(principal) = - body.and_then(|body| serde_json::from_slice::(&body).ok()) - { - match self - .core - .storage - .data - .create_account( - Principal { - id: principal.id, - typ: principal.typ, - quota: principal.quota, - name: principal.name, - secrets: principal.secrets, - emails: principal.emails, - member_of: principal.member_of, - description: principal.description, - }, - principal.members, - ) - .await - { - Ok(account_id) => JsonResponse::new(json!({ - "data": account_id, - })) - .into_http_response(), - Err(err) => map_directory_error(err), - } - } else { - RequestError::blank( - StatusCode::BAD_REQUEST.as_u16(), - "Invalid parameters", - "Failed to deserialize create request", - ) - .into_http_response() - } - } - ("principal", None, &Method::GET) => { - // List principal ids - let params = UrlParams::new(req.uri().query()); - let filter = params.get("filter"); - let typ = params.parse("type"); - let page: usize = params.parse("page").unwrap_or(0); - let limit: usize = params.parse("limit").unwrap_or(0); - - match self.core.storage.data.list_accounts(filter, typ).await { - Ok(accounts) => { - let (total, accounts) = if limit > 0 { - let offset = page.saturating_sub(1) * limit; - ( - accounts.len(), - accounts.into_iter().skip(offset).take(limit).collect(), - ) - } else { - (accounts.len(), accounts) - }; - - JsonResponse::new(json!({ - "data": { - "items": accounts, - "total": total, - }, - })) - .into_http_response() - } - Err(err) => map_directory_error(err), - } - } - ("principal", Some(name), method) => { - // Fetch, update or delete principal - let account_id = match self.core.storage.data.get_account_id(name).await { - Ok(Some(account_id)) => account_id, - Ok(None) => { - return RequestError::blank( - StatusCode::NOT_FOUND.as_u16(), - "Not found", - "Account not found.", - ) - .into_http_response(); - } - Err(err) => { - return map_directory_error(err); - } - }; - - match *method { - Method::GET => { - let result = match self - .core - .storage - .data - .query(QueryBy::Id(account_id), true) - .await - { - Ok(Some(principal)) => { - self.core.storage.data.map_group_ids(principal).await - } - Ok(None) => { - return RequestError::blank( - StatusCode::NOT_FOUND.as_u16(), - "Not found", - "Account not found.", - ) - .into_http_response() - } - Err(err) => Err(err), - }; - - match result { - Ok(principal) => { - // Obtain quota usage - let mut principal = PrincipalResponse::from(principal); - principal.used_quota = - self.get_used_quota(account_id).await.unwrap_or_default() - as u64; - - // Obtain member names - for member_id in self - .core - .storage - .data - .get_members(account_id) - .await - .unwrap_or_default() - { - if let Ok(Some(member_principal)) = self - .core - .storage - .data - .query(QueryBy::Id(member_id), false) - .await - { - principal.members.push(member_principal.name); - } - } - - JsonResponse::new(json!({ - "data": principal, - })) - .into_http_response() - } - Err(err) => map_directory_error(err), - } - } - Method::DELETE => { - // Remove FTS index - if let Err(err) = self.core.storage.fts.remove_all(account_id).await { - tracing::warn!( - context = "fts", - event = "error", - reason = ?err, - "Failed to remove FTS index" - ); - return RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Failed to remove FTS index", - "Contact the administrator if this problem persists", - ) - .into_http_response(); - } - - // Delete account - match self - .core - .storage - .data - .delete_account(QueryBy::Id(account_id)) - .await - { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => map_directory_error(err), - } - } - Method::PATCH => { - if let Some(changes) = body.and_then(|body| { - serde_json::from_slice::>(&body).ok() - }) { - match self - .core - .storage - .data - .update_account(QueryBy::Id(account_id), changes) - .await - { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => map_directory_error(err), - } - } else { - RequestError::blank( - StatusCode::BAD_REQUEST.as_u16(), - "Invalid parameters", - "Failed to deserialize modify request", - ) - .into_http_response() - } - } - _ => RequestError::not_found().into_http_response(), - } - } - ("domain", None, &Method::GET) => { - // List domains - let params = UrlParams::new(req.uri().query()); - let filter = params.get("filter"); - let page: usize = params.parse("page").unwrap_or(0); - let limit: usize = params.parse("limit").unwrap_or(0); - - match self.core.storage.data.list_domains(filter).await { - Ok(domains) => { - let (total, domains) = if limit > 0 { - let offset = page.saturating_sub(1) * limit; - ( - domains.len(), - domains.into_iter().skip(offset).take(limit).collect(), - ) - } else { - (domains.len(), domains) - }; - - JsonResponse::new(json!({ - "data": { - "items": domains, - "total": total, - }, - })) - .into_http_response() - } - Err(err) => map_directory_error(err), - } - } - ("domain", Some(domain), &Method::POST) => { - // Create domain - match self.core.storage.data.create_domain(domain).await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => map_directory_error(err), - } - } - ("domain", Some(domain), &Method::DELETE) => { - // Delete domain - match self.core.storage.data.delete_domain(domain).await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => map_directory_error(err), - } - } - ("store", Some("maintenance"), &Method::GET) => { - match self - .core - .storage - .data - .purge_blobs(self.core.storage.blob.clone()) - .await - { - Ok(_) => match self.core.storage.data.purge_store().await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Purge database failed", - err.to_string(), - ) - .into_http_response(), - }, - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Purge blob failed", - err.to_string(), - ) - .into_http_response(), - } - } - ("reload", Some("lookup"), &Method::GET) => { - match self.core.reload_lookups().await { - Ok(result) => { - // Update core - if let Some(core) = result.new_core { - self.shared_core.store(core.into()); - } - - JsonResponse::new(json!({ - "data": result.config, - })) - .into_http_response() - } - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Database error", - err.to_string(), - ) - .into_http_response(), - } - } - ("reload", Some("certificate"), &Method::GET) => { - match self.core.reload_certificates().await { - Ok(result) => JsonResponse::new(json!({ - "data": result.config, - })) - .into_http_response(), - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Database error", - err.to_string(), - ) - .into_http_response(), - } - } - ("reload", Some("server.blocked-ip"), &Method::GET) => { - match self.core.reload_blocked_ips().await { - Ok(result) => JsonResponse::new(json!({ - "data": result.config, - })) - .into_http_response(), - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Database error", - err.to_string(), - ) - .into_http_response(), - } - } - ("reload", _, &Method::GET) => { - match self.core.reload().await { - Ok(result) => { - if !UrlParams::new(req.uri().query()).has_key("dry-run") { - // Update core - if let Some(core) = result.new_core { - self.shared_core.store(core.into()); - } - } - - JsonResponse::new(json!({ - "data": result.config, - })) - .into_http_response() - } - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Database error", - err.to_string(), - ) - .into_http_response(), - } - } - ("settings", Some("group"), &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 suffix = params - .get("suffix") - .map(|s| { - if !s.starts_with('.') { - format!(".{s}") - } else { - s.to_string() - } - }) - .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.core.storage.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(&suffix) { - if !id.is_empty() { - if !has_filter { - if offset == 0 { - if limit == 0 || ids.len() < limit { - ids.push(id); - } - } else { - offset -= 1; - } - total += 1; - } else { - ids.push(id); - } - } - } - } - - // Group settings by record id - let mut records = Vec::new(); - for id in ids { - let mut record = AHashMap::new(); - let prefix = format!("{id}."); - record.insert("_id".to_string(), id.to_string()); - for (k, v) in &settings { - if let Some(k) = k.strip_prefix(&prefix) { - if field.map_or(true, |field| field == k) { - record.insert(k.to_string(), v.to_string()); - } - } else if record.len() > 1 { - break; - } - } - - if has_filter { - if record.iter().any(|(_, v)| v.contains(filter)) { - if offset == 0 { - if limit == 0 || records.len() < limit { - records.push(record); - } - } else { - offset -= 1; - } - total += 1; - } - } else { - records.push(record); - } - } - - JsonResponse::new(json!({ - "data": { - "total": total, - "items": records, - }, - })) - } else { - let total = settings.len(); - let items = settings - .into_iter() - .filter_map(|(k, v)| { - if filter.is_empty() || k.contains(filter) || v.contains(filter) { - let k = - k.strip_prefix(&prefix).map(|k| k.to_string()).unwrap_or(k); - Some(json!({ - "_id": k, - "_value": v, - })) - } else { - None - } - }) - .skip(offset) - .take(if limit == 0 { total } else { limit }) - .collect::>(); - - JsonResponse::new(json!({ - "data": { - "total": total, - "items": items, - }, - })) - } - .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.core.storage.config.list(&prefix, true).await { - Ok(settings) => { - let total = settings.len(); - let items = settings - .into_iter() - .skip(offset) - .take(if limit == 0 { total } else { limit }) - .collect::>(); - - JsonResponse::new(json!({ - "data": { - "total": total, - "items": items, - }, - })) - .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.core.storage.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.core.storage.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.core.storage.config.clear(prefix).await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Config fetch failed", - err.to_string(), - ) - .into_http_response(), - } - } - ("settings", None, &Method::POST) => { - if let Some(changes) = - body.and_then(|body| serde_json::from_slice::>(&body).ok()) - { - let mut result = Ok(true); - - 'next: for change in changes { - match change { - UpdateSettings::Delete { keys } => { - for key in keys { - result = - self.core.storage.config.clear(key).await.map(|_| true); - if result.is_err() { - break 'next; - } - } - } - UpdateSettings::Clear { prefix } => { - result = self - .core - .storage - .config - .clear_prefix(&prefix) - .await - .map(|_| true); - if result.is_err() { - break; - } - } - UpdateSettings::Insert { - prefix, - values, - assert_empty, - } => { - if assert_empty { - if let Some(prefix) = &prefix { - result = self - .core - .storage - .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 - .core - .storage - .config - .get(key) - .await - .map(|items| items.is_none()); - - if matches!(result, Ok(false) | Err(_)) { - break; - } - } - } - - result = self - .core - .storage - .config - .set(values.into_iter().map(|(key, value)| ConfigKey { - key: if let Some(prefix) = &prefix { - format!("{prefix}.{key}") - } else { - key - }, - value, - })) - .await - .map(|_| true); - if result.is_err() { - break; - } - } - } - } - - match result { - 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", - err.to_string(), - ) - .into_http_response(), - } - } else { - RequestError::blank( - StatusCode::BAD_REQUEST.as_u16(), - "Invalid parameters", - "Failed to deserialize config update request", - ) - .into_http_response() - } - } - ("oauth", _, _) => self.handle_api_request(req, body, access_token).await, - (path_1 @ ("queue" | "reports"), Some(path_2), &Method::GET) => { - self.smtp - .handle_manage_request(req.uri(), req.method(), path_1, path_2, path.next()) - .await - } - _ => RequestError::not_found().into_http_response(), - } - } - - pub async fn handle_api_request( - &self, - req: &HttpRequest, - body: Option>, - access_token: Arc, - ) -> hyper::Response> { - let mut path = req.uri().path().split('/'); - path.next(); - path.next(); - - match (path.next().unwrap_or(""), path.next(), req.method()) { - ("oauth", Some("code"), &Method::POST) => { - if let Some(request) = - body.and_then(|body| serde_json::from_slice::(&body).ok()) - { - JsonResponse::new(json!({ - "data": self.issue_client_code(&access_token, request.client_id, request.redirect_uri), - })) - .into_http_response() - } else { - RequestError::blank( - StatusCode::BAD_REQUEST.as_u16(), - "Invalid parameters", - "Failed to deserialize modify request", - ) - .into_http_response() - } - } - _ => RequestError::unauthorized().into_http_response(), - } - } -} - -fn map_directory_error(err: DirectoryError) -> hyper::Response> { - match err { - DirectoryError::Management(err) => { - let response = match err { - ManagementError::MissingField(field) => json!({ - "error": "missingField", - "field": field, - "details": format!("Missing required field '{field}'."), - }), - ManagementError::AlreadyExists { field, value } => json!({ - "error": "alreadyExists", - "field": field, - "value": value, - "details": format!("Another record exists containing '{value}' in the '{field}' field."), - }), - ManagementError::NotFound(details) => json!({ - "error": "notFound", - "item": details, - "details": format!("'{details}' does not exist."), - }), - }; - JsonResponse::new(response).into_http_response() - } - DirectoryError::Unsupported => JsonResponse::new(json!({ - "error": "unsupported", - "details": "Requested action is unsupported", - })) - .into_http_response(), - err => { - tracing::warn!( - context = "directory", - event = "error", - reason = ?err, - "Directory error" - ); - - RequestError::blank( - StatusCode::INTERNAL_SERVER_ERROR.as_u16(), - "Database error", - "Contact the administrator if this problem persists", - ) - .into_http_response() - } - } -} - -impl From> for PrincipalResponse { - fn from(principal: Principal) -> Self { - PrincipalResponse { - id: principal.id, - typ: principal.typ, - quota: principal.quota, - name: principal.name, - emails: principal.emails, - member_of: principal.member_of, - description: principal.description, - secrets: principal.secrets, - used_quota: 0, - members: Vec::new(), - } - } -} diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 0b341fe1..fe0c956d 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -283,34 +283,18 @@ impl JMAP { } _ => (), }, - "crypto" if self.core.jmap.encrypt => match *req.method() { - Method::GET => { - return self.handle_crypto_update(&mut req, session.remote_ip).await; - } - Method::POST => { - return match self.is_auth_allowed_soft(&session.remote_ip).await { - Ok(_) => self.handle_crypto_update(&mut req, session.remote_ip).await, - Err(err) => err.into_http_response(), - } - } - _ => (), - }, "api" => { // Allow CORS preflight requests if req.method() == Method::OPTIONS { return ().into_http_response(); } - // Make sure the user is a superuser + // 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; - if access_token.is_super_user() { - self.handle_api_manage_request(&req, body, access_token) - .await - } else { - self.handle_api_request(&req, body, access_token).await - } + self.handle_api_manage_request(&req, body, access_token) + .await } Ok(None) => RequestError::unauthorized().into_http_response(), Err(err) => err.into_http_response(), @@ -489,6 +473,25 @@ impl ToHttpResponse for JsonResponse { } } +impl ToHttpResponse for store::Error { + fn into_http_response(self) -> HttpResponse { + tracing::error!(context = "store", error = %self, "Database error"); + + RequestError::internal_server_error().into_http_response() + } +} + +impl ToHttpResponse for serde_json::Error { + fn into_http_response(self) -> HttpResponse { + RequestError::blank( + StatusCode::BAD_REQUEST.as_u16(), + "Invalid parameters", + format!("Failed to deserialize JSON: {self}"), + ) + .into_http_response() + } +} + impl JsonResponse { pub fn new(inner: T) -> Self { JsonResponse { diff --git a/crates/jmap/src/api/management/domain.rs b/crates/jmap/src/api/management/domain.rs new file mode 100644 index 00000000..ada8649a --- /dev/null +++ b/crates/jmap/src/api/management/domain.rs @@ -0,0 +1,102 @@ +/* + * 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 directory::backend::internal::manage::ManageDirectory; +use http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use serde_json::json; +use utils::url_params::UrlParams; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + JMAP, +}; + +impl JMAP { + pub async fn handle_manage_domain( + &self, + req: &HttpRequest, + path: Vec<&str>, + ) -> hyper::Response> { + match (path.get(1), req.method()) { + (None, &Method::GET) => { + // List domains + let params = UrlParams::new(req.uri().query()); + let filter = params.get("filter"); + let page: usize = params.parse("page").unwrap_or(0); + let limit: usize = params.parse("limit").unwrap_or(0); + + match self.core.storage.data.list_domains(filter).await { + Ok(domains) => { + let (total, domains) = if limit > 0 { + let offset = page.saturating_sub(1) * limit; + ( + domains.len(), + domains.into_iter().skip(offset).take(limit).collect(), + ) + } else { + (domains.len(), domains) + }; + + JsonResponse::new(json!({ + "data": { + "items": domains, + "total": total, + }, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + } + } + (Some(domain), &Method::POST) => { + // Make sure the current directory supports updates + if let Some(response) = self.assert_supported_directory() { + return response; + } + + // Create domain + match self.core.storage.data.create_domain(domain).await { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + (Some(domain), &Method::DELETE) => { + // Delete domain + match self.core.storage.data.delete_domain(domain).await { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + + _ => RequestError::not_found().into_http_response(), + } + } +} diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs new file mode 100644 index 00000000..c1e609d8 --- /dev/null +++ b/crates/jmap/src/api/management/mod.rs @@ -0,0 +1,154 @@ +/* + * 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. +*/ + +pub mod domain; +pub mod principal; +pub mod queue; +pub mod reload; +pub mod report; +pub mod settings; +pub mod stores; + +use std::{borrow::Cow, sync::Arc}; + +use http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use serde::Serialize; +use serde_json::json; + +use crate::{auth::{oauth::OAuthCodeRequest, AccessToken}, JMAP}; + +use super::{http::ToHttpResponse, HttpRequest, JsonResponse}; + +#[derive(Serialize)] +#[serde(tag = "error")] +pub enum ManagementApiError { + FieldAlreadyExists { + field: Cow<'static, str>, + value: Cow<'static, str>, + }, + FieldMissing { + field: Cow<'static, str>, + }, + NotFound { + item: Cow<'static, str>, + }, + Unsupported { + details: Cow<'static, str>, + }, + AssertFailed, + Other { + details: Cow<'static, str>, + }, + UnsupportedDirectoryOperation { + class: Cow<'static, str>, + }, +} + +impl JMAP { + pub async fn handle_api_manage_request( + &self, + req: &HttpRequest, + body: Option>, + access_token: Arc, + ) -> hyper::Response> { + let path = req.uri().path().split('/').skip(2).collect::>(); + let is_superuser = access_token.is_super_user(); + + match path.first().copied().unwrap_or_default() { + "principal" if is_superuser => { + self.handle_manage_principal(req, path, body) + .await + } + "domain" if is_superuser => { + self.handle_manage_domain(req, path) + .await + } + "store" if is_superuser => { + self.handle_manage_store(req, path,) + .await + } + "reload" if is_superuser => { + self.handle_manage_reload(req, path) + .await + } + "settings" if is_superuser => { + self.handle_manage_settings(req, path, body) + .await + } + "queue" if is_superuser => { + self.handle_manage_queue(req, path) + .await + } + "reports" if is_superuser => { + self.handle_manage_reports(req, path) + .await + } + "oauth" => { + match serde_json::from_slice::(body.as_deref().unwrap_or_default()) { + Ok(request) => { + JsonResponse::new(json!({ + "data": { + "code": self.issue_client_code(&access_token, request.client_id, request.redirect_uri), + "is_admin": access_token.is_super_user(), + }, + })) + .into_http_response() + + }, + Err(err) => err.into_http_response(), + } + } + "crypto" => match *req.method() { + Method::POST => self.handle_crypto_post(access_token, body).await, + Method::GET => self.handle_crypto_get(access_token).await, + _ => RequestError::not_found().into_http_response(), + }, + "password" => match *req.method() { + Method::POST => self.handle_change_password(req, access_token, body).await, + _ => RequestError::not_found().into_http_response(), + }, + _ => RequestError::not_found().into_http_response(), + } + } +} + + +impl ToHttpResponse for ManagementApiError { + fn into_http_response(self) -> super::HttpResponse { + JsonResponse::new(self).into_http_response() + } +} + +impl From> for ManagementApiError { + fn from(details: Cow<'static, str>) -> Self { + ManagementApiError::Other { details } + } +} + +impl From for ManagementApiError { + fn from(details: String) -> Self { + ManagementApiError::Other { details: details.into() } + } +} diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs new file mode 100644 index 00000000..f4ffeac6 --- /dev/null +++ b/crates/jmap/src/api/management/principal.rs @@ -0,0 +1,407 @@ +/* + * 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::sync::Arc; + +use directory::{ + backend::internal::{ + lookup::DirectoryStore, manage::ManageDirectory, PrincipalField, PrincipalUpdate, + PrincipalValue, + }, + DirectoryError, DirectoryInner, ManagementError, Principal, QueryBy, Type, +}; +use http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, header, Method, StatusCode}; +use jmap_proto::error::request::RequestError; +use serde_json::json; +use utils::url_params::UrlParams; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, + auth::AccessToken, + JMAP, +}; + +use super::ManagementApiError; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct PrincipalResponse { + #[serde(default)] + pub id: u32, + #[serde(rename = "type")] + pub typ: Type, + #[serde(default)] + pub quota: u64, + #[serde(rename = "usedQuota")] + #[serde(default)] + pub used_quota: u64, + #[serde(default)] + pub name: String, + #[serde(default)] + pub emails: Vec, + #[serde(default)] + pub secrets: Vec, + #[serde(rename = "memberOf")] + #[serde(default)] + pub member_of: Vec, + #[serde(default)] + pub members: Vec, + #[serde(default)] + pub description: Option, +} + +impl JMAP { + pub async fn handle_manage_principal( + &self, + req: &HttpRequest, + path: Vec<&str>, + body: Option>, + ) -> hyper::Response> { + match (path.get(1), req.method()) { + (None, &Method::POST) => { + // Make sure the current directory supports updates + if let Some(response) = self.assert_supported_directory() { + return response; + } + + // Create principal + match serde_json::from_slice::( + body.as_deref().unwrap_or_default(), + ) { + Ok(principal) => { + match self + .core + .storage + .data + .create_account( + Principal { + id: principal.id, + typ: principal.typ, + quota: principal.quota, + name: principal.name, + secrets: principal.secrets, + emails: principal.emails, + member_of: principal.member_of, + description: principal.description, + }, + principal.members, + ) + .await + { + Ok(account_id) => JsonResponse::new(json!({ + "data": account_id, + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + Err(err) => err.into_http_response(), + } + } + (None, &Method::GET) => { + // List principal ids + let params = UrlParams::new(req.uri().query()); + let filter = params.get("filter"); + let typ = params.parse("type"); + let page: usize = params.parse("page").unwrap_or(0); + let limit: usize = params.parse("limit").unwrap_or(0); + + match self.core.storage.data.list_accounts(filter, typ).await { + Ok(accounts) => { + let (total, accounts) = if limit > 0 { + let offset = page.saturating_sub(1) * limit; + ( + accounts.len(), + accounts.into_iter().skip(offset).take(limit).collect(), + ) + } else { + (accounts.len(), accounts) + }; + + JsonResponse::new(json!({ + "data": { + "items": accounts, + "total": total, + }, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + } + } + (Some(name), method) => { + // Fetch, update or delete principal + let account_id = match self.core.storage.data.get_account_id(name).await { + Ok(Some(account_id)) => account_id, + Ok(None) => { + return RequestError::blank( + StatusCode::NOT_FOUND.as_u16(), + "Not found", + "Account not found.", + ) + .into_http_response(); + } + Err(err) => { + return err.into_http_response(); + } + }; + + match *method { + Method::GET => { + let result = match self + .core + .storage + .data + .query(QueryBy::Id(account_id), true) + .await + { + Ok(Some(principal)) => { + self.core.storage.data.map_group_ids(principal).await + } + Ok(None) => { + return RequestError::blank( + StatusCode::NOT_FOUND.as_u16(), + "Not found", + "Account not found.", + ) + .into_http_response() + } + Err(err) => Err(err), + }; + + match result { + Ok(principal) => { + // Obtain quota usage + let mut principal = PrincipalResponse::from(principal); + principal.used_quota = + self.get_used_quota(account_id).await.unwrap_or_default() + as u64; + + // Obtain member names + for member_id in self + .core + .storage + .data + .get_members(account_id) + .await + .unwrap_or_default() + { + if let Ok(Some(member_principal)) = self + .core + .storage + .data + .query(QueryBy::Id(member_id), false) + .await + { + principal.members.push(member_principal.name); + } + } + + JsonResponse::new(json!({ + "data": principal, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + } + } + Method::DELETE => { + // Remove FTS index + if let Err(err) = self.core.storage.fts.remove_all(account_id).await { + return err.into_http_response(); + } + + // Delete account + match self + .core + .storage + .data + .delete_account(QueryBy::Id(account_id)) + .await + { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + Method::PATCH => { + match serde_json::from_slice::>( + body.as_deref().unwrap_or_default(), + ) { + Ok(changes) => { + match self + .core + .storage + .data + .update_account(QueryBy::Id(account_id), changes) + .await + { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + Err(err) => err.into_http_response(), + } + } + _ => RequestError::not_found().into_http_response(), + } + } + + _ => RequestError::not_found().into_http_response(), + } + } + + pub async fn handle_change_password( + &self, + req: &HttpRequest, + access_token: Arc, + body: Option>, + ) -> hyper::Response> { + // Make sure the user authenticated using Basic auth + if req + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .map_or(true, |header| !header.to_lowercase().starts_with("basic ")) + { + return ManagementApiError::Other { + details: "Password changes only allowed using Basic auth".into(), + } + .into_http_response(); + } + + // Make sure the current directory supports updates + if let Some(response) = self.assert_supported_directory() { + return response; + } + + let new_password = match String::from_utf8(body.unwrap_or_default()) { + Ok(new_password) if !new_password.is_empty() => new_password, + _ => { + return ManagementApiError::Other { + details: "Invalid change password request".into(), + } + .into_http_response() + } + }; + + // Update password + match self + .core + .storage + .data + .update_account( + QueryBy::Id(access_token.primary_id()), + vec![PrincipalUpdate::set( + PrincipalField::Secrets, + PrincipalValue::StringList(vec![new_password]), + )], + ) + .await + { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + + pub fn assert_supported_directory( + &self, + ) -> Option>> { + ManagementApiError::UnsupportedDirectoryOperation { + class: match &self.core.storage.directory.store { + DirectoryInner::Internal(_) => return None, + DirectoryInner::Ldap(_) => "LDAP", + DirectoryInner::Sql(_) => "SQL", + DirectoryInner::Imap(_) => "IMAP", + DirectoryInner::Smtp(_) => "SMTP", + DirectoryInner::Memory(_) => "In-Memory", + } + .into(), + } + .into_http_response() + .into() + } +} + +impl From> for PrincipalResponse { + fn from(principal: Principal) -> Self { + PrincipalResponse { + id: principal.id, + typ: principal.typ, + quota: principal.quota, + name: principal.name, + emails: principal.emails, + member_of: principal.member_of, + description: principal.description, + secrets: principal.secrets, + used_quota: 0, + members: Vec::new(), + } + } +} + +impl ToHttpResponse for DirectoryError { + fn into_http_response(self) -> HttpResponse { + match self { + DirectoryError::Management(err) => { + let response = match err { + ManagementError::MissingField(field) => ManagementApiError::FieldMissing { + field: field.to_string().into(), + }, + ManagementError::AlreadyExists { field, value } => { + ManagementApiError::FieldAlreadyExists { + field: field.to_string().into(), + value: value.into(), + } + } + ManagementError::NotFound(details) => ManagementApiError::NotFound { + item: details.into(), + }, + }; + JsonResponse::new(response).into_http_response() + } + DirectoryError::Unsupported => JsonResponse::new(ManagementApiError::Unsupported { + details: "Requested action is unsupported".into(), + }) + .into_http_response(), + err => { + tracing::warn!( + context = "directory", + event = "error", + reason = ?err, + "Directory error" + ); + + RequestError::internal_server_error().into_http_response() + } + } + } +} diff --git a/crates/jmap/src/api/management/queue.rs b/crates/jmap/src/api/management/queue.rs new file mode 100644 index 00000000..2a17f860 --- /dev/null +++ b/crates/jmap/src/api/management/queue.rs @@ -0,0 +1,701 @@ +/* + * 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::str::FromStr; + +use http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use mail_auth::{ + dmarc::URI, + mta_sts::ReportUri, + report::{self, tlsrpt::TlsReport}, +}; +use mail_parser::DateTime; +use serde::{Deserializer, Serializer}; +use serde_json::json; +use smtp::queue::{self, ErrorDetails, HostResponse, QueueId, Status}; +use store::{ + write::{key::DeserializeBigEndian, now, Bincode, QueueClass, ReportEvent, ValueClass}, + Deserialize, IterateParams, ValueKey, +}; +use utils::url_params::UrlParams; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + JMAP, +}; + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct Message { + pub id: QueueId, + pub return_path: String, + pub domains: Vec, + #[serde(deserialize_with = "deserialize_datetime")] + #[serde(serialize_with = "serialize_datetime")] + pub created: DateTime, + pub size: usize, + #[serde(skip_serializing_if = "is_zero")] + #[serde(default)] + pub priority: i16, + #[serde(skip_serializing_if = "Option::is_none")] + pub env_id: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct Domain { + pub name: String, + pub status: Status, + pub recipients: Vec, + + pub retry_num: u32, + #[serde(deserialize_with = "deserialize_maybe_datetime")] + #[serde(serialize_with = "serialize_maybe_datetime")] + pub next_retry: Option, + #[serde(deserialize_with = "deserialize_maybe_datetime")] + #[serde(serialize_with = "serialize_maybe_datetime")] + pub next_notify: Option, + #[serde(deserialize_with = "deserialize_datetime")] + #[serde(serialize_with = "serialize_datetime")] + pub expires: DateTime, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct Recipient { + pub address: String, + pub status: Status, + #[serde(skip_serializing_if = "Option::is_none")] + pub orcpt: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum Report { + Tls { + id: String, + domain: String, + #[serde(deserialize_with = "deserialize_datetime")] + #[serde(serialize_with = "serialize_datetime")] + range_from: DateTime, + #[serde(deserialize_with = "deserialize_datetime")] + #[serde(serialize_with = "serialize_datetime")] + range_to: DateTime, + report: TlsReport, + rua: Vec, + }, + Dmarc { + id: String, + domain: String, + #[serde(deserialize_with = "deserialize_datetime")] + #[serde(serialize_with = "serialize_datetime")] + range_from: DateTime, + #[serde(deserialize_with = "deserialize_datetime")] + #[serde(serialize_with = "serialize_datetime")] + range_to: DateTime, + report: report::Report, + rua: Vec, + }, +} + +impl JMAP { + pub async fn handle_manage_queue( + &self, + req: &HttpRequest, + path: Vec<&str>, + ) -> hyper::Response> { + let params = UrlParams::new(req.uri().query()); + + match ( + path.get(1).copied().unwrap_or_default(), + path.get(2).copied(), + req.method(), + ) { + ("messages", None, &Method::GET) => { + let text = params.get("text"); + let from = params.get("from"); + let to = params.get("to"); + let before = params.parse::("before").map(|t| t.into_inner()); + let after = params.parse::("after").map(|t| t.into_inner()); + let page: usize = params.parse::("page").unwrap_or_default(); + let limit: usize = params.parse::("limit").unwrap_or_default(); + let values = params.has_key("values"); + + let mut result_ids = Vec::new(); + let mut result_values = Vec::new(); + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))); + let has_filters = text.is_some() + || from.is_some() + || to.is_some() + || before.is_some() + || after.is_some(); + let mut offset = page.saturating_sub(1) * limit; + let mut total = 0; + let mut total_returned = 0; + let _ = self + .core + .storage + .data + .iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let message = Bincode::::deserialize(value)?.inner; + let matches = !has_filters + || (text + .as_ref() + .map(|text| { + message.return_path.contains(text) + || message + .recipients + .iter() + .any(|r| r.address_lcase.contains(text)) + }) + .unwrap_or_else(|| { + from.as_ref() + .map_or(true, |from| message.return_path.contains(from)) + && to.as_ref().map_or(true, |to| { + message + .recipients + .iter() + .any(|r| r.address_lcase.contains(to)) + }) + }) + && before.as_ref().map_or(true, |before| { + message.next_delivery_event() < *before + }) + && after.as_ref().map_or(true, |after| { + message.next_delivery_event() > *after + })); + + if matches { + if offset == 0 { + if limit == 0 || total_returned < limit { + if values { + result_values.push(Message::from(&message)); + } else { + result_ids.push(key.deserialize_be_u64(1)?); + } + total_returned += 1; + } + } else { + offset -= 1; + } + + total += 1; + } + + Ok(true) + }, + ) + .await; + + if values { + JsonResponse::new(json!({ + "data":{ + "items": result_values, + "total": total, + }, + })) + } else { + JsonResponse::new(json!({ + "data": { + "items": result_ids, + "total": total, + }, + })) + } + .into_http_response() + } + ("messages", Some(queue_id), &Method::GET) => { + if let Some(message) = self + .smtp + .read_message(queue_id.parse().unwrap_or_default()) + .await + { + JsonResponse::new(json!({ + "data": Message::from(&message), + })) + .into_http_response() + } else { + RequestError::not_found().into_http_response() + } + } + ("messages", Some(queue_id), &Method::PATCH) => { + let time = params + .parse::("at") + .map(|t| t.into_inner()) + .unwrap_or_else(now); + let item = params.get("filter"); + + if let Some(mut message) = self + .smtp + .read_message(queue_id.parse().unwrap_or_default()) + .await + { + let prev_event = message.next_event().unwrap_or_default(); + let mut found = false; + + for domain in &mut message.domains { + if matches!( + domain.status, + Status::Scheduled | Status::TemporaryFailure(_) + ) && item + .as_ref() + .map_or(true, |item| domain.domain.contains(item)) + { + domain.retry.due = time; + if domain.expires > time { + domain.expires = time + 10; + } + found = true; + } + } + + if found { + let next_event = message.next_event().unwrap_or_default(); + message + .save_changes(&self.smtp, prev_event.into(), next_event.into()) + .await; + let _ = self.smtp.inner.queue_tx.send(queue::Event::Reload).await; + } + + JsonResponse::new(json!({ + "data": found, + })) + .into_http_response() + } else { + RequestError::not_found().into_http_response() + } + } + ("messages", Some(queue_id), &Method::DELETE) => { + if let Some(mut message) = self + .smtp + .read_message(queue_id.parse().unwrap_or_default()) + .await + { + let mut found = false; + let prev_event = message.next_event().unwrap_or_default(); + + if let Some(item) = params.get("filter") { + // Cancel delivery for all recipients that match + for rcpt in &mut message.recipients { + if rcpt.address_lcase.contains(item) { + rcpt.status = Status::PermanentFailure(HostResponse { + hostname: ErrorDetails::default(), + response: smtp_proto::Response { + code: 0, + esc: [0, 0, 0], + message: "Delivery canceled.".to_string(), + }, + }); + found = true; + } + } + if found { + // Mark as completed domains without any pending deliveries + for (domain_idx, domain) in message.domains.iter_mut().enumerate() { + if matches!( + domain.status, + Status::TemporaryFailure(_) | Status::Scheduled + ) { + let mut total_rcpt = 0; + let mut total_completed = 0; + + for rcpt in &message.recipients { + if rcpt.domain_idx == domain_idx { + total_rcpt += 1; + if matches!( + rcpt.status, + Status::PermanentFailure(_) | Status::Completed(_) + ) { + total_completed += 1; + } + } + } + + if total_rcpt == total_completed { + domain.status = Status::Completed(()); + } + } + } + + // Delete message if there are no pending deliveries + if message.domains.iter().any(|domain| { + matches!( + domain.status, + Status::TemporaryFailure(_) | Status::Scheduled + ) + }) { + let next_event = message.next_event().unwrap_or_default(); + message + .save_changes(&self.smtp, next_event.into(), prev_event.into()) + .await; + } else { + message.remove(&self.smtp, prev_event).await; + } + } + } else { + message.remove(&self.smtp, prev_event).await; + found = true; + } + + JsonResponse::new(json!({ + "data": found, + })) + .into_http_response() + } else { + RequestError::not_found().into_http_response() + } + } + ("reports", None, &Method::GET) => { + let domain = params.get("domain").map(|d| d.to_lowercase()); + let type_ = params.get("type").and_then(|t| match t { + "dmarc" => 0u8.into(), + "tls" => 1u8.into(), + _ => None, + }); + let page: usize = params.parse("page").unwrap_or_default(); + let limit: usize = params.parse("limit").unwrap_or_default(); + + let mut result = Vec::new(); + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::DmarcReportHeader( + ReportEvent { + due: 0, + policy_hash: 0, + seq_id: 0, + domain: String::new(), + }, + ))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::TlsReportHeader( + ReportEvent { + due: u64::MAX, + policy_hash: 0, + seq_id: 0, + domain: String::new(), + }, + ))); + let mut offset = page.saturating_sub(1) * limit; + let mut total = 0; + let mut total_returned = 0; + let _ = self + .core + .storage + .data + .iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + if type_.map_or(true, |t| t == *key.last().unwrap()) { + let event = ReportEvent::deserialize(key)?; + if event.seq_id != 0 + && domain.as_ref().map_or(true, |d| event.domain.contains(d)) + { + if offset == 0 { + if limit == 0 || total_returned < limit { + result.push( + if *key.last().unwrap() == 0 { + QueueClass::DmarcReportHeader(event) + } else { + QueueClass::TlsReportHeader(event) + } + .queue_id(), + ); + total_returned += 1; + } + } else { + offset -= 1; + } + + total += 1; + } + } + + Ok(true) + }, + ) + .await; + + JsonResponse::new(json!({ + "data": { + "items": result, + "total": total, + }, + })) + .into_http_response() + } + ("reports", Some(report_id), &Method::GET) => { + let mut result = None; + if let Some(report_id) = parse_queued_report_id(report_id) { + match report_id { + QueueClass::DmarcReportHeader(event) => { + let mut rua = Vec::new(); + if let Ok(Some(report)) = self + .smtp + .generate_dmarc_aggregate_report(&event, &mut rua, None) + .await + { + result = Report::dmarc(event, report, rua).into(); + } + } + QueueClass::TlsReportHeader(event) => { + let mut rua = Vec::new(); + if let Ok(Some(report)) = self + .smtp + .generate_tls_aggregate_report(&[event.clone()], &mut rua, None) + .await + { + result = Report::tls(event, report, rua).into(); + } + } + _ => (), + } + } + + if let Some(result) = result { + JsonResponse::new(json!({ + "data": result, + })) + .into_http_response() + } else { + RequestError::not_found().into_http_response() + } + } + ("reports", Some(report_id), &Method::DELETE) => { + if let Some(report_id) = parse_queued_report_id(report_id) { + match report_id { + QueueClass::DmarcReportHeader(event) => { + self.smtp.delete_dmarc_report(event).await; + } + QueueClass::TlsReportHeader(event) => { + self.smtp.delete_tls_report(vec![event]).await; + } + _ => (), + } + + JsonResponse::new(json!({ + "data": true, + })) + .into_http_response() + } else { + RequestError::not_found().into_http_response() + } + } + _ => RequestError::not_found().into_http_response(), + } + } +} + +impl From<&queue::Message> for Message { + fn from(message: &queue::Message) -> Self { + let now = now(); + + Message { + id: message.id, + return_path: message.return_path.clone(), + created: DateTime::from_timestamp(message.created as i64), + size: message.size, + priority: message.priority, + env_id: message.env_id.clone(), + domains: message + .domains + .iter() + .enumerate() + .map(|(idx, domain)| Domain { + name: domain.domain.clone(), + status: match &domain.status { + Status::Scheduled => Status::Scheduled, + Status::Completed(_) => Status::Completed(String::new()), + Status::TemporaryFailure(status) => { + Status::TemporaryFailure(status.to_string()) + } + Status::PermanentFailure(status) => { + Status::PermanentFailure(status.to_string()) + } + }, + retry_num: domain.retry.inner, + next_retry: Some(DateTime::from_timestamp(domain.retry.due as i64)), + next_notify: if domain.notify.due > now { + DateTime::from_timestamp(domain.notify.due as i64).into() + } else { + None + }, + recipients: message + .recipients + .iter() + .filter(|rcpt| rcpt.domain_idx == idx) + .map(|rcpt| Recipient { + address: rcpt.address.clone(), + status: match &rcpt.status { + Status::Scheduled => Status::Scheduled, + Status::Completed(status) => { + Status::Completed(status.response.to_string()) + } + Status::TemporaryFailure(status) => { + Status::TemporaryFailure(status.response.to_string()) + } + Status::PermanentFailure(status) => { + Status::PermanentFailure(status.response.to_string()) + } + }, + orcpt: rcpt.orcpt.clone(), + }) + .collect(), + expires: DateTime::from_timestamp(domain.expires as i64), + }) + .collect(), + } + } +} + +impl Report { + fn dmarc(event: ReportEvent, report: report::Report, rua: Vec) -> Self { + Self::Dmarc { + domain: event.domain.clone(), + range_from: DateTime::from_timestamp(event.seq_id as i64), + range_to: DateTime::from_timestamp(event.due as i64), + id: QueueClass::DmarcReportHeader(event).queue_id(), + report, + rua, + } + } + + fn tls(event: ReportEvent, report: TlsReport, rua: Vec) -> Self { + Self::Tls { + domain: event.domain.clone(), + range_from: DateTime::from_timestamp(event.seq_id as i64), + range_to: DateTime::from_timestamp(event.due as i64), + id: QueueClass::TlsReportHeader(event).queue_id(), + report, + rua, + } + } +} + +trait GenerateQueueId { + fn queue_id(&self) -> String; +} + +impl GenerateQueueId for QueueClass { + fn queue_id(&self) -> String { + match self { + QueueClass::DmarcReportHeader(h) => { + format!("d!{}!{}!{}!{}", h.domain, h.policy_hash, h.seq_id, h.due) + } + QueueClass::TlsReportHeader(h) => { + format!("t!{}!{}!{}!{}", h.domain, h.policy_hash, h.seq_id, h.due) + } + _ => unreachable!(), + } + } +} + +fn parse_queued_report_id(id: &str) -> Option { + let mut parts = id.split('!'); + let type_ = parts.next()?; + let event = ReportEvent { + domain: parts.next()?.to_string(), + policy_hash: parts.next().and_then(|p| p.parse::().ok())?, + seq_id: parts.next().and_then(|p| p.parse::().ok())?, + due: parts.next().and_then(|p| p.parse::().ok())?, + }; + match type_ { + "d" => Some(QueueClass::DmarcReportHeader(event)), + "t" => Some(QueueClass::TlsReportHeader(event)), + _ => None, + } +} + +struct Timestamp(u64); + +impl FromStr for Timestamp { + type Err = (); + + fn from_str(s: &str) -> Result { + if let Some(dt) = DateTime::parse_rfc3339(s) { + let instant = dt.to_timestamp() as u64; + if instant >= now() { + return Ok(Timestamp(instant)); + } + } + + Err(()) + } +} + +impl Timestamp { + pub fn into_inner(self) -> u64 { + self.0 + } +} + +fn serialize_maybe_datetime(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match value { + Some(value) => serializer.serialize_some(&value.to_rfc3339()), + None => serializer.serialize_none(), + } +} + +fn deserialize_maybe_datetime<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + if let Some(value) = as serde::Deserialize>::deserialize(deserializer)? { + if let Some(value) = DateTime::parse_rfc3339(value) { + Ok(Some(value)) + } else { + Err(serde::de::Error::custom( + "Failed to parse RFC3339 timestamp", + )) + } + } else { + Ok(None) + } +} + +fn serialize_datetime(value: &DateTime, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_rfc3339()) +} + +fn deserialize_datetime<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::Deserialize; + + if let Some(value) = DateTime::parse_rfc3339(<&str>::deserialize(deserializer)?) { + Ok(value) + } else { + Err(serde::de::Error::custom( + "Failed to parse RFC3339 timestamp", + )) + } +} + +fn is_zero(num: &i16) -> bool { + *num == 0 +} diff --git a/crates/jmap/src/api/management/reload.rs b/crates/jmap/src/api/management/reload.rs new file mode 100644 index 00000000..5bd9f0af --- /dev/null +++ b/crates/jmap/src/api/management/reload.rs @@ -0,0 +1,95 @@ +/* + * 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 http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use serde_json::json; +use utils::url_params::UrlParams; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + JMAP, +}; + +impl JMAP { + pub async fn handle_manage_reload( + &self, + req: &HttpRequest, + path: Vec<&str>, + ) -> hyper::Response> { + match (path.get(1).copied(), req.method()) { + (Some("lookup"), &Method::GET) => { + match self.core.reload_lookups().await { + Ok(result) => { + // Update core + if let Some(core) = result.new_core { + self.shared_core.store(core.into()); + } + + JsonResponse::new(json!({ + "data": result.config, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + } + } + (Some("certificate"), &Method::GET) => match self.core.reload_certificates().await { + Ok(result) => JsonResponse::new(json!({ + "data": result.config, + })) + .into_http_response(), + Err(err) => err.into_http_response(), + }, + (Some("server.blocked-ip"), &Method::GET) => { + match self.core.reload_blocked_ips().await { + Ok(result) => JsonResponse::new(json!({ + "data": result.config, + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + (_, &Method::GET) => { + match self.core.reload().await { + Ok(result) => { + if !UrlParams::new(req.uri().query()).has_key("dry-run") { + // Update core + if let Some(core) = result.new_core { + self.shared_core.store(core.into()); + } + } + + JsonResponse::new(json!({ + "data": result.config, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + } + } + _ => RequestError::not_found().into_http_response(), + } + } +} diff --git a/crates/jmap/src/api/management/report.rs b/crates/jmap/src/api/management/report.rs new file mode 100644 index 00000000..0e29dd00 --- /dev/null +++ b/crates/jmap/src/api/management/report.rs @@ -0,0 +1,401 @@ +/* + * 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 http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use mail_auth::report::{ + tlsrpt::{FailureDetails, Policy, TlsReport}, + Feedback, +}; +use serde_json::json; +use smtp::reporting::analysis::IncomingReport; +use store::{ + write::{key::DeserializeBigEndian, BatchBuilder, Bincode, ReportClass, ValueClass}, + Deserialize, IterateParams, ValueKey, U64_LEN, +}; +use utils::url_params::UrlParams; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + JMAP, +}; + +enum ReportType { + Dmarc, + Tls, + Arf, +} + +impl JMAP { + pub async fn handle_manage_reports( + &self, + req: &HttpRequest, + path: Vec<&str>, + ) -> hyper::Response> { + match ( + path.get(1).copied().unwrap_or_default(), + path.get(2).copied(), + req.method(), + ) { + (class @ ("dmarc" | "tls" | "arf"), None, &Method::GET) => { + let params = UrlParams::new(req.uri().query()); + let filter = params.get("text"); + let page: usize = params.parse::("page").unwrap_or_default(); + let limit: usize = params.parse::("limit").unwrap_or_default(); + + let (from_key, to_key, typ) = match class { + "dmarc" => ( + ValueKey::from(ValueClass::Report(ReportClass::Dmarc { + id: 0, + expires: 0, + })), + ValueKey::from(ValueClass::Report(ReportClass::Dmarc { + id: u64::MAX, + expires: u64::MAX, + })), + ReportType::Dmarc, + ), + "tls" => ( + ValueKey::from(ValueClass::Report(ReportClass::Tls { id: 0, expires: 0 })), + ValueKey::from(ValueClass::Report(ReportClass::Tls { + id: u64::MAX, + expires: u64::MAX, + })), + ReportType::Tls, + ), + "arf" => ( + ValueKey::from(ValueClass::Report(ReportClass::Arf { id: 0, expires: 0 })), + ValueKey::from(ValueClass::Report(ReportClass::Arf { + id: u64::MAX, + expires: u64::MAX, + })), + ReportType::Arf, + ), + _ => unreachable!(), + }; + + let mut results = Vec::new(); + let mut offset = page.saturating_sub(1) * limit; + let mut total = 0; + let mut last_id = 0; + let result = self + .core + .storage + .data + .iterate( + IterateParams::new(from_key, to_key) + .set_values(filter.is_some()) + .descending(), + |key, value| { + // Skip chunked records + let id = key.deserialize_be_u64(U64_LEN + 1)?; + if id == last_id { + return Ok(true); + } + last_id = id; + + // TODO: Support filtering chunked records (over 10MB) on FDB + let matches = filter.map_or(true, |filter| match typ { + ReportType::Dmarc => Bincode::< + IncomingReport, + >::deserialize( + value + ) + .map_or(false, |v| v.inner.contains(filter)), + ReportType::Tls => { + Bincode::>::deserialize(value) + .map_or(false, |v| v.inner.contains(filter)) + } + ReportType::Arf => { + Bincode::>::deserialize(value) + .map_or(false, |v| v.inner.contains(filter)) + } + }); + if matches { + if offset == 0 { + if limit == 0 || results.len() < limit { + results.push(format!( + "{}_{}", + id, + key.deserialize_be_u64(1)? + )); + } + } else { + offset -= 1; + } + + total += 1; + } + + Ok(true) + }, + ) + .await; + match result { + Ok(_) => JsonResponse::new(json!({ + "data": { + "items": results, + "total": total, + }, + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + (class @ ("dmarc" | "tls" | "arf"), Some(report_id), &Method::GET) => { + if let Some(report_id) = parse_incoming_report_id(class, report_id) { + match &report_id { + ReportClass::Tls { .. } => match self + .core + .storage + .data + .get_value::>>(ValueKey::from( + ValueClass::Report(report_id), + )) + .await + { + Ok(Some(report)) => JsonResponse::new(json!({ + "data": report.inner, + })) + .into_http_response(), + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => err.into_http_response(), + }, + ReportClass::Dmarc { .. } => match self + .core + .storage + .data + .get_value::>>( + ValueKey::from(ValueClass::Report(report_id)), + ) + .await + { + Ok(Some(report)) => JsonResponse::new(json!({ + "data": report.inner, + })) + .into_http_response(), + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => err.into_http_response(), + }, + ReportClass::Arf { .. } => match self + .core + .storage + .data + .get_value::>>(ValueKey::from( + ValueClass::Report(report_id), + )) + .await + { + Ok(Some(report)) => JsonResponse::new(json!({ + "data": report.inner, + })) + .into_http_response(), + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => err.into_http_response(), + }, + } + } else { + RequestError::not_found().into_http_response() + } + } + (class @ ("dmarc" | "tls" | "arf"), Some(report_id), &Method::DELETE) => { + if let Some(report_id) = parse_incoming_report_id(class, report_id) { + let mut batch = BatchBuilder::new(); + batch.clear(ValueClass::Report(report_id)); + let result = self.core.storage.data.write(batch.build()).await.is_ok(); + + JsonResponse::new(json!({ + "data": result, + })) + .into_http_response() + } else { + RequestError::not_found().into_http_response() + } + } + _ => RequestError::not_found().into_http_response(), + } + } +} + +fn parse_incoming_report_id(class: &str, id: &str) -> Option { + let mut parts = id.split('_'); + let id = parts.next()?.parse().ok()?; + let expires = parts.next()?.parse().ok()?; + match class { + "dmarc" => Some(ReportClass::Dmarc { id, expires }), + "tls" => Some(ReportClass::Tls { id, expires }), + "arf" => Some(ReportClass::Arf { id, expires }), + _ => None, + } +} + +impl From<&str> for ReportType { + fn from(s: &str) -> Self { + match s { + "dmarc" => Self::Dmarc, + "tls" => Self::Tls, + "arf" => Self::Arf, + _ => unreachable!(), + } + } +} + +trait Contains { + fn contains(&self, text: &str) -> bool; +} + +impl Contains for mail_auth::report::Report { + fn contains(&self, text: &str) -> bool { + self.domain().contains(text) + || self.org_name().to_lowercase().contains(text) + || self.report_id().contains(text) + || self + .extra_contact_info() + .map_or(false, |c| c.to_lowercase().contains(text)) + || self.records().iter().any(|record| record.contains(text)) + } +} + +impl Contains for mail_auth::report::Record { + fn contains(&self, filter: &str) -> bool { + self.envelope_from().contains(filter) + || self.header_from().contains(filter) + || self.envelope_to().map_or(false, |to| to.contains(filter)) + || self.dkim_auth_result().iter().any(|dkim| { + dkim.domain().contains(filter) + || dkim.selector().contains(filter) + || dkim + .human_result() + .as_ref() + .map_or(false, |r| r.contains(filter)) + }) + || self.spf_auth_result().iter().any(|spf| { + spf.domain().contains(filter) + || spf.human_result().map_or(false, |r| r.contains(filter)) + }) + || self + .source_ip() + .map_or(false, |ip| ip.to_string().contains(filter)) + } +} + +impl Contains for TlsReport { + fn contains(&self, text: &str) -> bool { + self.organization_name + .as_ref() + .map_or(false, |o| o.to_lowercase().contains(text)) + || self + .contact_info + .as_ref() + .map_or(false, |c| c.to_lowercase().contains(text)) + || self.report_id.contains(text) + || self.policies.iter().any(|p| p.contains(text)) + } +} + +impl Contains for Policy { + fn contains(&self, filter: &str) -> bool { + self.policy.policy_domain.contains(filter) + || self + .policy + .policy_string + .iter() + .any(|s| s.to_lowercase().contains(filter)) + || self + .policy + .mx_host + .iter() + .any(|s| s.to_lowercase().contains(filter)) + || self.failure_details.iter().any(|f| f.contains(filter)) + } +} + +impl Contains for FailureDetails { + fn contains(&self, filter: &str) -> bool { + self.sending_mta_ip + .map_or(false, |s| s.to_string().contains(filter)) + || self + .receiving_ip + .map_or(false, |s| s.to_string().contains(filter)) + || self + .receiving_mx_hostname + .as_ref() + .map_or(false, |s| s.contains(filter)) + || self + .receiving_mx_helo + .as_ref() + .map_or(false, |s| s.contains(filter)) + || self + .additional_information + .as_ref() + .map_or(false, |s| s.contains(filter)) + || self + .failure_reason_code + .as_ref() + .map_or(false, |s| s.contains(filter)) + } +} + +impl<'x> Contains for Feedback<'x> { + fn contains(&self, text: &str) -> bool { + // Check if any of the string fields contain the filter + self.authentication_results() + .iter() + .any(|s| s.contains(text)) + || self + .original_envelope_id() + .map_or(false, |s| s.contains(text)) + || self + .original_mail_from() + .map_or(false, |s| s.contains(text)) + || self.original_rcpt_to().map_or(false, |s| s.contains(text)) + || self.reported_domain().iter().any(|s| s.contains(text)) + || self.reported_uri().iter().any(|s| s.contains(text)) + || self.reporting_mta().map_or(false, |s| s.contains(text)) + || self.user_agent().map_or(false, |s| s.contains(text)) + || self.dkim_adsp_dns().map_or(false, |s| s.contains(text)) + || self + .dkim_canonicalized_body() + .map_or(false, |s| s.contains(text)) + || self + .dkim_canonicalized_header() + .map_or(false, |s| s.contains(text)) + || self.dkim_domain().map_or(false, |s| s.contains(text)) + || self.dkim_identity().map_or(false, |s| s.contains(text)) + || self.dkim_selector().map_or(false, |s| s.contains(text)) + || self.dkim_selector_dns().map_or(false, |s| s.contains(text)) + || self.spf_dns().map_or(false, |s| s.contains(text)) + || self.message().map_or(false, |s| s.contains(text)) + || self.headers().map_or(false, |s| s.contains(text)) + } +} + +impl Contains for IncomingReport { + fn contains(&self, text: &str) -> bool { + self.from.to_lowercase().contains(text) + || self.to.iter().any(|to| to.to_lowercase().contains(text)) + || self.subject.to_lowercase().contains(text) + || self.report.contains(text) + } +} diff --git a/crates/jmap/src/api/management/settings.rs b/crates/jmap/src/api/management/settings.rs new file mode 100644 index 00000000..d13ec567 --- /dev/null +++ b/crates/jmap/src/api/management/settings.rs @@ -0,0 +1,381 @@ +/* + * 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 http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use serde_json::json; +use store::ahash::AHashMap; +use utils::{config::ConfigKey, url_params::UrlParams}; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + JMAP, +}; + +use super::ManagementApiError; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum UpdateSettings { + Delete { + keys: Vec, + }, + Clear { + prefix: String, + }, + Insert { + prefix: Option, + values: Vec<(String, String)>, + assert_empty: bool, + }, +} + +impl JMAP { + pub async fn handle_manage_settings( + &self, + req: &HttpRequest, + path: Vec<&str>, + body: Option>, + ) -> hyper::Response> { + match (path.get(1).copied(), req.method()) { + (Some("group"), &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 suffix = params + .get("suffix") + .map(|s| { + if !s.starts_with('.') { + format!(".{s}") + } else { + s.to_string() + } + }) + .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.core.storage.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(&suffix) { + if !id.is_empty() { + if !has_filter { + if offset == 0 { + if limit == 0 || ids.len() < limit { + ids.push(id); + } + } else { + offset -= 1; + } + total += 1; + } else { + ids.push(id); + } + } + } + } + + // Group settings by record id + let mut records = Vec::new(); + for id in ids { + let mut record = AHashMap::new(); + let prefix = format!("{id}."); + record.insert("_id".to_string(), id.to_string()); + for (k, v) in &settings { + if let Some(k) = k.strip_prefix(&prefix) { + if field.map_or(true, |field| field == k) { + record.insert(k.to_string(), v.to_string()); + } + } else if record.len() > 1 { + break; + } + } + + if has_filter { + if record.iter().any(|(_, v)| v.contains(filter)) { + if offset == 0 { + if limit == 0 || records.len() < limit { + records.push(record); + } + } else { + offset -= 1; + } + total += 1; + } + } else { + records.push(record); + } + } + + JsonResponse::new(json!({ + "data": { + "total": total, + "items": records, + }, + })) + } else { + let total = settings.len(); + let items = settings + .into_iter() + .filter_map(|(k, v)| { + if filter.is_empty() || k.contains(filter) || v.contains(filter) { + let k = + k.strip_prefix(&prefix).map(|k| k.to_string()).unwrap_or(k); + Some(json!({ + "_id": k, + "_value": v, + })) + } else { + None + } + }) + .skip(offset) + .take(if limit == 0 { total } else { limit }) + .collect::>(); + + JsonResponse::new(json!({ + "data": { + "total": total, + "items": items, + }, + })) + } + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + (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.core.storage.config.list(&prefix, true).await { + Ok(settings) => { + let total = settings.len(); + let items = settings + .into_iter() + .skip(offset) + .take(if limit == 0 { total } else { limit }) + .collect::>(); + + JsonResponse::new(json!({ + "data": { + "total": total, + "items": items, + }, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + } + } + (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 = None; + let mut results = AHashMap::with_capacity(keys.len()); + + for key in keys { + match self.core.storage.config.get(key).await { + Ok(Some(value)) => { + results.insert(key.to_string(), value); + } + Ok(None) => {} + Err(err_) => { + err = err_.into(); + break; + } + } + } + for prefix in prefixes { + let prefix = if !prefix.ends_with('.') { + format!("{prefix}.") + } else { + prefix.to_string() + }; + match self.core.storage.config.list(&prefix, false).await { + Ok(values) => { + results.extend(values); + } + Err(err_) => { + err = err_.into(); + break; + } + } + } + + match err { + None => JsonResponse::new(json!({ + "data": results, + })) + .into_http_response(), + Some(err) => err.into_http_response(), + } + } + (Some(prefix), &Method::DELETE) if !prefix.is_empty() => { + match self.core.storage.config.clear(prefix).await { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + (None, &Method::POST) => { + match serde_json::from_slice::>( + body.as_deref().unwrap_or_default(), + ) { + Ok(changes) => { + let mut result = Ok(true); + + 'next: for change in changes { + match change { + UpdateSettings::Delete { keys } => { + for key in keys { + result = + self.core.storage.config.clear(key).await.map(|_| true); + if result.is_err() { + break 'next; + } + } + } + UpdateSettings::Clear { prefix } => { + result = self + .core + .storage + .config + .clear_prefix(&prefix) + .await + .map(|_| true); + if result.is_err() { + break; + } + } + UpdateSettings::Insert { + prefix, + values, + assert_empty, + } => { + if assert_empty { + if let Some(prefix) = &prefix { + result = self + .core + .storage + .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 + .core + .storage + .config + .get(key) + .await + .map(|items| items.is_none()); + + if matches!(result, Ok(false) | Err(_)) { + break; + } + } + } + + result = self + .core + .storage + .config + .set(values.into_iter().map(|(key, value)| ConfigKey { + key: if let Some(prefix) = &prefix { + format!("{prefix}.{key}") + } else { + key + }, + value, + })) + .await + .map(|_| true); + if result.is_err() { + break; + } + } + } + } + + match result { + Ok(true) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Ok(false) => JsonResponse::new(ManagementApiError::AssertFailed) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + Err(err) => err.into_http_response(), + } + } + _ => RequestError::not_found().into_http_response(), + } + } +} diff --git a/crates/jmap/src/api/management/stores.rs b/crates/jmap/src/api/management/stores.rs new file mode 100644 index 00000000..d49dcc1f --- /dev/null +++ b/crates/jmap/src/api/management/stores.rs @@ -0,0 +1,62 @@ +/* + * 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 http_body_util::combinators::BoxBody; +use hyper::{body::Bytes, Method}; +use jmap_proto::error::request::RequestError; +use serde_json::json; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + JMAP, +}; + +impl JMAP { + pub async fn handle_manage_store( + &self, + req: &HttpRequest, + path: Vec<&str>, + ) -> hyper::Response> { + match (path.get(1).copied(), req.method()) { + (Some("maintenance"), &Method::GET) => { + match self + .core + .storage + .data + .purge_blobs(self.core.storage.blob.clone()) + .await + { + Ok(_) => match self.core.storage.data.purge_store().await { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + }, + Err(err) => err.into_http_response(), + } + } + _ => RequestError::not_found().into_http_response(), + } + } +} diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 5916dca0..85924c07 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -28,9 +28,9 @@ use utils::map::vec_map::VecMap; use crate::JmapInstance; -pub mod admin; pub mod event_source; pub mod http; +pub mod management; pub mod request; pub mod session; diff --git a/crates/jmap/src/email/crypto.rs b/crates/jmap/src/email/crypto.rs index b6606515..219efa30 100644 --- a/crates/jmap/src/email/crypto.rs +++ b/crates/jmap/src/email/crypto.rs @@ -21,16 +21,18 @@ * for more details. */ -use std::{borrow::Cow, collections::BTreeSet, fmt::Display, io::Cursor, net::IpAddr}; +use std::{borrow::Cow, collections::BTreeSet, fmt::Display, io::Cursor, sync::Arc}; use crate::{ - api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse}, - auth::oauth::FormData, + api::{http::ToHttpResponse, management::ManagementApiError, HttpResponse, JsonResponse}, + auth::AccessToken, JMAP, }; use aes::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; -use common::AuthResult; -use jmap_proto::types::{collection::Collection, property::Property}; +use jmap_proto::{ + error::request::RequestError, + types::{collection::Collection, property::Property}, +}; use mail_builder::{encoders::base64::base64_encode_mime, mime::make_boundary}; use mail_parser::{decoders::base64::base64_decode, Message, MessageParser, MimeHeaders}; use openpgp::{ @@ -49,18 +51,12 @@ use rasn_cms::{ }; use rsa::{pkcs1::DecodeRsaPublicKey, Pkcs1v15Encrypt, RsaPublicKey}; use sequoia_openpgp as openpgp; +use serde_json::json; use store::{ - write::{BatchBuilder, ToBitmaps, F_CLEAR, F_VALUE}, + write::{BatchBuilder, Bincode, ToBitmaps, F_CLEAR, F_VALUE}, Deserialize, Serialize, }; -const CRYPT_HTML_HEADER: &str = include_str!("../../../../resources/htx/crypto_header.htx"); -const CRYPT_HTML_FOOTER: &str = include_str!("../../../../resources/htx/crypto_footer.htx"); -const CRYPT_HTML_FORM: &str = include_str!("../../../../resources/htx/crypto_form.htx"); -const CRYPT_HTML_SUCCESS: &str = include_str!("../../../../resources/htx/crypto_success.htx"); -const CRYPT_HTML_DISABLED: &str = include_str!("../../../../resources/htx/crypto_disabled.htx"); -const CRYPT_HTML_ERROR: &str = include_str!("../../../../resources/htx/crypto_error.htx"); - const P: openpgp::policy::StandardPolicy<'static> = openpgp::policy::StandardPolicy::new(); #[derive(Debug)] @@ -88,6 +84,21 @@ pub struct EncryptionParams { pub certs: Vec>, } +#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +#[serde(tag = "type")] +pub enum EncryptionType { + PGP { + algo: Algorithm, + certs: String, + }, + SMIME { + algo: Algorithm, + certs: String, + }, + #[default] + Disabled, +} + #[allow(async_fn_in_trait)] pub trait EncryptMessage { async fn encrypt(&self, params: &EncryptionParams) -> Result, EncryptMessageError>; @@ -425,20 +436,29 @@ impl Algorithm { } } -pub fn try_parse_certs(bytes: Vec) -> Result<(EncryptionMethod, Vec>), String> { +pub fn try_parse_certs( + expected_method: EncryptionMethod, + cert: Vec, +) -> Result>, Cow<'static, str>> { // Check if it's a PEM file - if let Some(result) = try_parse_pem(&bytes)? { - Ok(result) - } else if rasn::der::decode::(&bytes[..]).is_ok() { - Ok((EncryptionMethod::SMIME, vec![bytes])) - } else if let Ok(cert) = openpgp::Cert::from_bytes(&bytes[..]) { - if !has_pgp_keys(cert) { - Ok((EncryptionMethod::PGP, vec![bytes])) + let (method, certs) = if let Some(result) = try_parse_pem(&cert)? { + result + } else if rasn::der::decode::(&cert[..]).is_ok() { + (EncryptionMethod::SMIME, vec![cert]) + } else if let Ok(cert_) = openpgp::Cert::from_bytes(&cert[..]) { + if !has_pgp_keys(cert_) { + (EncryptionMethod::PGP, vec![cert]) } else { - Err("Could not find any suitable keys in certificate".to_string()) + return Err("Could not find any suitable keys in certificate".into()); } } else { - Err("Could not find any valid certificates".to_string()) + return Err("Could not find any valid certificates".into()); + }; + + if method == expected_method { + Ok(certs) + } else { + Err("No valid certificates found for the selected encryption".into()) } } @@ -454,7 +474,22 @@ fn has_pgp_keys(cert: openpgp::Cert) -> bool { } #[allow(clippy::type_complexity)] -fn try_parse_pem(bytes_: &[u8]) -> Result>)>, String> { +fn try_parse_pem( + bytes_: &[u8], +) -> Result>)>, Cow<'static, str>> { + if let Some(internal) = std::str::from_utf8(bytes_) + .ok() + .and_then(|cert| cert.strip_prefix("-----STALWART CERTIFICATE-----")) + { + return base64_decode(internal.as_bytes()) + .ok_or(Cow::from("Failed to decode base64")) + .and_then(|bytes| { + Bincode::::deserialize(&bytes) + .map_err(|_| Cow::from("Failed to deserialize internal certificate")) + }) + .map(|params| Some((params.inner.method, params.inner.certs))); + } + let mut bytes = bytes_.iter().enumerate(); let mut buf = vec![]; let mut method = None; @@ -496,13 +531,13 @@ fn try_parse_pem(bytes_: &[u8]) -> Result> let tag = std::str::from_utf8(&buf).unwrap(); if tag.contains("CERTIFICATE") { if method.map_or(false, |m| m == EncryptionMethod::PGP) { - return Err("Cannot mix OpenPGP and S/MIME certificates".to_string()); + return Err("Cannot mix OpenPGP and S/MIME certificates".into()); } else { method = Some(EncryptionMethod::SMIME); } } else if tag.contains("PGP") { if method.map_or(false, |m| m == EncryptionMethod::SMIME) { - return Err("Cannot mix OpenPGP and S/MIME certificates".to_string()); + return Err("Cannot mix OpenPGP and S/MIME certificates".into()); } else { method = Some(EncryptionMethod::PGP); } @@ -544,15 +579,13 @@ fn try_parse_pem(bytes_: &[u8]) -> Result> } // Decode base64 - let cert = base64_decode(&buf) - .ok_or_else(|| "Failed to decode base64 certificate.".to_string())?; + let cert = + base64_decode(&buf).ok_or_else(|| Cow::from("Failed to decode base64 certificate."))?; match method.unwrap() { EncryptionMethod::PGP => match openpgp::Cert::from_bytes(bytes_) { Ok(cert) => { if !has_pgp_keys(cert) { - return Err( - "Could not find any suitable keys in OpenPGP public key".to_string() - ); + return Err("Could not find any suitable keys in OpenPGP public key".into()); } certs.push( bytes_ @@ -561,11 +594,13 @@ fn try_parse_pem(bytes_: &[u8]) -> Result> .to_vec(), ); } - Err(err) => return Err(format!("Failed to decode OpenPGP public key: {}", err)), + Err(err) => { + return Err(format!("Failed to decode OpenPGP public key: {err}").into()) + } }, EncryptionMethod::SMIME => { if let Err(err) = rasn::der::decode::(&cert) { - return Err(format!("Failed to decode X509 certificate: {}", err)); + return Err(format!("Failed to decode X509 certificate: {err}").into()); } certs.push(cert); } @@ -616,141 +651,129 @@ impl ToBitmaps for &EncryptionParams { } impl JMAP { - // Code authorization flow, handles an authorization request - pub async fn handle_crypto_update( - &self, - req: &mut HttpRequest, - remote_addr: IpAddr, - ) -> HttpResponse { - let mut response = String::with_capacity( - CRYPT_HTML_HEADER.len() + CRYPT_HTML_FOOTER.len() + CRYPT_HTML_FORM.len(), - ); - response.push_str(&CRYPT_HTML_HEADER.replace("@@@", "/crypto")); - - match *req.method() { - hyper::Method::POST => { - // Parse form - let form = match FormData::from_request(req, 1024 * 1024).await { - Ok(form) => form, - Err(err) => return err, - }; - - match self.validate_form(form, remote_addr).await { - Ok(Some(params)) => { - response.push_str( - &CRYPT_HTML_SUCCESS - .replace( - "$$$", - format!("{} ({})", params.method, params.algo).as_str(), - ) - .replace("@@@", params.certs.len().to_string().as_str()), + pub async fn handle_crypto_get(&self, access_token: Arc) -> HttpResponse { + match self + .get_property::( + access_token.primary_id(), + Collection::Principal, + 0, + Property::Parameters, + ) + .await + { + Ok(params) => { + let ec = params + .map(|params| { + let method = params.method; + let algo = params.algo; + let mut certs = Vec::new(); + certs.extend_from_slice(b"-----STALWART CERTIFICATE-----\r\n"); + let _ = base64_encode_mime( + &Bincode::new(params).serialize(), + &mut certs, + false, ); - } - Ok(None) => { - response.push_str(CRYPT_HTML_DISABLED); - } - Err(error) => { - response.push_str(&CRYPT_HTML_ERROR.replace("@@@", &error)); - } - } + certs.extend_from_slice(b"\r\n"); + let certs = String::from_utf8(certs).unwrap_or_default(); + + match method { + EncryptionMethod::PGP => EncryptionType::PGP { algo, certs }, + EncryptionMethod::SMIME => EncryptionType::SMIME { algo, certs }, + } + }) + .unwrap_or(EncryptionType::Disabled); + + JsonResponse::new(json!({ + "data": ec, + })) + .into_http_response() } + Err(err) => { + tracing::warn!( + context = "store", + event = "error", + reason = ?err, + "Database error while fetching encryption parameters" + ); - hyper::Method::GET => { - response.push_str(CRYPT_HTML_FORM); + RequestError::internal_server_error().into_http_response() } - _ => unreachable!(), - }; - - response.push_str(CRYPT_HTML_FOOTER); - - HtmlResponse::new(response).into_http_response() + } } - async fn validate_form( + pub async fn handle_crypto_post( &self, - mut form: FormData, - remote_addr: IpAddr, - ) -> Result, Cow> { - let certificate = form.remove_bytes("certificate"); - if let (Some(email), Some(password), Some(encryption)) = ( - form.get("email"), - form.get("password"), - form.get("encryption"), - ) { - // Validate fields - if email.is_empty() || password.is_empty() { - return Err(Cow::from("Please enter your login and password")); - } else if encryption != "disable" && certificate.as_ref().map_or(true, |c| c.is_empty()) - { - return Err(Cow::from("Please select one or more certificates")); - } - - // Authenticate - let token = if let AuthResult::Success(token) = - self.authenticate_plain(email, password, remote_addr).await - { - token - } else { - return Err(Cow::from("Invalid login or password")); + access_token: Arc, + body: Option>, + ) -> HttpResponse { + let request = + match serde_json::from_slice::(body.as_deref().unwrap_or_default()) { + Ok(request) => request, + Err(err) => return err.into_http_response(), }; - if encryption != "disable" { - let (method, certs) = - try_parse_certs(certificate.unwrap_or_default()).map_err(Cow::from)?; - let algo = match (encryption, method) { - ("pgp-256", EncryptionMethod::PGP) => Algorithm::Aes256, - ("pgp-128", EncryptionMethod::PGP) => Algorithm::Aes128, - ("smime-256", EncryptionMethod::SMIME) => Algorithm::Aes256, - ("smime-128", EncryptionMethod::SMIME) => Algorithm::Aes128, - _ => { - return Err(Cow::from( - "No valid certificates found for the selected encryption", - )); - } - }; - let params = EncryptionParams { - method, - algo, - certs, - }; - - // Try a test encryption - if let Err(EncryptMessageError::Error(message)) = MessageParser::new() - .parse("Subject: test\r\ntest\r\n".as_bytes()) - .unwrap() - .encrypt(¶ms) - .await - { - return Err(Cow::from(message)); - } - - // Save encryption params + let (method, algo, certs) = match request { + EncryptionType::PGP { algo, certs } => (EncryptionMethod::PGP, algo, certs), + EncryptionType::SMIME { algo, certs } => (EncryptionMethod::SMIME, algo, certs), + EncryptionType::Disabled => { + // Disable encryption at rest let mut batch = BatchBuilder::new(); batch - .with_account_id(token.primary_id()) - .with_collection(Collection::Principal) - .update_document(0) - .value(Property::Parameters, ¶ms, F_VALUE); - self.write_batch(batch).await.map_err(|_| { - Cow::from("Failed to save encryption parameters, please try again later") - })?; - - Ok(Some(params)) - } else { - // Remove encryption params - let mut batch = BatchBuilder::new(); - batch - .with_account_id(token.primary_id()) + .with_account_id(access_token.primary_id()) .with_collection(Collection::Principal) .update_document(0) .value(Property::Parameters, (), F_VALUE | F_CLEAR); - self.write_batch(batch).await.map_err(|_| { - Cow::from("Failed to save encryption parameters, please try again later") - })?; - Ok(None) + return match self.core.storage.data.write(batch.build()).await { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + }; } - } else { - Err(Cow::from("Missing form parameters")) + }; + + // Make sure Encryption is enabled + if !self.core.jmap.encrypt { + return ManagementApiError::Unsupported { + details: "Encryption-at-rest has been disabled by the system administrator".into(), + } + .into_http_response(); + } + + // Parse certificates + let params = match try_parse_certs(method, certs.into_bytes()) { + Ok(certs) => EncryptionParams { + method, + algo, + certs, + }, + Err(err) => return ManagementApiError::from(err).into_http_response(), + }; + + // Try a test encryption + if let Err(EncryptMessageError::Error(message)) = MessageParser::new() + .parse("Subject: test\r\ntest\r\n".as_bytes()) + .unwrap() + .encrypt(¶ms) + .await + { + return ManagementApiError::from(message).into_http_response(); + } + + // Save encryption params + let mut batch = BatchBuilder::new(); + batch + .with_account_id(access_token.primary_id()) + .with_collection(Collection::Principal) + .update_document(0) + .value(Property::Parameters, ¶ms, F_VALUE); + match self.core.storage.data.write(batch.build()).await { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), } } } diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index d165d288..50020161 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.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index ef840ff9..3319cdbe 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index a986cb47..1152bcd4 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 3fc4f663..b03e0c3d 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.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/smtp/src/core/management.rs b/crates/smtp/src/core/management.rs deleted file mode 100644 index f52eb559..00000000 --- a/crates/smtp/src/core/management.rs +++ /dev/null @@ -1,1322 +0,0 @@ -/* - * 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::{net::IpAddr, str::FromStr}; - -use common::{ - listener::{limiter::InFlight, SessionData, SessionManager, SessionStream}, - AuthResult, -}; -use directory::Type; -use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; -use hyper::{ - body::{self, Bytes}, - header::{self, HeaderValue, AUTHORIZATION}, - server::conn::http1, - service::service_fn, - Method, StatusCode, Uri, -}; -use hyper_util::rt::TokioIo; -use mail_auth::{ - dmarc::URI, - mta_sts::ReportUri, - report::{ - self, - tlsrpt::{FailureDetails, Policy, TlsReport}, - Feedback, - }, -}; -use mail_parser::{decoders::base64::base64_decode, DateTime}; -use mail_send::Credentials; -use serde::{Deserializer, Serializer}; -use serde_json::json; -use store::{ - write::{ - key::DeserializeBigEndian, now, BatchBuilder, Bincode, QueueClass, ReportClass, - ReportEvent, ValueClass, - }, - Deserialize, IterateParams, ValueKey, U64_LEN, -}; - -use utils::url_params::UrlParams; - -use crate::{ - queue::{self, ErrorDetails, HostResponse, QueueId, Status}, - reporting::analysis::IncomingReport, -}; - -use super::{SmtpAdminSessionManager, SMTP}; - -#[derive(Debug, serde::Serialize)] -pub struct Response { - data: T, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct Message { - pub id: QueueId, - pub return_path: String, - pub domains: Vec, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - pub created: DateTime, - pub size: usize, - #[serde(skip_serializing_if = "is_zero")] - #[serde(default)] - pub priority: i16, - #[serde(skip_serializing_if = "Option::is_none")] - pub env_id: Option, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct Domain { - pub name: String, - pub status: Status, - pub recipients: Vec, - - pub retry_num: u32, - #[serde(deserialize_with = "deserialize_maybe_datetime")] - #[serde(serialize_with = "serialize_maybe_datetime")] - pub next_retry: Option, - #[serde(deserialize_with = "deserialize_maybe_datetime")] - #[serde(serialize_with = "serialize_maybe_datetime")] - pub next_notify: Option, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - pub expires: DateTime, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct Recipient { - pub address: String, - pub status: Status, - #[serde(skip_serializing_if = "Option::is_none")] - pub orcpt: Option, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -#[serde(tag = "type")] -pub enum Report { - Tls { - id: String, - domain: String, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - range_from: DateTime, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - range_to: DateTime, - report: TlsReport, - rua: Vec, - }, - Dmarc { - id: String, - domain: String, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - range_from: DateTime, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - range_to: DateTime, - report: report::Report, - rua: Vec, - }, -} - -impl SessionManager for SmtpAdminSessionManager { - fn handle( - self, - session: SessionData, - ) -> impl std::future::Future + Send { - handle_request( - session.stream, - self.inner.into(), - session.remote_ip, - session.in_flight, - ) - } - - #[allow(clippy::manual_async_fn)] - fn shutdown(&self) -> impl std::future::Future + Send { - async {} - } -} - -async fn handle_request( - stream: impl SessionStream, - core: SMTP, - remote_addr: IpAddr, - _in_flight: InFlight, -) { - if let Err(http_err) = http1::Builder::new() - .keep_alive(true) - .serve_connection( - TokioIo::new(stream), - service_fn(|req: hyper::Request| { - let core = core.clone(); - - async move { - let mut response = core.parse_request(&req, remote_addr).await; - - // Add CORS headers - if let Ok(response) = &mut response { - let headers = response.headers_mut(); - headers.insert( - header::ACCESS_CONTROL_ALLOW_ORIGIN, - HeaderValue::from_static("*"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_METHODS, - HeaderValue::from_static( - "POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS", - ), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_HEADERS, - HeaderValue::from_static( - "Authorization, Content-Type, Accept, X-Requested-With", - ), - ); - } - - tracing::debug!( - context = "management", - event = "request", - remote.ip = remote_addr.to_string(), - uri = req.uri().to_string(), - status = match &response { - Ok(response) => response.status().to_string(), - Err(error) => error.to_string(), - } - ); - - response - } - }), - ) - .await - { - tracing::debug!( - context = "management", - event = "http-error", - remote.ip = remote_addr.to_string(), - reason = %http_err, - ); - } -} - -impl SMTP { - async fn parse_request( - &self, - req: &hyper::Request, - remote_addr: IpAddr, - ) -> Result>, hyper::Error> { - if req.method() == Method::OPTIONS { - return Ok(hyper::Response::builder() - .status(StatusCode::OK) - .body( - Empty::::new() - .map_err(|never| match never {}) - .boxed(), - ) - .unwrap()); - } - - // Authenticate request - let mut is_authenticated = false; - if let Some((mechanism, payload)) = req - .headers() - .get(AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.trim().split_once(' ')) - { - if mechanism.eq_ignore_ascii_case("basic") { - // Decode the base64 encoded credentials - if let Some((username, secret)) = base64_decode(payload.as_bytes()) - .and_then(|token| String::from_utf8(token).ok()) - .and_then(|token| { - token.split_once(':').map(|(login, secret)| { - (login.trim().to_lowercase(), secret.to_string()) - }) - }) - { - match self - .core - .authenticate( - &self.core.storage.directory, - &Credentials::Plain { username, secret }, - remote_addr, - false, - ) - .await - { - Ok(AuthResult::Success(principal)) if principal.typ == Type::Superuser => { - is_authenticated = true; - } - Ok(AuthResult::Success(_)) => { - tracing::debug!( - context = "management", - event = "auth-error", - "Insufficient privileges." - ); - } - Ok(AuthResult::Failure | AuthResult::Banned) => { - tracing::debug!( - context = "management", - event = "auth-error", - "Invalid username or password." - ); - } - _ => { - tracing::debug!( - context = "management", - event = "auth-error", - "Temporary authentication failure." - ); - } - } - } else { - tracing::debug!( - context = "management", - event = "auth-error", - "Failed to decode base64 Authorization header." - ); - } - } else { - tracing::debug!( - context = "management", - event = "auth-error", - mechanism = mechanism, - "Unsupported authentication mechanism." - ); - } - } - if !is_authenticated { - return Ok(hyper::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(header::WWW_AUTHENTICATE, "Basic realm=\"Stalwart SMTP\"") - .body( - Empty::::new() - .map_err(|never| match never {}) - .boxed(), - ) - .unwrap()); - } - - let mut path = req.uri().path().split('/'); - path.next(); - path.next(); // Skip the leading /api - Ok(self - .handle_manage_request( - req.uri(), - req.method(), - path.next().unwrap_or_default(), - path.next().unwrap_or_default(), - path.next(), - ) - .await) - } - - pub async fn handle_manage_request( - &self, - uri: &Uri, - method: &Method, - path_1: &str, - path_2: &str, - path_3: Option<&str>, - ) -> hyper::Response> { - let params = UrlParams::new(uri.query()); - - let (status, response) = match (method, path_1, path_2, path_3) { - (&Method::GET, "queue", "messages", None) => { - let text = params.get("text"); - let from = params.get("from"); - let to = params.get("to"); - let before = params.parse::("before").map(|t| t.into_inner()); - let after = params.parse::("after").map(|t| t.into_inner()); - let page: usize = params.parse::("page").unwrap_or_default(); - let limit: usize = params.parse::("limit").unwrap_or_default(); - let values = params.has_key("values"); - - let mut result_ids = Vec::new(); - let mut result_values = Vec::new(); - let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0))); - let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))); - let has_filters = text.is_some() - || from.is_some() - || to.is_some() - || before.is_some() - || after.is_some(); - let mut offset = page.saturating_sub(1) * limit; - let mut total = 0; - let mut total_returned = 0; - let _ = self - .core - .storage - .data - .iterate( - IterateParams::new(from_key, to_key).ascending(), - |key, value| { - let message = Bincode::::deserialize(value)?.inner; - let matches = !has_filters - || (text - .as_ref() - .map(|text| { - message.return_path.contains(text) - || message - .recipients - .iter() - .any(|r| r.address_lcase.contains(text)) - }) - .unwrap_or_else(|| { - from.as_ref() - .map_or(true, |from| message.return_path.contains(from)) - && to.as_ref().map_or(true, |to| { - message - .recipients - .iter() - .any(|r| r.address_lcase.contains(to)) - }) - }) - && before.as_ref().map_or(true, |before| { - message.next_delivery_event() < *before - }) - && after.as_ref().map_or(true, |after| { - message.next_delivery_event() > *after - })); - - if matches { - if offset == 0 { - if limit == 0 || total_returned < limit { - if values { - result_values.push(Message::from(&message)); - } else { - result_ids.push(key.deserialize_be_u64(1)?); - } - total_returned += 1; - } - } else { - offset -= 1; - } - - total += 1; - } - - Ok(true) - }, - ) - .await; - - ( - StatusCode::OK, - if values { - serde_json::to_string(&json!({ - "data": { - "items": result_values, - "total": total, - }, - })) - } else { - serde_json::to_string(&json!({ - "data": { - "items": result_ids, - "total": total, - }, - })) - } - .unwrap_or_default(), - ) - } - (&Method::GET, "queue", "messages", Some(queue_id)) => { - if let Some(message) = self - .read_message(queue_id.parse().unwrap_or_default()) - .await - { - ( - StatusCode::OK, - serde_json::to_string(&Response { - data: Message::from(&message), - }) - .unwrap_or_default(), - ) - } else { - not_found() - } - } - (&Method::PATCH, "queue", "messages", Some(queue_id)) => { - let time = params - .parse::("at") - .map(|t| t.into_inner()) - .unwrap_or_else(now); - let item = params.get("filter"); - - if let Some(mut message) = self - .read_message(queue_id.parse().unwrap_or_default()) - .await - { - let prev_event = message.next_event().unwrap_or_default(); - let mut found = false; - - for domain in &mut message.domains { - if matches!( - domain.status, - Status::Scheduled | Status::TemporaryFailure(_) - ) && item - .as_ref() - .map_or(true, |item| domain.domain.contains(item)) - { - domain.retry.due = time; - if domain.expires > time { - domain.expires = time + 10; - } - found = true; - } - } - - if found { - let next_event = message.next_event().unwrap_or_default(); - message - .save_changes(self, prev_event.into(), next_event.into()) - .await; - let _ = self.inner.queue_tx.send(queue::Event::Reload).await; - } - - ( - StatusCode::OK, - serde_json::to_string(&Response { data: found }).unwrap_or_default(), - ) - } else { - not_found() - } - } - (&Method::DELETE, "queue", "messages", Some(queue_id)) => { - if let Some(mut message) = self - .read_message(queue_id.parse().unwrap_or_default()) - .await - { - let mut found = false; - let prev_event = message.next_event().unwrap_or_default(); - - if let Some(item) = params.get("filter") { - // Cancel delivery for all recipients that match - for rcpt in &mut message.recipients { - if rcpt.address_lcase.contains(item) { - rcpt.status = Status::PermanentFailure(HostResponse { - hostname: ErrorDetails::default(), - response: smtp_proto::Response { - code: 0, - esc: [0, 0, 0], - message: "Delivery canceled.".to_string(), - }, - }); - found = true; - } - } - if found { - // Mark as completed domains without any pending deliveries - for (domain_idx, domain) in message.domains.iter_mut().enumerate() { - if matches!( - domain.status, - Status::TemporaryFailure(_) | Status::Scheduled - ) { - let mut total_rcpt = 0; - let mut total_completed = 0; - - for rcpt in &message.recipients { - if rcpt.domain_idx == domain_idx { - total_rcpt += 1; - if matches!( - rcpt.status, - Status::PermanentFailure(_) | Status::Completed(_) - ) { - total_completed += 1; - } - } - } - - if total_rcpt == total_completed { - domain.status = Status::Completed(()); - } - } - } - - // Delete message if there are no pending deliveries - if message.domains.iter().any(|domain| { - matches!( - domain.status, - Status::TemporaryFailure(_) | Status::Scheduled - ) - }) { - let next_event = message.next_event().unwrap_or_default(); - message - .save_changes(self, next_event.into(), prev_event.into()) - .await; - } else { - message.remove(self, prev_event).await; - } - } - } else { - message.remove(self, prev_event).await; - found = true; - } - - ( - StatusCode::OK, - serde_json::to_string(&Response { data: found }).unwrap_or_default(), - ) - } else { - not_found() - } - } - (&Method::GET, "queue", "reports", None) => { - let domain = params.get("domain").map(|d| d.to_lowercase()); - let type_ = params.get("type").and_then(|t| match t { - "dmarc" => 0u8.into(), - "tls" => 1u8.into(), - _ => None, - }); - let page: usize = params.parse("page").unwrap_or_default(); - let limit: usize = params.parse("limit").unwrap_or_default(); - - let mut result = Vec::new(); - let from_key = ValueKey::from(ValueClass::Queue(QueueClass::DmarcReportHeader( - ReportEvent { - due: 0, - policy_hash: 0, - seq_id: 0, - domain: String::new(), - }, - ))); - let to_key = ValueKey::from(ValueClass::Queue(QueueClass::TlsReportHeader( - ReportEvent { - due: u64::MAX, - policy_hash: 0, - seq_id: 0, - domain: String::new(), - }, - ))); - let mut offset = page.saturating_sub(1) * limit; - let mut total = 0; - let mut total_returned = 0; - let _ = self - .core - .storage - .data - .iterate( - IterateParams::new(from_key, to_key).ascending().no_values(), - |key, _| { - if type_.map_or(true, |t| t == *key.last().unwrap()) { - let event = ReportEvent::deserialize(key)?; - if event.seq_id != 0 - && domain.as_ref().map_or(true, |d| event.domain.contains(d)) - { - if offset == 0 { - if limit == 0 || total_returned < limit { - result.push( - if *key.last().unwrap() == 0 { - QueueClass::DmarcReportHeader(event) - } else { - QueueClass::TlsReportHeader(event) - } - .queue_id(), - ); - total_returned += 1; - } - } else { - offset -= 1; - } - - total += 1; - } - } - - Ok(true) - }, - ) - .await; - - ( - StatusCode::OK, - serde_json::to_string(&json!({ - "data": { - "items": result, - "total": total, - }, - })) - .unwrap_or_default(), - ) - } - (&Method::GET, "queue", "reports", Some(report_id)) => { - let mut result = None; - if let Some(report_id) = parse_queued_report_id(report_id) { - match report_id { - QueueClass::DmarcReportHeader(event) => { - let mut rua = Vec::new(); - if let Ok(Some(report)) = self - .generate_dmarc_aggregate_report(&event, &mut rua, None) - .await - { - result = Report::dmarc(event, report, rua).into(); - } - } - QueueClass::TlsReportHeader(event) => { - let mut rua = Vec::new(); - if let Ok(Some(report)) = self - .generate_tls_aggregate_report(&[event.clone()], &mut rua, None) - .await - { - result = Report::tls(event, report, rua).into(); - } - } - _ => (), - } - } - - if let Some(result) = result { - ( - StatusCode::OK, - serde_json::to_string(&Response { data: result }).unwrap_or_default(), - ) - } else { - not_found() - } - } - (&Method::DELETE, "queue", "reports", Some(report_id)) => { - if let Some(report_id) = parse_queued_report_id(report_id) { - match report_id { - QueueClass::DmarcReportHeader(event) => { - self.delete_dmarc_report(event).await; - } - QueueClass::TlsReportHeader(event) => { - self.delete_tls_report(vec![event]).await; - } - _ => (), - } - - ( - StatusCode::OK, - serde_json::to_string(&Response { data: true }).unwrap_or_default(), - ) - } else { - not_found() - } - } - (&Method::GET, "reports", class @ ("dmarc" | "tls" | "arf"), None) => { - let filter = params.get("text"); - let page: usize = params.parse::("page").unwrap_or_default(); - let limit: usize = params.parse::("limit").unwrap_or_default(); - - let (from_key, to_key, typ) = match class { - "dmarc" => ( - ValueKey::from(ValueClass::Report(ReportClass::Dmarc { - id: 0, - expires: 0, - })), - ValueKey::from(ValueClass::Report(ReportClass::Dmarc { - id: u64::MAX, - expires: u64::MAX, - })), - ReportType::Dmarc, - ), - "tls" => ( - ValueKey::from(ValueClass::Report(ReportClass::Tls { id: 0, expires: 0 })), - ValueKey::from(ValueClass::Report(ReportClass::Tls { - id: u64::MAX, - expires: u64::MAX, - })), - ReportType::Tls, - ), - "arf" => ( - ValueKey::from(ValueClass::Report(ReportClass::Arf { id: 0, expires: 0 })), - ValueKey::from(ValueClass::Report(ReportClass::Arf { - id: u64::MAX, - expires: u64::MAX, - })), - ReportType::Arf, - ), - _ => unreachable!(), - }; - - let mut results = Vec::new(); - let mut offset = page.saturating_sub(1) * limit; - let mut total = 0; - let mut last_id = 0; - let result = self - .core - .storage - .data - .iterate( - IterateParams::new(from_key, to_key) - .set_values(filter.is_some()) - .descending(), - |key, value| { - // Skip chunked records - let id = key.deserialize_be_u64(U64_LEN + 1)?; - if id == last_id { - return Ok(true); - } - last_id = id; - - // TODO: Support filtering chunked records (over 10MB) on FDB - let matches = filter.map_or(true, |filter| match typ { - ReportType::Dmarc => Bincode::< - IncomingReport, - >::deserialize( - value - ) - .map_or(false, |v| v.inner.contains(filter)), - ReportType::Tls => { - Bincode::>::deserialize(value) - .map_or(false, |v| v.inner.contains(filter)) - } - ReportType::Arf => { - Bincode::>::deserialize(value) - .map_or(false, |v| v.inner.contains(filter)) - } - }); - if matches { - if offset == 0 { - if limit == 0 || results.len() < limit { - results.push(format!( - "{}_{}", - id, - key.deserialize_be_u64(1)? - )); - } - } else { - offset -= 1; - } - - total += 1; - } - - Ok(true) - }, - ) - .await; - match result { - Ok(_) => ( - StatusCode::OK, - serde_json::to_string(&json!({ - "data": { - "items": results, - "total": total, - }, - })) - .unwrap_or_default(), - ), - Err(err) => err.into_bad_request(), - } - } - (&Method::GET, "reports", class @ ("dmarc" | "tls" | "arf"), Some(report_id)) => { - if let Some(report_id) = parse_incoming_report_id(class, report_id) { - match &report_id { - ReportClass::Tls { .. } => match self - .core - .storage - .data - .get_value::>>(ValueKey::from( - ValueClass::Report(report_id), - )) - .await - { - Ok(Some(report)) => ( - StatusCode::OK, - serde_json::to_string(&json!({ - "data": report.inner, - })) - .unwrap_or_default(), - ), - Ok(None) => not_found(), - Err(err) => err.into_bad_request(), - }, - ReportClass::Dmarc { .. } => match self - .core - .storage - .data - .get_value::>>( - ValueKey::from(ValueClass::Report(report_id)), - ) - .await - { - Ok(Some(report)) => ( - StatusCode::OK, - serde_json::to_string(&json!({ - "data": report.inner, - })) - .unwrap_or_default(), - ), - Ok(None) => not_found(), - Err(err) => err.into_bad_request(), - }, - ReportClass::Arf { .. } => match self - .core - .storage - .data - .get_value::>>(ValueKey::from( - ValueClass::Report(report_id), - )) - .await - { - Ok(Some(report)) => ( - StatusCode::OK, - serde_json::to_string(&json!({ - "data": report.inner, - })) - .unwrap_or_default(), - ), - Ok(None) => not_found(), - Err(err) => err.into_bad_request(), - }, - } - } else { - not_found() - } - } - (&Method::DELETE, "reports", class @ ("dmarc" | "tls" | "arf"), Some(report_id)) => { - if let Some(report_id) = parse_incoming_report_id(class, report_id) { - let mut batch = BatchBuilder::new(); - batch.clear(ValueClass::Report(report_id)); - let result = self.core.storage.data.write(batch.build()).await.is_ok(); - ( - StatusCode::OK, - serde_json::to_string(&Response { data: result }).unwrap_or_default(), - ) - } else { - not_found() - } - } - _ => not_found(), - }; - - hyper::Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "application/json") - .body( - Full::new(Bytes::from(response)) - .map_err(|never| match never {}) - .boxed(), - ) - .unwrap() - } -} - -fn not_found() -> (StatusCode, String) { - ( - StatusCode::NOT_FOUND, - "{\"error\": \"not-found\", \"details\": \"URL does not exist.\"}".to_string(), - ) -} - -enum ReportType { - Dmarc, - Tls, - Arf, -} - -impl From<&str> for ReportType { - fn from(s: &str) -> Self { - match s { - "dmarc" => Self::Dmarc, - "tls" => Self::Tls, - "arf" => Self::Arf, - _ => unreachable!(), - } - } -} - -trait Contains { - fn contains(&self, text: &str) -> bool; -} - -impl Contains for mail_auth::report::Report { - fn contains(&self, text: &str) -> bool { - self.domain().contains(text) - || self.org_name().to_lowercase().contains(text) - || self.report_id().contains(text) - || self - .extra_contact_info() - .map_or(false, |c| c.to_lowercase().contains(text)) - || self.records().iter().any(|record| record.contains(text)) - } -} - -impl Contains for mail_auth::report::Record { - fn contains(&self, filter: &str) -> bool { - self.envelope_from().contains(filter) - || self.header_from().contains(filter) - || self.envelope_to().map_or(false, |to| to.contains(filter)) - || self.dkim_auth_result().iter().any(|dkim| { - dkim.domain().contains(filter) - || dkim.selector().contains(filter) - || dkim - .human_result() - .as_ref() - .map_or(false, |r| r.contains(filter)) - }) - || self.spf_auth_result().iter().any(|spf| { - spf.domain().contains(filter) - || spf.human_result().map_or(false, |r| r.contains(filter)) - }) - || self - .source_ip() - .map_or(false, |ip| ip.to_string().contains(filter)) - } -} - -impl Contains for TlsReport { - fn contains(&self, text: &str) -> bool { - self.organization_name - .as_ref() - .map_or(false, |o| o.to_lowercase().contains(text)) - || self - .contact_info - .as_ref() - .map_or(false, |c| c.to_lowercase().contains(text)) - || self.report_id.contains(text) - || self.policies.iter().any(|p| p.contains(text)) - } -} - -impl Contains for Policy { - fn contains(&self, filter: &str) -> bool { - self.policy.policy_domain.contains(filter) - || self - .policy - .policy_string - .iter() - .any(|s| s.to_lowercase().contains(filter)) - || self - .policy - .mx_host - .iter() - .any(|s| s.to_lowercase().contains(filter)) - || self.failure_details.iter().any(|f| f.contains(filter)) - } -} - -impl Contains for FailureDetails { - fn contains(&self, filter: &str) -> bool { - self.sending_mta_ip - .map_or(false, |s| s.to_string().contains(filter)) - || self - .receiving_ip - .map_or(false, |s| s.to_string().contains(filter)) - || self - .receiving_mx_hostname - .as_ref() - .map_or(false, |s| s.contains(filter)) - || self - .receiving_mx_helo - .as_ref() - .map_or(false, |s| s.contains(filter)) - || self - .additional_information - .as_ref() - .map_or(false, |s| s.contains(filter)) - || self - .failure_reason_code - .as_ref() - .map_or(false, |s| s.contains(filter)) - } -} - -impl<'x> Contains for Feedback<'x> { - fn contains(&self, text: &str) -> bool { - // Check if any of the string fields contain the filter - self.authentication_results() - .iter() - .any(|s| s.contains(text)) - || self - .original_envelope_id() - .map_or(false, |s| s.contains(text)) - || self - .original_mail_from() - .map_or(false, |s| s.contains(text)) - || self.original_rcpt_to().map_or(false, |s| s.contains(text)) - || self.reported_domain().iter().any(|s| s.contains(text)) - || self.reported_uri().iter().any(|s| s.contains(text)) - || self.reporting_mta().map_or(false, |s| s.contains(text)) - || self.user_agent().map_or(false, |s| s.contains(text)) - || self.dkim_adsp_dns().map_or(false, |s| s.contains(text)) - || self - .dkim_canonicalized_body() - .map_or(false, |s| s.contains(text)) - || self - .dkim_canonicalized_header() - .map_or(false, |s| s.contains(text)) - || self.dkim_domain().map_or(false, |s| s.contains(text)) - || self.dkim_identity().map_or(false, |s| s.contains(text)) - || self.dkim_selector().map_or(false, |s| s.contains(text)) - || self.dkim_selector_dns().map_or(false, |s| s.contains(text)) - || self.spf_dns().map_or(false, |s| s.contains(text)) - || self.message().map_or(false, |s| s.contains(text)) - || self.headers().map_or(false, |s| s.contains(text)) - } -} - -impl Contains for IncomingReport { - fn contains(&self, text: &str) -> bool { - self.from.to_lowercase().contains(text) - || self.to.iter().any(|to| to.to_lowercase().contains(text)) - || self.subject.to_lowercase().contains(text) - || self.report.contains(text) - } -} - -impl From<&queue::Message> for Message { - fn from(message: &queue::Message) -> Self { - let now = now(); - - Message { - id: message.id, - return_path: message.return_path.clone(), - created: DateTime::from_timestamp(message.created as i64), - size: message.size, - priority: message.priority, - env_id: message.env_id.clone(), - domains: message - .domains - .iter() - .enumerate() - .map(|(idx, domain)| Domain { - name: domain.domain.clone(), - status: match &domain.status { - Status::Scheduled => Status::Scheduled, - Status::Completed(_) => Status::Completed(String::new()), - Status::TemporaryFailure(status) => { - Status::TemporaryFailure(status.to_string()) - } - Status::PermanentFailure(status) => { - Status::PermanentFailure(status.to_string()) - } - }, - retry_num: domain.retry.inner, - next_retry: Some(DateTime::from_timestamp(domain.retry.due as i64)), - next_notify: if domain.notify.due > now { - DateTime::from_timestamp(domain.notify.due as i64).into() - } else { - None - }, - recipients: message - .recipients - .iter() - .filter(|rcpt| rcpt.domain_idx == idx) - .map(|rcpt| Recipient { - address: rcpt.address.clone(), - status: match &rcpt.status { - Status::Scheduled => Status::Scheduled, - Status::Completed(status) => { - Status::Completed(status.response.to_string()) - } - Status::TemporaryFailure(status) => { - Status::TemporaryFailure(status.response.to_string()) - } - Status::PermanentFailure(status) => { - Status::PermanentFailure(status.response.to_string()) - } - }, - orcpt: rcpt.orcpt.clone(), - }) - .collect(), - expires: DateTime::from_timestamp(domain.expires as i64), - }) - .collect(), - } - } -} - -impl Report { - fn dmarc(event: ReportEvent, report: report::Report, rua: Vec) -> Self { - Self::Dmarc { - domain: event.domain.clone(), - range_from: DateTime::from_timestamp(event.seq_id as i64), - range_to: DateTime::from_timestamp(event.due as i64), - id: QueueClass::DmarcReportHeader(event).queue_id(), - report, - rua, - } - } - - fn tls(event: ReportEvent, report: TlsReport, rua: Vec) -> Self { - Self::Tls { - domain: event.domain.clone(), - range_from: DateTime::from_timestamp(event.seq_id as i64), - range_to: DateTime::from_timestamp(event.due as i64), - id: QueueClass::TlsReportHeader(event).queue_id(), - report, - rua, - } - } -} - -trait GenerateQueueId { - fn queue_id(&self) -> String; -} - -impl GenerateQueueId for QueueClass { - fn queue_id(&self) -> String { - match self { - QueueClass::DmarcReportHeader(h) => { - format!("d!{}!{}!{}!{}", h.domain, h.policy_hash, h.seq_id, h.due) - } - QueueClass::TlsReportHeader(h) => { - format!("t!{}!{}!{}!{}", h.domain, h.policy_hash, h.seq_id, h.due) - } - _ => unreachable!(), - } - } -} - -fn parse_queued_report_id(id: &str) -> Option { - let mut parts = id.split('!'); - let type_ = parts.next()?; - let event = ReportEvent { - domain: parts.next()?.to_string(), - policy_hash: parts.next().and_then(|p| p.parse::().ok())?, - seq_id: parts.next().and_then(|p| p.parse::().ok())?, - due: parts.next().and_then(|p| p.parse::().ok())?, - }; - match type_ { - "d" => Some(QueueClass::DmarcReportHeader(event)), - "t" => Some(QueueClass::TlsReportHeader(event)), - _ => None, - } -} - -fn parse_incoming_report_id(class: &str, id: &str) -> Option { - let mut parts = id.split('_'); - let id = parts.next()?.parse().ok()?; - let expires = parts.next()?.parse().ok()?; - match class { - "dmarc" => Some(ReportClass::Dmarc { id, expires }), - "tls" => Some(ReportClass::Tls { id, expires }), - "arf" => Some(ReportClass::Arf { id, expires }), - _ => None, - } -} - -struct Timestamp(u64); - -impl FromStr for Timestamp { - type Err = (); - - fn from_str(s: &str) -> Result { - if let Some(dt) = DateTime::parse_rfc3339(s) { - let instant = dt.to_timestamp() as u64; - if instant >= now() { - return Ok(Timestamp(instant)); - } - } - - Err(()) - } -} - -impl Timestamp { - pub fn into_inner(self) -> u64 { - self.0 - } -} - -trait BadRequest { - fn into_bad_request(self) -> (StatusCode, String); -} - -impl BadRequest for String { - fn into_bad_request(self) -> (StatusCode, String) { - ( - StatusCode::BAD_REQUEST, - format!( - "{{\"error\": \"bad-parameters\", \"details\": {}}}", - serde_json::to_string(&self).unwrap() - ), - ) - } -} - -impl BadRequest for store::Error { - fn into_bad_request(self) -> (StatusCode, String) { - ( - StatusCode::INTERNAL_SERVER_ERROR, - serde_json::to_string(&json!({ - "error": "internal-error", - "details": self.to_string(), - })) - .unwrap_or_default(), - ) - } -} - -fn is_zero(num: &i16) -> bool { - *num == 0 -} - -fn serialize_maybe_datetime(value: &Option, serializer: S) -> Result -where - S: Serializer, -{ - match value { - Some(value) => serializer.serialize_some(&value.to_rfc3339()), - None => serializer.serialize_none(), - } -} - -fn deserialize_maybe_datetime<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - if let Some(value) = as serde::Deserialize>::deserialize(deserializer)? { - if let Some(value) = DateTime::parse_rfc3339(value) { - Ok(Some(value)) - } else { - Err(serde::de::Error::custom( - "Failed to parse RFC3339 timestamp", - )) - } - } else { - Ok(None) - } -} - -fn serialize_datetime(value: &DateTime, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_str(&value.to_rfc3339()) -} - -fn deserialize_datetime<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - use serde::Deserialize; - - if let Some(value) = DateTime::parse_rfc3339(<&str>::deserialize(deserializer)?) { - Ok(value) - } else { - Err(serde::de::Error::custom( - "Failed to parse RFC3339 timestamp", - )) - } -} diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index 89f80d3f..76d0e6ec 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -58,7 +58,6 @@ use crate::{ use self::throttle::{ThrottleKey, ThrottleKeyHasherBuilder}; -pub mod management; pub mod params; pub mod throttle; pub mod worker; @@ -83,23 +82,12 @@ pub struct SmtpSessionManager { pub inner: SmtpInstance, } -#[derive(Clone)] -pub struct SmtpAdminSessionManager { - pub inner: SmtpInstance, -} - impl SmtpSessionManager { pub fn new(inner: SmtpInstance) -> Self { Self { inner } } } -impl SmtpAdminSessionManager { - pub fn new(inner: SmtpInstance) -> Self { - Self { inner } - } -} - #[derive(Clone)] pub struct SMTP { pub core: Arc, diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs index d9e468fa..51779a33 100644 --- a/crates/smtp/src/reporting/dmarc.rs +++ b/crates/smtp/src/reporting/dmarc.rs @@ -427,7 +427,7 @@ impl SMTP { self.delete_dmarc_report(event).await; } - pub(crate) async fn generate_dmarc_aggregate_report( + pub async fn generate_dmarc_aggregate_report( &self, event: &ReportEvent, rua: &mut Vec, diff --git a/crates/smtp/src/reporting/mod.rs b/crates/smtp/src/reporting/mod.rs index f0ead1ae..fb713332 100644 --- a/crates/smtp/src/reporting/mod.rs +++ b/crates/smtp/src/reporting/mod.rs @@ -309,7 +309,7 @@ impl From<(&Option>, &Option>)> for PolicyType { } } -pub(crate) struct SerializedSize { +pub struct SerializedSize { bytes_left: usize, } diff --git a/crates/smtp/src/reporting/tls.rs b/crates/smtp/src/reporting/tls.rs index 33a09377..30e29af3 100644 --- a/crates/smtp/src/reporting/tls.rs +++ b/crates/smtp/src/reporting/tls.rs @@ -247,7 +247,7 @@ impl SMTP { self.delete_tls_report(events).await; } - pub(crate) async fn generate_tls_aggregate_report( + pub async fn generate_tls_aggregate_report( &self, events: &[ReportEvent], rua: &mut Vec, diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index f66749ac..2bb65a0c 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index aa98efaa..d63141d6 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.6.0" +version = "0.7.0" edition = "2021" resolver = "2" diff --git a/crates/utils/src/config/mod.rs b/crates/utils/src/config/mod.rs index 283e8154..a7d87587 100644 --- a/crates/utils/src/config/mod.rs +++ b/crates/utils/src/config/mod.rs @@ -43,15 +43,15 @@ pub struct Config { #[serde(tag = "type")] pub enum ConfigWarning { Missing, - AppliedDefault(String), + AppliedDefault { default: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "type")] pub enum ConfigError { - Parse(String), - Build(String), - Macro(String), + Parse { error: String }, + Build { error: String }, + Macro { error: String }, } #[derive(Debug, Default, PartialEq, Eq)] @@ -99,7 +99,9 @@ impl Config { } else { self.errors.insert( key.clone(), - ConfigError::Macro(format!("Unknown key {location:?}")), + ConfigError::Macro { + error: format!("Unknown key {location:?}"), + }, ); } } @@ -110,9 +112,9 @@ impl Config { Err(_) => { self.errors.insert( key.clone(), - ConfigError::Macro(format!( + ConfigError::Macro { error : format!( "Failed to obtain environment variable {location:?}" - )), + )}, ); } }, @@ -126,9 +128,11 @@ impl Config { Err(err) => { self.errors.insert( key.clone(), - ConfigError::Macro(format!( + ConfigError::Macro { + error: format!( "Failed to read file {file_name:?}: {err}" - )), + ), + }, ); continue 'outer; } @@ -136,9 +140,11 @@ impl Config { Err(err) => { self.errors.insert( key.clone(), - ConfigError::Macro(format!( - "Failed to read file {file_name:?}: {err}" - )), + ConfigError::Macro { + error: format!( + "Failed to read file {file_name:?}: {err}" + ), + }, ); continue 'outer; } @@ -175,14 +181,14 @@ impl Config { pub fn log_errors(&self, use_stderr: bool) { for (key, err) in &self.errors { let message = match err { - ConfigError::Parse(err) => { - format!("Failed to parse setting {key:?}: {err}") + ConfigError::Parse { error } => { + format!("Failed to parse setting {key:?}: {error}") } - ConfigError::Build(err) => { - format!("Build error for key {key:?}: {err}") + ConfigError::Build { error } => { + format!("Build error for key {key:?}: {error}") } - ConfigError::Macro(err) => { - format!("Macro expansion error for setting {key:?}: {err}") + ConfigError::Macro { error } => { + format!("Macro expansion error for setting {key:?}: {error}") } }; if !use_stderr { @@ -196,7 +202,7 @@ impl Config { pub fn log_warnings(&self, use_stderr: bool) { for (key, warn) in &self.warnings { let message = match warn { - ConfigWarning::AppliedDefault(default) => { + ConfigWarning::AppliedDefault { default } => { format!("WARNING: Missing setting {key:?}, applied default {default:?}") } ConfigWarning::Missing => { diff --git a/crates/utils/src/config/utils.rs b/crates/utils/src/config/utils.rs index b310f0b1..e0f5d1dd 100644 --- a/crates/utils/src/config/utils.rs +++ b/crates/utils/src/config/utils.rs @@ -168,8 +168,9 @@ impl Config { Ok(value) => { results.push((key.to_string(), value)); } - Err(err) => { - self.errors.insert(key.to_string(), ConfigError::Parse(err)); + Err(error) => { + self.errors + .insert(key.to_string(), ConfigError::Parse { error }); } } } @@ -191,8 +192,12 @@ impl Config { if let Some(value) = self.keys.get(&key) { Some(value.as_str()) } else { - self.errors - .insert(key, ConfigError::Parse("Missing property".to_string())); + self.errors.insert( + key, + ConfigError::Parse { + error: "Missing property".to_string(), + }, + ); None } } @@ -200,8 +205,9 @@ impl Config { pub fn try_parse_value(&mut self, key: impl AsKey, value: &str) -> Option { match T::parse_value(value) { Ok(value) => Some(value), - Err(err) => { - self.errors.insert(key.as_key(), ConfigError::Parse(err)); + Err(error) => { + self.errors + .insert(key.as_key(), ConfigError::Parse { error }); None } } @@ -252,13 +258,21 @@ impl Config { } pub fn new_parse_error(&mut self, key: impl AsKey, details: impl Into) { - self.errors - .insert(key.as_key(), ConfigError::Parse(details.into())); + self.errors.insert( + key.as_key(), + ConfigError::Parse { + error: details.into(), + }, + ); } pub fn new_build_error(&mut self, key: impl AsKey, details: impl Into) { - self.errors - .insert(key.as_key(), ConfigError::Build(details.into())); + self.errors.insert( + key.as_key(), + ConfigError::Build { + error: details.into(), + }, + ); } pub fn new_missing_property(&mut self, key: impl AsKey) { diff --git a/resources/htx/crypto_disabled.htx b/resources/htx/crypto_disabled.htx deleted file mode 100644 index a93de38a..00000000 --- a/resources/htx/crypto_disabled.htx +++ /dev/null @@ -1 +0,0 @@ -

Encryption at rest disabled

Messages will now be stored in plain text on the server.

\ No newline at end of file diff --git a/resources/htx/crypto_error.htx b/resources/htx/crypto_error.htx deleted file mode 100644 index 594865b2..00000000 --- a/resources/htx/crypto_error.htx +++ /dev/null @@ -1 +0,0 @@ -

Failed to update encryption settings

@@@

\ No newline at end of file diff --git a/resources/htx/crypto_footer.htx b/resources/htx/crypto_footer.htx deleted file mode 100644 index eb03f53a..00000000 --- a/resources/htx/crypto_footer.htx +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/htx/crypto_form.htx b/resources/htx/crypto_form.htx deleted file mode 100644 index f5b0524d..00000000 --- a/resources/htx/crypto_form.htx +++ /dev/null @@ -1 +0,0 @@ -

Enable encryption at rest for your Stalwart Mail Server account

Select Certificate...
Cancel \ No newline at end of file diff --git a/resources/htx/crypto_header.htx b/resources/htx/crypto_header.htx deleted file mode 100644 index 710db304..00000000 --- a/resources/htx/crypto_header.htx +++ /dev/null @@ -1 +0,0 @@ -Encryption - Stalwart Mail Server