mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2024-11-28 00:56:45 +00:00
REST API cleanup
This commit is contained in:
parent
35562bb9fd
commit
223bd59bab
50 changed files with 2722 additions and 2531 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,7 +2,7 @@
|
|||
.vscode
|
||||
*.failed
|
||||
*_failed
|
||||
stalwart.toml
|
||||
/resources/config/config.toml
|
||||
run.sh
|
||||
_ignore
|
||||
.DS_Store
|
||||
|
|
25
Cargo.lock
generated
25
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "common"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "directory"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "imap"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
102
crates/jmap/src/api/management/domain.rs
Normal file
102
crates/jmap/src/api/management/domain.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
154
crates/jmap/src/api/management/mod.rs
Normal file
154
crates/jmap/src/api/management/mod.rs
Normal 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() }
|
||||
}
|
||||
}
|
407
crates/jmap/src/api/management/principal.rs
Normal file
407
crates/jmap/src/api/management/principal.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
701
crates/jmap/src/api/management/queue.rs
Normal file
701
crates/jmap/src/api/management/queue.rs
Normal 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
|
||||
}
|
95
crates/jmap/src/api/management/reload.rs
Normal file
95
crates/jmap/src/api/management/reload.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
401
crates/jmap/src/api/management/report.rs
Normal file
401
crates/jmap/src/api/management/report.rs
Normal 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)
|
||||
}
|
||||
}
|
381
crates/jmap/src/api/management/settings.rs
Normal file
381
crates/jmap/src/api/management/settings.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
62
crates/jmap/src/api/management/stores.rs
Normal file
62
crates/jmap/src/api/management/stores.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(¶ms)
|
||||
.await
|
||||
{
|
||||
return Err(Cow::from(message));
|
||||
}
|
||||
|
||||
// Save encryption params
|
||||
let (method, algo, certs) = match request {
|
||||
EncryptionType::PGP { algo, certs } => (EncryptionMethod::PGP, algo, certs),
|
||||
EncryptionType::SMIME { algo, certs } => (EncryptionMethod::SMIME, algo, certs),
|
||||
EncryptionType::Disabled => {
|
||||
// Disable encryption at rest
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(token.primary_id())
|
||||
.with_collection(Collection::Principal)
|
||||
.update_document(0)
|
||||
.value(Property::Parameters, ¶ms, F_VALUE);
|
||||
self.write_batch(batch).await.map_err(|_| {
|
||||
Cow::from("Failed to save encryption parameters, please try again later")
|
||||
})?;
|
||||
|
||||
Ok(Some(params))
|
||||
} else {
|
||||
// Remove encryption params
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(token.primary_id())
|
||||
.with_account_id(access_token.primary_id())
|
||||
.with_collection(Collection::Principal)
|
||||
.update_document(0)
|
||||
.value(Property::Parameters, (), F_VALUE | F_CLEAR);
|
||||
self.write_batch(batch).await.map_err(|_| {
|
||||
Cow::from("Failed to save encryption parameters, please try again later")
|
||||
})?;
|
||||
Ok(None)
|
||||
return match self.core.storage.data.write(batch.build()).await {
|
||||
Ok(_) => JsonResponse::new(json!({
|
||||
"data": (),
|
||||
}))
|
||||
.into_http_response(),
|
||||
Err(err) => err.into_http_response(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
Err(Cow::from("Missing form parameters"))
|
||||
};
|
||||
|
||||
// Make sure Encryption is enabled
|
||||
if !self.core.jmap.encrypt {
|
||||
return ManagementApiError::Unsupported {
|
||||
details: "Encryption-at-rest has been disabled by the system administrator".into(),
|
||||
}
|
||||
.into_http_response();
|
||||
}
|
||||
|
||||
// Parse certificates
|
||||
let params = match try_parse_certs(method, certs.into_bytes()) {
|
||||
Ok(certs) => EncryptionParams {
|
||||
method,
|
||||
algo,
|
||||
certs,
|
||||
},
|
||||
Err(err) => return ManagementApiError::from(err).into_http_response(),
|
||||
};
|
||||
|
||||
// Try a test encryption
|
||||
if let Err(EncryptMessageError::Error(message)) = MessageParser::new()
|
||||
.parse("Subject: test\r\ntest\r\n".as_bytes())
|
||||
.unwrap()
|
||||
.encrypt(¶ms)
|
||||
.await
|
||||
{
|
||||
return ManagementApiError::from(message).into_http_response();
|
||||
}
|
||||
|
||||
// Save encryption params
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(access_token.primary_id())
|
||||
.with_collection(Collection::Principal)
|
||||
.update_document(0)
|
||||
.value(Property::Parameters, ¶ms, F_VALUE);
|
||||
match self.core.storage.data.write(batch.build()).await {
|
||||
Ok(_) => JsonResponse::new(json!({
|
||||
"data": (),
|
||||
}))
|
||||
.into_http_response(),
|
||||
Err(err) => err.into_http_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "managesieve"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "nlp"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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
|
@ -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>,
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -309,7 +309,7 @@ impl From<(&Option<Arc<Policy>>, &Option<Arc<Tlsa>>)> for PolicyType {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SerializedSize {
|
||||
pub struct SerializedSize {
|
||||
bytes_left: usize,
|
||||
}
|
||||
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "store"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "utils"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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")
|
||||
|
|
|
@ -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:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue