REST API cleanup

This commit is contained in:
mdecimus 2024-04-01 19:48:59 +02:00
parent 35562bb9fd
commit 223bd59bab
50 changed files with 2722 additions and 2531 deletions

2
.gitignore vendored
View file

@ -2,7 +2,7 @@
.vscode
*.failed
*_failed
stalwart.toml
/resources/config/config.toml
run.sh
_ignore
.DS_Store

25
Cargo.lock generated
View file

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

View file

@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
license = "AGPL-3.0-only"
repository = "https://github.com/stalwartlabs/cli"
homepage = "https://github.com/stalwartlabs/cli"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
readme = "README.md"
resolver = "2"

View file

@ -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<T> {
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}.")
}
}
}
}

View file

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

View file

@ -196,12 +196,10 @@ impl ConfigManager {
) -> store::Result<Vec<(String, String)>> {
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::<f64>().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);

View file

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

View file

@ -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::<VerifyStrategy>(
"auth.spf.verify.mail-from",

View file

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

View file

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

View file

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

View file

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

View file

@ -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."
),
)
}

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::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<String>,
#[serde(default)]
pub secrets: Vec<String>,
#[serde(rename = "memberOf")]
#[serde(default)]
pub member_of: Vec<String>,
#[serde(default)]
pub members: Vec<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type")]
pub enum UpdateSettings {
Delete {
keys: Vec<String>,
},
Clear {
prefix: String,
},
Insert {
prefix: Option<String>,
values: Vec<(String, String)>,
assert_empty: bool,
},
}
impl JMAP {
pub async fn handle_api_manage_request(
&self,
req: &HttpRequest,
body: Option<Vec<u8>>,
access_token: Arc<AccessToken>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
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::<PrincipalResponse>(&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::<Vec<PrincipalUpdate>>(&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::<usize>("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::<Vec<_>>();
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::<usize>("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::<AHashMap<_, _>>();
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::<Vec<_>>())
.unwrap_or_default();
let prefixes = params
.get("prefixes")
.map(|s| s.split(',').collect::<Vec<_>>())
.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::<Vec<UpdateSettings>>(&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<Vec<u8>>,
access_token: Arc<AccessToken>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
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::<OAuthCodeRequest>(&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<BoxBody<Bytes, hyper::Error>> {
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<Principal<String>> for PrincipalResponse {
fn from(principal: Principal<String>) -> 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(),
}
}
}

View file

@ -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<T: serde::Serialize> ToHttpResponse for JsonResponse<T> {
}
}
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<T: serde::Serialize> JsonResponse<T> {
pub fn new(inner: T) -> Self {
JsonResponse {

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use 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<BoxBody<Bytes, hyper::Error>> {
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(),
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
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<Vec<u8>>,
access_token: Arc<AccessToken>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
let path = req.uri().path().split('/').skip(2).collect::<Vec<_>>();
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::<OAuthCodeRequest>(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<Cow<'static, str>> for ManagementApiError {
fn from(details: Cow<'static, str>) -> Self {
ManagementApiError::Other { details }
}
}
impl From<String> for ManagementApiError {
fn from(details: String) -> Self {
ManagementApiError::Other { details: details.into() }
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::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<String>,
#[serde(default)]
pub secrets: Vec<String>,
#[serde(rename = "memberOf")]
#[serde(default)]
pub member_of: Vec<String>,
#[serde(default)]
pub members: Vec<String>,
#[serde(default)]
pub description: Option<String>,
}
impl JMAP {
pub async fn handle_manage_principal(
&self,
req: &HttpRequest,
path: Vec<&str>,
body: Option<Vec<u8>>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
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::<PrincipalResponse>(
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::<Vec<PrincipalUpdate>>(
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<AccessToken>,
body: Option<Vec<u8>>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
// 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<hyper::Response<BoxBody<Bytes, hyper::Error>>> {
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<Principal<String>> for PrincipalResponse {
fn from(principal: Principal<String>) -> 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()
}
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::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<Domain>,
#[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<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct Domain {
pub name: String,
pub status: Status<String, String>,
pub recipients: Vec<Recipient>,
pub retry_num: u32,
#[serde(deserialize_with = "deserialize_maybe_datetime")]
#[serde(serialize_with = "serialize_maybe_datetime")]
pub next_retry: Option<DateTime>,
#[serde(deserialize_with = "deserialize_maybe_datetime")]
#[serde(serialize_with = "serialize_maybe_datetime")]
pub next_notify: Option<DateTime>,
#[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<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub orcpt: Option<String>,
}
#[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<ReportUri>,
},
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<URI>,
},
}
impl JMAP {
pub async fn handle_manage_queue(
&self,
req: &HttpRequest,
path: Vec<&str>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
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::<Timestamp>("before").map(|t| t.into_inner());
let after = params.parse::<Timestamp>("after").map(|t| t.into_inner());
let page: usize = params.parse::<usize>("page").unwrap_or_default();
let limit: usize = params.parse::<usize>("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::<queue::Message>::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::<Timestamp>("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<URI>) -> 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<ReportUri>) -> 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<QueueClass> {
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::<u64>().ok())?,
seq_id: parts.next().and_then(|p| p.parse::<u64>().ok())?,
due: parts.next().and_then(|p| p.parse::<u64>().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<Self, Self::Err> {
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<S>(value: &Option<DateTime>, serializer: S) -> Result<S::Ok, S::Error>
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<Option<DateTime>, D::Error>
where
D: Deserializer<'de>,
{
if let Some(value) = <Option<&str> 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<S>(value: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_rfc3339())
}
fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use 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<BoxBody<Bytes, hyper::Error>> {
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(),
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use 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<BoxBody<Bytes, hyper::Error>> {
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::<usize>("page").unwrap_or_default();
let limit: usize = params.parse::<usize>("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<mail_auth::report::Report>,
>::deserialize(
value
)
.map_or(false, |v| v.inner.contains(filter)),
ReportType::Tls => {
Bincode::<IncomingReport<TlsReport>>::deserialize(value)
.map_or(false, |v| v.inner.contains(filter))
}
ReportType::Arf => {
Bincode::<IncomingReport<Feedback>>::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::<Bincode<IncomingReport<TlsReport>>>(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::<Bincode<IncomingReport<mail_auth::report::Report>>>(
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::<Bincode<IncomingReport<Feedback>>>(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<ReportClass> {
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<T: Contains> Contains for IncomingReport<T> {
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)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use 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<String>,
},
Clear {
prefix: String,
},
Insert {
prefix: Option<String>,
values: Vec<(String, String)>,
assert_empty: bool,
},
}
impl JMAP {
pub async fn handle_manage_settings(
&self,
req: &HttpRequest,
path: Vec<&str>,
body: Option<Vec<u8>>,
) -> hyper::Response<BoxBody<Bytes, hyper::Error>> {
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::<usize>("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::<Vec<_>>();
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::<usize>("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::<AHashMap<_, _>>();
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::<Vec<_>>())
.unwrap_or_default();
let prefixes = params
.get("prefixes")
.map(|s| s.split(',').collect::<Vec<_>>())
.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::<Vec<UpdateSettings>>(
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(),
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use 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<BoxBody<Bytes, hyper::Error>> {
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(),
}
}
}

View file

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

View file

@ -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<Vec<u8>>,
}
#[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<Vec<u8>, EncryptMessageError>;
@ -425,20 +436,29 @@ impl Algorithm {
}
}
pub fn try_parse_certs(bytes: Vec<u8>) -> Result<(EncryptionMethod, Vec<Vec<u8>>), String> {
pub fn try_parse_certs(
expected_method: EncryptionMethod,
cert: Vec<u8>,
) -> Result<Vec<Vec<u8>>, 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::<rasn_pkix::Certificate>(&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::<rasn_pkix::Certificate>(&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<Option<(EncryptionMethod, Vec<Vec<u8>>)>, String> {
fn try_parse_pem(
bytes_: &[u8],
) -> Result<Option<(EncryptionMethod, Vec<Vec<u8>>)>, 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::<EncryptionParams>::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<Option<(EncryptionMethod, Vec<Vec<u8>>
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<Option<(EncryptionMethod, Vec<Vec<u8>>
}
// 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<Option<(EncryptionMethod, Vec<Vec<u8>>
.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::<rasn_pkix::Certificate>(&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<AccessToken>) -> HttpResponse {
match self
.get_property::<EncryptionParams>(
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<Option<EncryptionParams>, Cow<str>> {
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<AccessToken>,
body: Option<Vec<u8>>,
) -> HttpResponse {
let request =
match serde_json::from_slice::<EncryptionType>(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(&params)
.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, &params, 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(&params)
.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, &params, 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(),
}
}
}

View file

@ -7,7 +7,7 @@ homepage = "https://stalw.art"
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
resolver = "2"

View file

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

View file

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

View file

@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
resolver = "2"

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -309,7 +309,7 @@ impl From<(&Option<Arc<Policy>>, &Option<Arc<Tlsa>>)> for PolicyType {
}
}
pub(crate) struct SerializedSize {
pub struct SerializedSize {
bytes_left: usize,
}

View file

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

View file

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

View file

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

View file

@ -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 => {

View file

@ -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<T: ParseValue>(&mut self, key: impl AsKey, value: &str) -> Option<T> {
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<String>) {
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<String>) {
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) {

View file

@ -1 +0,0 @@
<div class="illustration"><i class="icon ion-unlocked"></i></div><p class="auth"><b>Encryption at rest disabled</b><br /><br />Messages will now be stored in plain text on the server.</p>

View file

@ -1 +0,0 @@
<div class="illustration"><i class="icon ion-close-circled"></i></div><p class="auth"><b>Failed to update encryption settings</b><br /><br />@@@</p>

View file

@ -1 +0,0 @@
</form></div><script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/js/bootstrap.bundle.min.js"></script><script>$(document).ready(function(){$("#encryption").change(function(){"disable"===$(this).val()?$("#certificate_div").hide():$("#certificate_div").show()})})</script></body></html>

View file

@ -1 +0,0 @@
<div class="illustration"><i class="icon ion-unlocked"></i></div><p class="auth">Enable encryption at rest for your <b>Stalwart Mail Server</b> account</p><div class="form-group"><input class="form-control" type="text" name="email" placeholder="Login"></div><div class="form-group"><input class="form-control" type="password" name="password" placeholder="Password"></div><div class="form-group"><select class="form-control" id="encryption" name="encryption"><option value="pgp-256">OpenPGP (AES256)</option><option value="pgp-128">OpenPGP (AES128)</option><option value="smime-256">S/MIME (AES256-CBC)</option><option value="smime-128">S/MIME (AES128-CBC)</option><option value="disable">Disable Encryption</option></select></div><div class="form-group" id="certificate_div"><div class="fileUpload btn btn-secondary btn-block"><span>Select Certificate...</span><input type="file" id="certificate" name="certificate" class="upload"></div></div><div class="form-group"><button class="btn btn-primary btn-block" type="submit">Update</button></div><a class="auth" style="font-size:12px" href="about:blank">Cancel</a>

View file

@ -1 +0,0 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Encryption - Stalwart Mail Server</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/ionicons/2.0.1/css/ionicons.min.css"><style>body,html{height:100%;margin:0}.login-clean{background:#f1f7fc;padding:80px 0;display:flex;flex-flow:column;height:100%}.login-clean form{max-width:320px;width:90%;margin:0 auto;background-color:#fff;padding:40px;border-radius:4px;color:#505e6c;box-shadow:1px 1px 5px rgba(0,0,0,.1)}.login-clean .illustration{text-align:center;padding:0 0 0;font-size:70px;color:#f4476b}.login-clean form .form-control{background:#f7f9fc;border:none;border-bottom:1px solid #dfe7f1;border-radius:0;box-shadow:none;outline:0;color:inherit;text-indent:8px;height:42px}.login-clean form .btn-primary{background:#f4476b;border:none;border-radius:4px;padding:11px;box-shadow:none;margin-top:26px;text-shadow:none;outline:0!important}.login-clean form .btn-primary:active,.login-clean form .btn-primary:hover{background:#eb3b60}.login-clean form .btn-primary:active{transform:translateY(1px)}.login-clean form .auth{display:block;text-align:center;font-size:14px;color:#6f7a85;opacity:.9;padding:0 0 10px;text-decoration:none}.fileUpload{position:relative;overflow:hidden;margin:1px}.fileUpload input.upload{position:absolute;top:0;right:0;margin:0;padding:0;font-size:20px;cursor:pointer;opacity:0}</style></head><body><div class="login-clean"><form method="post" enctype="multipart/form-data" action="@@@"><h2 class="sr-only">Stalwart Mail Server</h2>

View file

@ -1 +0,0 @@
<div class="illustration"><i class="icon ion-locked"></i></div><p class="auth"><b>$$$ encryption at rest is now enabled</b><br /><br />@@@ certificate(s) were imported.</p>

View file

@ -202,13 +202,14 @@ pub async fn test(params: &mut JMAPTest) {
#[tokio::test]
pub async fn import_certs_and_encrypt() {
for (name, expected_method, expected_certs) in [
for (name, method, expected_certs) in [
("cert_pgp.pem", EncryptionMethod::PGP, 1),
//("cert_pgp.der", EncryptionMethod::PGP, 1),
("cert_smime.pem", EncryptionMethod::SMIME, 3),
("cert_smime.der", EncryptionMethod::SMIME, 1),
] {
let (method, mut certs) = try_parse_certs(
let mut certs = try_parse_certs(
method,
std::fs::read(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
@ -219,7 +220,6 @@ pub async fn import_certs_and_encrypt() {
)
.expect(name);
assert_eq!(method, expected_method);
assert_eq!(certs.len(), expected_certs);
if method == EncryptionMethod::PGP && certs.len() == 2 {
@ -245,6 +245,7 @@ pub async fn import_certs_and_encrypt() {
// S/MIME and PGP should not be allowed mixed
assert!(try_parse_certs(
EncryptionMethod::PGP,
std::fs::read(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")

View file

@ -23,6 +23,7 @@
use std::time::Duration;
use jmap_proto::error::request::RequestError;
use reqwest::{header::AUTHORIZATION, Method};
use serde::{de::DeserializeOwned, Deserialize};
@ -32,8 +33,9 @@ pub mod report;
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Response<T> {
Data { data: T },
RequestError(RequestError),
Error { error: String, details: String },
Data { data: T },
}
pub async fn send_manage_request<T: DeserializeOwned>(
@ -69,16 +71,22 @@ impl<T> Response<T> {
Response::Error { error, details } => {
panic!("Expected data, found error {error:?}: {details:?}")
}
Response::RequestError(err) => {
panic!("Expected data, found error {err:?}")
}
}
}
pub fn try_unwrap_data(self) -> Option<T> {
match self {
Response::Data { data } => Some(data),
Response::Error { error, .. } if error == "not-found" => None,
Response::RequestError(error) if error.status == 404 => None,
Response::Error { error, details } => {
panic!("Expected data, found error {error:?}: {details:?}")
}
Response::RequestError(err) => {
panic!("Expected data, found error {err:?}")
}
}
}
@ -86,6 +94,9 @@ impl<T> Response<T> {
match self {
Response::Error { error, details } => (error, details),
Response::Data { .. } => panic!("Expected error, found data."),
Response::RequestError(err) => {
panic!("Expected error, found request error {err:?}")
}
}
}
}

View file

@ -26,15 +26,13 @@ use std::time::{Duration, Instant};
use ahash::{AHashMap, HashMap, HashSet};
use common::config::server::ServerProtocol;
use jmap::api::management::queue::Message;
use mail_auth::MX;
use mail_parser::DateTime;
use reqwest::{header::AUTHORIZATION, Method, StatusCode};
use crate::smtp::{management::send_manage_request, outbound::TestServer, session::TestSession};
use smtp::{
core::management::Message,
queue::{manager::SpawnQueue, QueueId, Status},
};
use smtp::queue::{manager::SpawnQueue, QueueId, Status};
const LOCAL: &str = r#"
[storage]
@ -465,7 +463,7 @@ async fn manage_queue() {
.danger_accept_invalid_certs(true)
.build()
.unwrap()
.get("https://127.0.0.1:9980/list")
.get("https://127.0.0.1:9980/api/queue/messages")
.header(AUTHORIZATION, "Basic YWRtaW46aGVsbG93b3JsZA==")
.send()
.await

View file

@ -26,6 +26,7 @@ use std::sync::Arc;
use ahash::{AHashMap, HashSet};
use common::config::{server::ServerProtocol, smtp::report::AggregateFrequency};
use jmap::api::management::queue::Report;
use mail_auth::{
common::parse::TxtRecordParser,
dmarc::Dmarc,
@ -41,10 +42,7 @@ use crate::smtp::{
management::{queue::List, send_manage_request},
outbound::TestServer,
};
use smtp::{
core::management::Report,
reporting::{scheduler::SpawnReport, DmarcEvent, TlsEvent},
};
use smtp::reporting::{scheduler::SpawnReport, DmarcEvent, TlsEvent};
const CONFIG: &str = r#"
[storage]

View file

@ -25,12 +25,11 @@ use common::{
config::server::{ServerProtocol, Servers},
Core,
};
use jmap::{api::JmapSessionManager, JMAP};
use store::{BlobStore, Store, Stores};
use tokio::sync::{mpsc, watch};
use ::smtp::core::{
Inner, Session, SmtpAdminSessionManager, SmtpInstance, SmtpSessionManager, SMTP,
};
use ::smtp::core::{Inner, Session, SmtpInstance, SmtpSessionManager, SMTP};
use utils::config::Config;
use crate::AssertConfig;
@ -147,10 +146,18 @@ impl TestServer {
// Start servers
servers.bind_and_drop_priv(&mut config);
config.assert_no_errors();
let instance = self.instance.clone();
let smtp_manager = SmtpSessionManager::new(instance.clone());
let smtp_admin_manager = SmtpAdminSessionManager::new(instance.clone());
let jmap = JMAP::init(
&mut config,
mpsc::channel(1).1,
instance.core.clone(),
instance.inner.clone(),
)
.await;
let jmap_manager = JmapSessionManager::new(jmap);
config.assert_no_errors();
servers.spawn(|server, acceptor, shutdown_rx| {
match &server.protocol {
ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn(
@ -160,7 +167,7 @@ impl TestServer {
shutdown_rx,
),
ServerProtocol::Http => server.spawn(
smtp_admin_manager.clone(),
jmap_manager.clone(),
instance.core.clone(),
acceptor,
shutdown_rx,