From fa25da8da3260f768fb081cb1d15072814d7099f Mon Sep 17 00:00:00 2001 From: mdecimus <11444311+mdecimus@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:48:51 +0100 Subject: [PATCH] RIP Stalwart CLI (closes #2455) --- .github/workflows/ci.yml | 4 - Cargo.lock | 274 +------- Cargo.toml | 1 - crates/cli/Cargo.toml | 31 - crates/cli/src/main.rs | 316 --------- crates/cli/src/modules/account.rs | 367 ---------- crates/cli/src/modules/cli.rs | 594 ---------------- crates/cli/src/modules/database.rs | 143 ---- crates/cli/src/modules/dkim.rs | 70 -- crates/cli/src/modules/domain.rs | 125 ---- crates/cli/src/modules/export.rs | 449 ------------ crates/cli/src/modules/group.rs | 152 ---- crates/cli/src/modules/import.rs | 1039 ---------------------------- crates/cli/src/modules/list.rs | 152 ---- crates/cli/src/modules/mod.rs | 260 ------- crates/cli/src/modules/queue.rs | 473 ------------- crates/cli/src/modules/report.rs | 249 ------- 17 files changed, 1 insertion(+), 4698 deletions(-) delete mode 100644 crates/cli/Cargo.toml delete mode 100644 crates/cli/src/main.rs delete mode 100644 crates/cli/src/modules/account.rs delete mode 100644 crates/cli/src/modules/cli.rs delete mode 100644 crates/cli/src/modules/database.rs delete mode 100644 crates/cli/src/modules/dkim.rs delete mode 100644 crates/cli/src/modules/domain.rs delete mode 100644 crates/cli/src/modules/export.rs delete mode 100644 crates/cli/src/modules/group.rs delete mode 100644 crates/cli/src/modules/import.rs delete mode 100644 crates/cli/src/modules/list.rs delete mode 100644 crates/cli/src/modules/mod.rs delete mode 100644 crates/cli/src/modules/queue.rs delete mode 100644 crates/cli/src/modules/report.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52ae4507..9d4ad862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,10 +312,8 @@ jobs: run: | rustup target add ${{matrix.target}} cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" - cargo build --release --target ${{matrix.target}} -p stalwart-cli mkdir -p artifacts mv ./target/${{matrix.target}}/release/stalwart.exe ./artifacts/stalwart.exe - mv ./target/${{matrix.target}}/release/stalwart-cli.exe ./artifacts/stalwart-cli.exe - name: Upload Artifacts uses: actions/upload-artifact@v6 @@ -359,10 +357,8 @@ jobs: run: | rustup target add ${{matrix.target}} cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" - cargo build --release --target ${{matrix.target}} -p stalwart-cli mkdir -p artifacts mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart - mv ./target/${{matrix.target}}/release/stalwart-cli ./artifacts/stalwart-cli - name: Upload Artifacts uses: actions/upload-artifact@v6 diff --git a/Cargo.lock b/Cargo.lock index 5f6ccabd..e7da8efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,56 +118,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.100" @@ -1068,46 +1018,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "4.5.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "clap_lex" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" - [[package]] name = "cmac" version = "0.7.2" @@ -1128,12 +1038,6 @@ dependencies = [ "cc", ] -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "combine" version = "4.6.7" @@ -1251,19 +1155,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width 0.2.2", - "windows-sys 0.59.0", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -2139,12 +2030,6 @@ dependencies = [ "log", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -3146,12 +3031,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "human-size" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9994b79e8c1a39b3166c63ae7823bb2b00831e2a96a31399c50fe69df408eaeb" - [[package]] name = "humantime" version = "2.3.0" @@ -3521,19 +3400,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console", - "number_prefix", - "portable-atomic", - "unicode-width 0.2.2", - "web-time", -] - [[package]] name = "infer" version = "0.2.3" @@ -3605,23 +3471,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.10.5" @@ -3761,28 +3610,6 @@ dependencies = [ "utils", ] -[[package]] -name = "jmap-client" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99dea3753873e0017ea5eecc09b1f06ac6a96d180d1ff5c9f38be83b85eb1ee" -dependencies = [ - "ahash", - "async-stream", - "base64 0.13.1", - "chrono", - "futures-util", - "maybe-async", - "parking_lot", - "reqwest", - "rustls 0.23.35", - "rustls-pki-types", - "serde", - "serde_json", - "tokio", - "tokio-tungstenite", -] - [[package]] name = "jmap-client" version = "0.4.0" @@ -4800,12 +4627,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "object" version = "0.37.3" @@ -4846,12 +4667,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "opaque-debug" version = "0.3.1" @@ -5473,20 +5288,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "prettytable-rs" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" -dependencies = [ - "csv", - "encode_unicode", - "is-terminal", - "lazy_static", - "term", - "unicode-width 0.1.14", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -6376,17 +6177,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rpassword" -version = "7.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.59.0", -] - [[package]] name = "rsa" version = "0.9.9" @@ -6407,16 +6197,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rtoolbox" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "rtrb" version = "0.3.2" @@ -7574,31 +7354,6 @@ dependencies = [ "utils", ] -[[package]] -name = "stalwart-cli" -version = "0.15.4" -dependencies = [ - "clap", - "console", - "csv", - "form_urlencoded", - "futures", - "human-size", - "indicatif", - "jmap-client 0.3.3", - "mail-auth", - "mail-parser", - "num_cpus", - "prettytable-rs", - "pwhash", - "rand 0.9.2", - "reqwest", - "rpassword", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -7800,7 +7555,7 @@ dependencies = [ "imap_proto", "jemallocator", "jmap", - "jmap-client 0.4.0", + "jmap-client", "jmap_proto", "mail-auth", "mail-builder", @@ -8515,18 +8270,6 @@ dependencies = [ "unicode-script", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -8602,12 +8345,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "utils" version = "0.15.4" @@ -9030,15 +8767,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 0538c0ae..320e80ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ members = [ "crates/common", "crates/trc", "crates/migration", - "crates/cli", "tests", ] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml deleted file mode 100644 index 10a62d35..00000000 --- a/crates/cli/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "stalwart-cli" -description = "Stalwart Server CLI" -authors = ["Stalwart Labs LLC "] -license = "AGPL-3.0-only OR LicenseRef-SEL" -repository = "https://github.com/stalwartlabs/cli" -homepage = "https://github.com/stalwartlabs/cli" -version = "0.15.5" -edition = "2024" -readme = "README.md" - -[dependencies] -jmap-client = { version = "0.3", features = ["async"] } -mail-parser = { version = "0.11", features = ["full_encoding", "serde"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]} -tokio = { version = "1.47", features = ["full"] } -num_cpus = "1.13.1" -clap = { version = "4.1.6", features = ["derive"] } -prettytable-rs = "0.10.0" -rpassword = "7.0" -indicatif = "0.17.0" -console = { version = "0.15", default-features = false, features = ["ansi-parsing"] } -serde = { version = "1.0", features = ["derive"]} -serde_json = "1.0" -csv = "1.1" -form_urlencoded = "1.1.0" -human-size = "0.4.2" -futures = "0.3.28" -pwhash = "1.0.0" -rand = "0.9.0" -mail-auth = { version = "0.7.1" } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs deleted file mode 100644 index a3cba8f6..00000000 --- a/crates/cli/src/main.rs +++ /dev/null @@ -1,316 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::{ - collections::HashMap, - fmt::Display, - io::{BufRead, Write}, - time::Duration, -}; - -use clap::Parser; -use console::style; -use jmap_client::client::Credentials; -use modules::{ - UnwrapResult, - cli::{Cli, Client, Commands}, - host, is_localhost, -}; -use reqwest::{Method, StatusCode, header::AUTHORIZATION}; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; - -use crate::modules::OAuthResponse; - -pub mod modules; - -#[tokio::main] -async fn main() -> std::io::Result<()> { - let args = Cli::parse(); - let url = args - .url - .or_else(|| std::env::var("URL").ok()) - .map(|url| url.trim_end_matches('/').to_string()) - .unwrap_or_else(|| { - eprintln!("No URL specified. Use --url or set the URL environment variable."); - std::process::exit(1); - }); - let client = Client { - credentials: if let Some(credentials) = args.credentials { - parse_credentials(&credentials) - } else if let Ok(credentials) = std::env::var("CREDENTIALS") { - parse_credentials(&credentials) - } else if args.anonymous { - let credentials = "anonymous:".to_string(); - parse_credentials(&credentials) - } else { - let credentials = rpassword::prompt_password( - "\nEnter administrator credentials or press [ENTER] to use OAuth: ", - ) - .unwrap(); - - if !credentials.is_empty() { - parse_credentials(&credentials) - } else { - oauth(&url).await - } - }, - timeout: args.timeout, - url, - }; - - match args.command { - Commands::Import(command) => { - command.exec(client).await; - } - Commands::Export(command) => { - command.exec(client).await; - } - Commands::Server(command) => command.exec(client).await, - /*Commands::Account(command) => command.exec(client).await, - Commands::Domain(command) => command.exec(client).await, - Commands::List(command) => command.exec(client).await, - Commands::Group(command) => command.exec(client).await,*/ - Commands::Dkim(command) => command.exec(client).await, - Commands::Queue(command) => command.exec(client).await, - Commands::Report(command) => command.exec(client).await, - } - - Ok(()) -} - -fn parse_credentials(credentials: &str) -> Credentials { - if let Some((account, secret)) = credentials.split_once(':') { - Credentials::basic(account, secret) - } else { - Credentials::basic("admin", credentials) - } -} - -async fn oauth(url: &str) -> Credentials { - let metadata: HashMap = serde_json::from_slice( - &reqwest::Client::builder() - .danger_accept_invalid_certs(is_localhost(url)) - .build() - .unwrap_or_default() - .get(format!("{}/.well-known/oauth-authorization-server", url)) - .send() - .await - .unwrap_result("send OAuth GET request") - .bytes() - .await - .unwrap_result("fetch bytes"), - ) - .unwrap_result("deserialize OAuth GET response"); - - let token_endpoint = metadata.property("token_endpoint"); - let mut params: HashMap = - HashMap::from_iter([("client_id".to_string(), "Stalwart_CLI".to_string())]); - let response: HashMap = serde_json::from_slice( - &reqwest::Client::builder() - .danger_accept_invalid_certs(is_localhost(url)) - .build() - .unwrap_or_default() - .post(metadata.property("device_authorization_endpoint")) - .form(¶ms) - .send() - .await - .unwrap_result("send OAuth POST request") - .bytes() - .await - .unwrap_result("fetch bytes"), - ) - .unwrap_result("deserialize OAuth POST response"); - - params.insert( - "grant_type".to_string(), - "urn:ietf:params:oauth:grant-type:device_code".to_string(), - ); - params.insert( - "device_code".to_string(), - response.property("device_code").to_string(), - ); - - print!( - "\nAuthenticate this request using code {} at {}. Please ENTER when done.", - style(response.property("user_code")).bold(), - style(response.property("verification_uri")).bold().dim() - ); - - std::io::stdout().flush().unwrap(); - std::io::stdin().lock().lines().next(); - - let mut response: HashMap = serde_json::from_slice( - &reqwest::Client::builder() - .danger_accept_invalid_certs(is_localhost(url)) - .build() - .unwrap_or_default() - .post(token_endpoint) - .form(¶ms) - .send() - .await - .unwrap_result("send OAuth POST request") - .bytes() - .await - .unwrap_result("fetch bytes"), - ) - .unwrap_result("deserialize OAuth POST response"); - - if let Some(serde_json::Value::String(access_token)) = response.remove("access_token") { - Credentials::Bearer(access_token) - } else { - eprintln!( - "OAuth failed with code {}.", - response - .get("error") - .and_then(|s| s.as_str()) - .unwrap_or("") - ); - std::process::exit(1); - } -} - -#[derive(Deserialize)] -#[serde(untagged)] -pub enum Response { - Error(ManagementApiError), - Data { data: T }, -} - -#[derive(Deserialize)] -#[serde(tag = "error")] -#[serde(rename_all = "camelCase")] -pub enum ManagementApiError { - FieldAlreadyExists { field: String, value: String }, - FieldMissing { field: String }, - NotFound { item: String }, - Unsupported { details: String }, - AssertFailed, - Other { details: String }, -} - -impl Client { - pub async fn into_jmap_client(self) -> jmap_client::client::Client { - jmap_client::client::Client::new() - .credentials(self.credentials) - .accept_invalid_certs(is_localhost(&self.url)) - .follow_redirects([host(&self.url).expect("Invalid host").to_owned()]) - .timeout(Duration::from_secs(self.timeout.unwrap_or(60))) - .connect(&self.url) - .await - .unwrap_or_else(|err| { - eprintln!("Failed to connect to JMAP server {}: {}.", &self.url, err); - std::process::exit(1); - }) - } - - pub async fn http_request( - &self, - method: Method, - url: &str, - body: Option, - ) -> R { - self.try_http_request(method, url, body) - .await - .unwrap_or_else(|| { - eprintln!("Request failed: No data returned."); - std::process::exit(1); - }) - } - - pub async fn try_http_request( - &self, - method: Method, - url: &str, - body: Option, - ) -> Option { - let url = format!( - "{}{}{}", - self.url, - if !self.url.ends_with('/') && !url.starts_with('/') { - "/" - } else { - "" - }, - url - ); - let mut request = reqwest::Client::builder() - .danger_accept_invalid_certs(is_localhost(&url)) - .timeout(Duration::from_secs(self.timeout.unwrap_or(60))) - .build() - .unwrap_or_default() - .request(method, url) - .header( - AUTHORIZATION, - match &self.credentials { - Credentials::Basic(s) => format!("Basic {s}"), - Credentials::Bearer(s) => format!("Bearer {s}"), - }, - ); - - if let Some(body) = body { - request = request.body(serde_json::to_string(&body).unwrap_result("serialize body")); - } - - let response = request.send().await.unwrap_result("send HTTP request"); - - match response.status() { - StatusCode::OK => (), - StatusCode::NOT_FOUND => { - return None; - } - StatusCode::UNAUTHORIZED => { - eprintln!( - "Authentication failed. Make sure the credentials are correct and that the account has administrator rights." - ); - std::process::exit(1); - } - _ => { - eprintln!( - "Request failed: {}", - response.text().await.unwrap_result("fetch text") - ); - std::process::exit(1); - } - } - - let bytes = response.bytes().await.unwrap_result("fetch bytes"); - match serde_json::from_slice::>(&bytes).unwrap_result(&format!( - "deserialize response {}", - String::from_utf8_lossy(bytes.as_ref()) - )) { - Response::Data { data } => Some(data), - 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) - } - } - } -} diff --git a/crates/cli/src/modules/account.rs b/crates/cli/src/modules/account.rs deleted file mode 100644 index cfba144a..00000000 --- a/crates/cli/src/modules/account.rs +++ /dev/null @@ -1,367 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::fmt::Display; - -use prettytable::{Attr, Cell, Row, Table}; -use pwhash::sha512_crypt; -use reqwest::Method; -use serde_json::Value; - -use super::{ - Principal, PrincipalField, PrincipalUpdate, PrincipalValue, Type, - cli::{AccountCommands, Client}, -}; - -impl AccountCommands { - pub async fn exec(self, client: Client) { - match self { - AccountCommands::Create { - name, - password, - description, - quota, - is_admin, - addresses, - member_of, - } => { - let principal = Principal { - typ: if is_admin.unwrap_or_default() { - Type::Superuser - } else { - Type::Individual - } - .into(), - quota, - name: name.clone().into(), - secrets: vec![sha512_crypt::hash(password).unwrap()], - emails: addresses.unwrap_or_default(), - member_of: member_of.unwrap_or_default(), - description, - ..Default::default() - }; - let account_id = client - .http_request::(Method::POST, "/api/principal", Some(principal)) - .await; - eprintln!("Successfully created account {name:?} with id {account_id}."); - } - AccountCommands::Update { - name, - new_name, - password, - description, - quota, - is_admin, - addresses, - member_of, - } => { - let mut changes = Vec::new(); - if let Some(new_name) = new_name { - changes.push(PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String(new_name), - )); - } - if let Some(password) = password { - changes.push(PrincipalUpdate::add_item( - PrincipalField::Secrets, - PrincipalValue::String(sha512_crypt::hash(password).unwrap()), - )); - } - if let Some(description) = description { - changes.push(PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String(description), - )); - } - if let Some(quota) = quota { - changes.push(PrincipalUpdate::set( - PrincipalField::Quota, - PrincipalValue::Integer(quota), - )); - } - if let Some(is_admin) = is_admin { - changes.push(PrincipalUpdate::set( - PrincipalField::Type, - PrincipalValue::String( - if is_admin { - Type::Superuser - } else { - Type::Individual - } - .to_string() - .to_ascii_lowercase(), - ), - )); - } - if let Some(addresses) = addresses { - changes.push(PrincipalUpdate::set( - PrincipalField::Emails, - PrincipalValue::StringList(addresses), - )); - } - if let Some(member_of) = member_of { - changes.push(PrincipalUpdate::set( - PrincipalField::MemberOf, - PrincipalValue::StringList(member_of), - )); - } - - if !changes.is_empty() { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some(changes), - ) - .await; - eprintln!("Successfully updated account {name:?}."); - } else { - eprintln!("No changes to apply."); - } - } - AccountCommands::AddEmail { name, addresses } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - addresses - .into_iter() - .map(|address| { - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String(address), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated account {name:?}."); - } - AccountCommands::RemoveEmail { name, addresses } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - addresses - .into_iter() - .map(|address| { - PrincipalUpdate::remove_item( - PrincipalField::Emails, - PrincipalValue::String(address), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated account {name:?}."); - } - AccountCommands::AddToGroup { name, member_of } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - member_of - .into_iter() - .map(|group| { - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String(group), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated account {name:?}."); - } - AccountCommands::RemoveFromGroup { name, member_of } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - member_of - .into_iter() - .map(|group| { - PrincipalUpdate::remove_item( - PrincipalField::MemberOf, - PrincipalValue::String(group), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated account {name:?}."); - } - AccountCommands::Delete { name } => { - client - .http_request::( - Method::DELETE, - &format!("/api/principal/{name}"), - None, - ) - .await; - eprintln!("Successfully deleted account {name:?}."); - } - AccountCommands::Display { name } => { - client.display_principal(&name).await; - } - AccountCommands::List { - filter, - limit, - page, - } => { - client - .list_principals("individual", "Account", filter, page, limit) - .await; - } - } - } -} - -impl Client { - pub async fn display_principal(&self, name: &str) { - let principal = self - .http_request::(Method::GET, &format!("/api/principal/{name}"), None) - .await; - let mut table = Table::new(); - if let Some(name) = principal.name { - table.add_row(Row::new(vec![ - Cell::new("Name").with_style(Attr::Bold), - Cell::new(&name), - ])); - } - if let Some(typ) = principal.typ { - table.add_row(Row::new(vec![ - Cell::new("Type").with_style(Attr::Bold), - Cell::new(&typ.to_string()), - ])); - } - if let Some(description) = principal.description { - table.add_row(Row::new(vec![ - Cell::new("Description").with_style(Attr::Bold), - Cell::new(&description), - ])); - } - if matches!( - principal.typ, - Some(Type::Individual | Type::Superuser | Type::Group) - ) { - if let Some(quota) = principal.quota { - table.add_row(Row::new(vec![ - Cell::new("Quota").with_style(Attr::Bold), - if quota != 0 { - Cell::new("a.to_string()) - } else { - Cell::new("Unlimited") - }, - ])); - } - if let Some(used_quota) = principal.used_quota { - table.add_row(Row::new(vec![ - Cell::new("Used Quota").with_style(Attr::Bold), - Cell::new(&used_quota.to_string()), - ])); - } - } - if !principal.members.is_empty() { - table.add_row(Row::new(vec![ - Cell::new("Members").with_style(Attr::Bold), - Cell::new(&principal.members.join(", ")), - ])); - } - if !principal.member_of.is_empty() { - table.add_row(Row::new(vec![ - Cell::new("Member of").with_style(Attr::Bold), - Cell::new(&principal.member_of.join(", ")), - ])); - } - if !principal.emails.is_empty() { - table.add_row(Row::new(vec![ - Cell::new("E-mail address(es)").with_style(Attr::Bold), - Cell::new(&principal.emails.join(", ")), - ])); - } - eprintln!(); - table.printstd(); - eprintln!(); - } - - pub async fn list_principals( - &self, - record_type: &str, - record_name: &str, - filter: Option, - page: Option, - limit: Option, - ) { - let mut query = form_urlencoded::Serializer::new("/api/principal?".to_string()); - - query.append_pair("type", record_type); - - if let Some(filter) = &filter { - query.append_pair("filter", filter); - } - if let Some(limit) = limit { - query.append_pair("limit", &limit.to_string()); - } - if let Some(page) = page { - query.append_pair("page", &page.to_string()); - } - - let results = self - .http_request::(Method::GET, &query.finish(), None) - .await; - if !results.items.is_empty() { - let mut table = Table::new(); - table.add_row(Row::new(vec![ - Cell::new(&format!("{record_name} Name")).with_style(Attr::Bold), - ])); - - for item in &results.items { - table.add_row(Row::new(vec![Cell::new(item)])); - } - - eprintln!(); - table.printstd(); - eprintln!(); - } - - eprintln!( - "\n\n{} {}{} found.\n", - results.total, - record_name.to_ascii_lowercase(), - if results.total == 1 { "" } else { "s" } - ); - } -} - -#[derive(Debug, serde::Deserialize)] -struct ListResponse { - pub total: usize, - pub items: Vec, -} - -impl Display for Type { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Type::Superuser => write!(f, "Superuser"), - Type::Individual => write!(f, "Individual"), - Type::Group => write!(f, "Group"), - Type::List => write!(f, "List"), - Type::Resource => write!(f, "Resource"), - Type::Location => write!(f, "Location"), - Type::Other => write!(f, "Other"), - } - } -} diff --git a/crates/cli/src/modules/cli.rs b/crates/cli/src/modules/cli.rs deleted file mode 100644 index ef1808f2..00000000 --- a/crates/cli/src/modules/cli.rs +++ /dev/null @@ -1,594 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use super::dkim::Algorithm; -use clap::{Parser, Subcommand, ValueEnum}; -use jmap_client::client::Credentials; -use mail_parser::DateTime; -use serde::Deserialize; - -#[derive(Parser)] -#[clap(version, about, long_about = None)] -#[clap(name = "stalwart-cli")] -pub struct Cli { - #[clap(subcommand)] - pub command: Commands, - /// Server base URL - #[clap(short, long)] - pub url: Option, - /// Authentication credentials - #[clap(short, long)] - pub credentials: Option, - /// Connection timeout in seconds - #[clap(short, long)] - pub timeout: Option, - /// Do not ask for credentials - #[clap(short, long)] - pub anonymous: bool, -} - -#[derive(Subcommand)] -pub enum Commands { - /// Manage user accounts - /* #[clap(subcommand)] - Account(AccountCommands), - - /// Manage domains - #[clap(subcommand)] - Domain(DomainCommands), - - /// Manage mailing lists - #[clap(subcommand)] - List(ListCommands), - - /// Manage groups - #[clap(subcommand)] - Group(GroupCommands), - */ - - /// Manage DKIM signatures - #[clap(subcommand)] - Dkim(DkimCommands), - - /// Import JMAP accounts and Maildir/mbox mailboxes - #[clap(subcommand)] - Import(ImportCommands), - - /// Export JMAP accounts - #[clap(subcommand)] - Export(ExportCommands), - - /// Manage JMAP database - #[clap(subcommand)] - Server(ServerCommands), - - /// Manage SMTP message queue - #[clap(subcommand)] - Queue(QueueCommands), - - /// Manage SMTP DMARC/TLS report queue - #[clap(subcommand)] - Report(ReportCommands), -} - -pub struct Client { - pub url: String, - pub credentials: Credentials, - pub timeout: Option, -} - -#[derive(Subcommand)] -pub enum AccountCommands { - /// Create a new user account - Create { - /// Login Name - name: String, - /// Password - password: String, - /// Account description - #[clap(short, long)] - description: Option, - /// Quota in bytes - #[clap(short, long)] - quota: Option, - /// Whether the account is an administrator - #[clap(short, long)] - is_admin: Option, - /// E-mail addresses - #[clap(short, long)] - addresses: Option>, - /// Groups this account is a member of - #[clap(short, long)] - member_of: Option>, - }, - - /// Update an existing user account - Update { - /// Account login - name: String, - /// Rename account login - #[clap(short, long)] - new_name: Option, - /// Update password - #[clap(short, long)] - password: Option, - /// Update account description - #[clap(short, long)] - description: Option, - /// Update quota in bytes - #[clap(short, long)] - quota: Option, - /// Whether the account is an administrator - #[clap(short, long)] - is_admin: Option, - /// Update e-mail addresses - #[clap(short, long)] - addresses: Option>, - /// Update groups this account is a member of - #[clap(short, long)] - member_of: Option>, - }, - - /// Add e-mail aliases to a user account - AddEmail { - /// Account login - name: String, - /// E-mail aliases to add - #[clap(required = true)] - addresses: Vec, - }, - - /// Remove e-mail aliases to a user account - RemoveEmail { - /// Account login - name: String, - /// E-mail aliases to remove - #[clap(required = true)] - addresses: Vec, - }, - - /// Add a user account to groups - AddToGroup { - /// Account login - name: String, - /// Groups to add - #[clap(required = true)] - member_of: Vec, - }, - - /// Remove a user account from groups - RemoveFromGroup { - /// Account login - name: String, - /// Groups to remove - #[clap(required = true)] - member_of: Vec, - }, - - /// Delete an existing user account - Delete { - /// Account name to delete - name: String, - }, - - /// Display an existing user account - Display { - /// Account name to display - name: String, - }, - - /// List all user accounts - List { - /// Filter accounts by keywords - filter: Option, - /// Maximum number of accounts to list - limit: Option, - /// Page number - page: Option, - }, -} - -#[derive(Subcommand)] -pub enum ListCommands { - /// Create a new mailing list - Create { - /// List Name - name: String, - /// List email address - email: String, - /// Description - #[clap(short, long)] - description: Option, - /// Mailing list members - #[clap(short, long)] - members: Option>, - }, - - /// Update an existing mailing list - Update { - /// List Name - name: String, - /// Rename list - new_name: Option, - /// List email address - email: Option, - /// Description - #[clap(short, long)] - description: Option, - /// Mailing list members - #[clap(short, long)] - members: Option>, - }, - - /// Add members to a mailing list - AddMembers { - /// List Name - name: String, - /// Members to add - #[clap(required = true)] - members: Vec, - }, - - /// Remove members from a mailing list - RemoveMembers { - /// List Name - name: String, - /// Members to remove - #[clap(required = true)] - members: Vec, - }, - - /// Display an existing mailing list - Display { - /// Mailing list to display - name: String, - }, - - /// List all mailing lists - List { - /// Filter mailing lists by keywords - filter: Option, - /// Maximum number of mailing lists to list - limit: Option, - /// Page number - page: Option, - }, -} - -#[derive(Subcommand)] -pub enum GroupCommands { - /// Create a group - Create { - /// Group Name - name: String, - /// Group email address - email: Option, - /// Description - #[clap(short, long)] - description: Option, - /// Group members - #[clap(short, long)] - members: Option>, - }, - - /// Update an existing group - Update { - /// Group Name - name: String, - /// Rename group - new_name: Option, - /// Group email address - email: Option, - /// Description - #[clap(short, long)] - description: Option, - /// Update groups that this group is a member of - #[clap(short, long)] - members: Option>, - }, - - /// Add members to a group - AddMembers { - /// Group name - name: String, - /// Groups to add - #[clap(required = true)] - members: Vec, - }, - - /// Remove members from a group - RemoveMembers { - /// Group name - name: String, - /// Groups to remove - #[clap(required = true)] - members: Vec, - }, - - /// Display an existing group - Display { - /// Group name to display - name: String, - }, - - /// List all groups - List { - /// Filter groups by keywords - filter: Option, - /// Maximum number of groups to list - limit: Option, - /// Page number - page: Option, - }, -} - -#[derive(Subcommand)] -pub enum DomainCommands { - /// Create a new domain - Create { - /// Domain name to create - name: String, - }, - - /// Delete an existing domain - Delete { - /// Domain name to delete - name: String, - }, - - /// List DNS records for domain - DNSRecords { - /// Domain name to list DNS records for - name: String, - }, - - /// List all domains - List { - /// Starting point for listing domains - from: Option, - /// Maximum number of domains to list - limit: Option, - }, -} - -#[derive(Subcommand)] -pub enum DkimCommands { - /// Create DKIM signature - Create { - /// Algorithm to use - algorithm: Algorithm, - /// Domain name for which to create - domain: String, - /// Id - signature_id: Option, - /// Selector - selector: Option, - }, - - /// Get DKIM public key - GetPublicKey { - /// Signature id - signature_id: String, - }, -} - -#[derive(Subcommand)] -pub enum ImportCommands { - /// Import messages and folders - Messages { - #[clap(value_enum)] - #[clap(short, long)] - format: MailboxFormat, - - /// Number of messages to import concurrently, defaults to the number of CPUs. - #[clap(short, long)] - num_concurrent: Option, - - /// Account name or email to import messages into - account: String, - - /// Path to the mailbox to import, or '-' for stdin (stdin only supported for mbox) - path: String, - }, - /// Import a JMAP account - Account { - /// Number of concurrent requests, defaults to the number of CPUs. - #[clap(short, long)] - num_concurrent: Option, - - /// Account name or email to import messages into - account: String, - - /// Path to the exported account directory - path: String, - }, -} - -#[derive(Subcommand)] -pub enum ExportCommands { - /// Export a JMAP account - Account { - /// Number of concurrent blob downloads to perform, defaults to the number of CPUs. - #[clap(short, long)] - num_concurrent: Option, - - /// Account name or email to import messages into - account: String, - - /// Path to export the account to - path: String, - }, -} - -#[derive(Subcommand)] -pub enum ServerCommands { - /// Perform database maintenance - DatabaseMaintenance {}, - - /// Reload TLS certificates - ReloadCertificates {}, - - /// Reload configuration - ReloadConfig {}, - - /// Create a new configuration key - AddConfig { - /// Key to add - key: String, - /// Value to set - value: Option, - }, - - /// Delete a configuration key or prefix - DeleteConfig { - /// Configuration key or prefix to delete - key: String, - }, - - /// List all configuration entries - ListConfig { - /// Prefix to filter configuration entries by - prefix: Option, - }, - - /// Perform Healthcheck - Healthcheck { - /// Status `ready` (default) or `live` to check for - check: Option - }, -} - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -pub enum MailboxFormat { - /// Mbox format - Mbox, - /// Maildir and Maildir++ formats - Maildir, - /// Maildir with hierarchical folders (i.e. Dovecot) - MaildirNested, -} - -#[derive(Subcommand)] -pub enum QueueCommands { - /// Shows messages queued for delivery - List { - /// Filter by sender address - #[clap(short, long)] - sender: Option, - /// Filter by recipient - #[clap(short, long)] - rcpt: Option, - /// Filter messages due for delivery before a certain datetime - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - before: Option, - /// Filter messages due for delivery after a certain datetime - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - after: Option, - /// Number of items to show per page - #[clap(short, long)] - page_size: Option, - }, - - /// Displays details about a queued message - Status { - #[clap(required = true)] - ids: Vec, - }, - - /// Reschedule delivery - Retry { - /// Apply to messages matching a sender address - #[clap(short, long)] - sender: Option, - /// Apply to a specific domain - #[clap(short, long)] - domain: Option, - /// Apply to messages due before a certain datetime - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - before: Option, - /// Apply to messages due after a certain datetime - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - after: Option, - /// Schedule delivery at a specific time - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - time: Option, - // Reschedule one or multiple message ids - ids: Vec, - }, - - /// Cancel delivery - Cancel { - /// Apply to messages matching a sender address - #[clap(short, long)] - sender: Option, - /// Apply to specific recipients or domains - #[clap(short, long)] - rcpt: Option, - /// Apply to messages due before a certain datetime - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - before: Option, - /// Apply to messages due after a certain datetime - #[clap(short, long)] - #[arg(value_parser = parse_datetime)] - after: Option, - // Cancel one or multiple message ids - ids: Vec, - }, -} - -#[derive(Subcommand)] -pub enum ReportCommands { - /// Shows reports queued for delivery - List { - /// Filter by report domain - #[clap(short, long)] - domain: Option, - /// Filter by report type - #[clap(short, long)] - #[clap(value_enum)] - format: Option, - /// Number of items to show per page - #[clap(short, long)] - page_size: Option, - }, - - /// Displays details about a queued report - Status { - #[clap(required = true)] - ids: Vec, - }, - - /// Cancel report delivery - Cancel { - #[clap(required = true)] - ids: Vec, - }, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Deserialize)] -pub enum ReportFormat { - /// DMARC report - #[serde(rename = "dmarc")] - Dmarc, - /// TLS report - #[serde(rename = "tls")] - Tls, -} - -fn parse_datetime(arg: &str) -> Result { - if arg.contains('T') { - DateTime::parse_rfc3339(arg).ok_or("Failed to parse RFC3339 datetime") - } else { - DateTime::parse_rfc3339(&format!("{arg}T00:00:00Z")) - .ok_or("Failed to parse RFC3339 datetime") - } -} diff --git a/crates/cli/src/modules/database.rs b/crates/cli/src/modules/database.rs deleted file mode 100644 index c9b1cd18..00000000 --- a/crates/cli/src/modules/database.rs +++ /dev/null @@ -1,143 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::collections::HashMap; -use prettytable::{Attr, Cell, Row, Table}; -use reqwest::{Method, StatusCode}; -use serde_json::Value; - -use crate::modules::{Response, UnwrapResult}; - -use super::cli::{Client, ServerCommands}; - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -#[serde(tag = "type")] -#[serde(rename_all = "camelCase")] -pub enum UpdateSettings { - Delete { - keys: Vec, - }, - Clear { - prefix: String, - #[serde(default)] - filter: Option, - }, - Insert { - prefix: Option, - values: Vec<(String, String)>, - assert_empty: bool, - }, -} - -impl ServerCommands { - pub async fn exec(self, client: Client) { - match self { - ServerCommands::DatabaseMaintenance {} => { - client - .http_request::(Method::GET, "/api/store/maintenance", None) - .await; - eprintln!("Success."); - } - ServerCommands::ReloadCertificates {} => { - client - .http_request::(Method::GET, "/api/reload/certificate", None) - .await; - eprintln!("Success."); - } - ServerCommands::ReloadConfig {} => { - client - .http_request::(Method::GET, "/api/reload", None) - .await; - eprintln!("Success."); - } - ServerCommands::AddConfig { key, value } => { - client - .http_request::( - Method::POST, - "/api/settings", - Some(vec![UpdateSettings::Insert { - prefix: None, - values: vec![(key.clone(), value.unwrap_or_default())], - assert_empty: false, - }]), - ) - .await; - eprintln!("Successfully added key {key}."); - } - ServerCommands::DeleteConfig { key } => { - client - .http_request::( - Method::POST, - "/api/settings", - Some(vec![UpdateSettings::Delete { - keys: vec![key.clone()], - }]), - ) - .await; - eprintln!("Successfully deleted key {key}."); - } - ServerCommands::ListConfig { prefix } => { - let results = client - .http_request::>, String>( - Method::GET, - &format!("/api/settings/list?prefix={}", prefix.unwrap_or_default()), - None, - ) - .await - .items; - - if !results.is_empty() { - let mut table = Table::new(); - table.add_row(Row::new(vec![ - Cell::new("Key").with_style(Attr::Bold), - Cell::new("Value").with_style(Attr::Bold), - ])); - - for (key, value) in &results { - table.add_row(Row::new(vec![Cell::new(key), Cell::new(value)])); - } - - eprintln!(); - table.printstd(); - eprintln!(); - } - - eprintln!( - "\n\n{} key{} found.\n", - results.len(), - if results.len() == 1 { "" } else { "s" } - ); - } - ServerCommands::Healthcheck { check } => { - let response = reqwest::get( - format!("{}/healthz/{}", - client.url, - check.unwrap_or("ready".to_string())) - ).await; - match response { - Ok(resp) => { - match resp.status() { - StatusCode::OK => { - eprintln!("Success") - }, - _ => { - eprintln!( - "Request failed: {}", - resp.text().await.unwrap_result("fetch text") - ); - std::process::exit(1); - } - } - } - Err(err) => { - eprintln!("Request failed: {}", err); - std::process::exit(1); - } - } - } - } - } -} diff --git a/crates/cli/src/modules/dkim.rs b/crates/cli/src/modules/dkim.rs deleted file mode 100644 index ce113802..00000000 --- a/crates/cli/src/modules/dkim.rs +++ /dev/null @@ -1,70 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use super::cli::{Client, DkimCommands}; -use clap::ValueEnum; -use reqwest::Method; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] -pub enum Algorithm { - /// RSA - #[default] - Rsa, - /// ED25519 - Ed25519, -} - -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] -struct DkimSignature { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - pub algorithm: Algorithm, - - pub domain: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub selector: Option, -} - -impl DkimCommands { - pub async fn exec(self, client: Client) { - match self { - DkimCommands::Create { - signature_id, - algorithm, - domain, - selector, - } => { - let signature_req = DkimSignature { - id: signature_id, - algorithm, - domain: domain.clone(), - selector, - }; - client - .http_request::(Method::POST, "/api/dkim", Some(signature_req)) - .await; - eprintln!("Successfully created {algorithm:?} signature for domain {domain:?}"); - } - DkimCommands::GetPublicKey { signature_id } => { - let response = client - .http_request::( - Method::GET, - &format!("/api/dkim/{signature_id}"), - None, - ) - .await; - - eprintln!(); - eprintln!("Public DKIM key for signature {signature_id}: {response}"); - eprintln!(); - } - } - } -} diff --git a/crates/cli/src/modules/domain.rs b/crates/cli/src/modules/domain.rs deleted file mode 100644 index ad2e4d39..00000000 --- a/crates/cli/src/modules/domain.rs +++ /dev/null @@ -1,125 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::borrow::Cow; - -use prettytable::{Attr, Cell, Row, Table, format}; -use reqwest::Method; -use serde_json::Value; - -use crate::modules::List; - -use super::cli::{Client, DomainCommands}; - -use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DnsRecord { - #[serde(rename = "type")] - typ: String, - name: String, - content: String, -} - -impl DomainCommands { - pub async fn exec(self, client: Client) { - match self { - DomainCommands::Create { name } => { - client - .http_request::( - Method::POST, - &format!("/api/domain/{name}"), - None, - ) - .await; - eprintln!("Successfully created domain {name:?}"); - } - DomainCommands::Delete { name } => { - client - .http_request::( - Method::DELETE, - &format!("/api/domain/{name}"), - None, - ) - .await; - eprintln!("Successfully deleted domain {name:?}"); - } - DomainCommands::DNSRecords { name } => { - let records = client - .http_request::, String>( - Method::GET, - &format!("/api/domain/{name}"), - None, - ) - .await; - - if !records.is_empty() { - let mut table = Table::new(); - // no borderline separator separator, as long values will mess it up - table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR); - - table.add_row(Row::new(vec![ - Cell::new("Type").with_style(Attr::Bold), - Cell::new("Name").with_style(Attr::Bold), - Cell::new("Contents").with_style(Attr::Bold), - ])); - - for record in &records { - table.add_row(Row::new(vec![ - Cell::new(&record.typ), - Cell::new(&record.name), - Cell::new(&record.content), - ])); - } - - eprintln!(); - table.printstd(); - eprintln!(); - } - } - DomainCommands::List { from, limit } => { - let query = if from.is_none() && limit.is_none() { - Cow::Borrowed("/api/domain") - } else { - let mut query = "/api/domain?".to_string(); - if let Some(from) = &from { - query.push_str(&format!("from={from}")); - } - if let Some(limit) = limit { - query.push_str(&format!( - "{}limit={limit}", - if from.is_some() { "&" } else { "" } - )); - } - Cow::Owned(query) - }; - - let domains = client - .http_request::, String>(Method::GET, query.as_ref(), None) - .await; - if !domains.items.is_empty() { - let mut table = Table::new(); - table.add_row(Row::new(vec![ - Cell::new("Domain Name").with_style(Attr::Bold), - ])); - - for domain in &domains.items { - table.add_row(Row::new(vec![Cell::new(domain)])); - } - - eprintln!(); - table.printstd(); - eprintln!(); - } - - eprintln!( - "\n\n{} domain{} found.\n", - domains.total, - if domains.total == 1 { "" } else { "s" } - ); - } - } - } -} diff --git a/crates/cli/src/modules/export.rs b/crates/cli/src/modules/export.rs deleted file mode 100644 index 49cab451..00000000 --- a/crates/cli/src/modules/export.rs +++ /dev/null @@ -1,449 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; - -use futures::{StreamExt, stream::FuturesUnordered}; -use jmap_client::{ - email::{self, Email}, - identity::{self, Identity}, - mailbox::{self, Mailbox}, - sieve::{self, SieveScript}, - vacation_response::{self, VacationResponse}, -}; -use serde::Serialize; -use tokio::io::AsyncWriteExt; - -use crate::modules::RETRY_ATTEMPTS; - -use super::{ - UnwrapResult, - cli::{Client, ExportCommands}, - name_to_id, -}; - -impl ExportCommands { - pub async fn exec(self, client: Client) { - let mut client = client.into_jmap_client().await; - match self { - ExportCommands::Account { - num_concurrent, - account, - path, - } => { - client.set_default_account_id(name_to_id(&client, &account).await); - let max_objects_in_get = client - .session() - .core_capabilities() - .map(|c| c.max_objects_in_get()) - .unwrap_or(500); - - // Create directory - let mut path = PathBuf::from(path); - if !path.is_dir() { - eprintln!("Directory {} does not exist.", path.display()); - std::process::exit(1); - } - path.push(&account); - if !path.is_dir() { - std::fs::create_dir(&path).unwrap_or_else(|_| { - eprintln!("Failed to create directory: {}", path.display()); - std::process::exit(1); - }); - } - - // Export metadata - let mut blobs = Vec::new(); - export_mailboxes(&client, max_objects_in_get, &path).await; - export_emails(&client, max_objects_in_get, &mut blobs, &path).await; - export_sieve_scripts(&client, max_objects_in_get, &mut blobs, &path).await; - export_identities(&client, &path).await; - export_vacation_responses(&client, &path).await; - - // Export blobs - path.push("blobs"); - if !path.exists() { - std::fs::create_dir(&path).unwrap_or_else(|_| { - eprintln!("Failed to create directory: {}", path.display()); - std::process::exit(1); - }); - } - let client = Arc::new(client); - let num_concurrent = num_concurrent.unwrap_or_else(num_cpus::get); - let mut futures = FuturesUnordered::new(); - eprintln!("Exporting {} blobs...", blobs.len()); - for blob_id in blobs { - let client = client.clone(); - let mut blob_path = path.clone(); - blob_path.push(&blob_id); - - if tokio::fs::metadata(&blob_path).await.is_err() { - futures.push(async move { - let mut retry_count = 0; - - let bytes = loop { - match client.download(&blob_id).await { - Ok(bytes) => break bytes, - Err(_) if retry_count < RETRY_ATTEMPTS => { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - retry_count += 1; - } - result => { - result.unwrap_result("download blob"); - return; - } - } - }; - - tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&blob_path) - .await - .unwrap_result(&format!("open {}", blob_path.display())) - .write_all(&bytes) - .await - .unwrap_result(&format!("write {}", blob_path.display())); - }); - } - - if futures.len() == num_concurrent { - futures.next().await.unwrap(); - } - } - - // Wait for remaining futures - while futures.next().await.is_some() {} - } - } - } -} - -pub async fn fetch_mailboxes( - client: &jmap_client::client::Client, - max_objects_in_get: usize, -) -> Vec { - let mut position = 0; - let mut results = Vec::new(); - loop { - let mut request = client.build(); - let query_result = request - .query_mailbox() - .calculate_total(true) - .position(position) - .limit(max_objects_in_get) - .result_reference(); - request.get_mailbox().ids_ref(query_result).properties([ - mailbox::Property::Id, - mailbox::Property::Name, - mailbox::Property::IsSubscribed, - mailbox::Property::ParentId, - mailbox::Property::Role, - mailbox::Property::SortOrder, - mailbox::Property::ShareWith, - ]); - - let mut response = request - .send() - .await - .unwrap_result("send JMAP request") - .unwrap_method_responses(); - if response.len() != 2 { - eprintln!("Invalid response while fetching mailboxes"); - std::process::exit(1); - } - let mut get_response = response - .pop() - .unwrap() - .unwrap_get_mailbox() - .unwrap_result("fetch mailboxes"); - let mailboxes_part = get_response.take_list(); - let total_mailboxes = response - .pop() - .unwrap() - .unwrap_query_mailbox() - .unwrap_result("query mailboxes") - .total() - .unwrap_or(0); - - let mailboxes_part_len = mailboxes_part.len(); - if mailboxes_part_len > 0 { - results.extend(mailboxes_part); - if results.len() < total_mailboxes { - position += mailboxes_part_len as i32; - continue; - } - } - break; - } - results -} - -async fn export_mailboxes( - client: &jmap_client::client::Client, - max_objects_in_get: usize, - path: &Path, -) { - eprintln!( - "Exported {} mailboxes.", - write_file( - path, - "mailboxes.json", - fetch_mailboxes(client, max_objects_in_get).await, - ) - .await - ); -} - -pub async fn fetch_emails( - client: &jmap_client::client::Client, - max_objects_in_get: usize, -) -> Vec { - let mut position = 0; - let mut results = Vec::new(); - - loop { - let mut request = client.build(); - let query_result = request - .query_email() - .calculate_total(true) - .position(position) - .limit(max_objects_in_get) - .result_reference(); - request.get_email().ids_ref(query_result).properties([ - email::Property::Id, - email::Property::MailboxIds, - email::Property::Keywords, - email::Property::ReceivedAt, - email::Property::BlobId, - email::Property::MessageId, - ]); - - let mut response = request - .send() - .await - .unwrap_result("send JMAP request") - .unwrap_method_responses(); - if response.len() != 2 { - eprintln!("Invalid response while fetching emails"); - std::process::exit(1); - } - let mut get_response = response - .pop() - .unwrap() - .unwrap_get_email() - .unwrap_result("fetch emails"); - let emails_part = get_response.take_list(); - let total_emails = response - .pop() - .unwrap() - .unwrap_query_email() - .unwrap_result("query emails") - .total() - .unwrap_or(0); - - let emails_part_len = emails_part.len(); - if emails_part_len > 0 { - results.extend(emails_part); - if results.len() < total_emails { - position += emails_part_len as i32; - continue; - } - } - break; - } - - results -} - -async fn export_emails( - client: &jmap_client::client::Client, - max_objects_in_get: usize, - blobs: &mut Vec, - path: &Path, -) { - let emails = fetch_emails(client, max_objects_in_get).await; - - for email in &emails { - if let Some(blob_id) = email.blob_id() { - blobs.push(blob_id.to_string()); - } else { - eprintln!( - "Warning: email {:?} has no blobId", - email.id().unwrap_or_default() - ); - } - } - - eprintln!( - "Exported {} emails.", - write_file(path, "emails.json", emails,).await - ); -} - -pub async fn fetch_sieve_scripts( - client: &jmap_client::client::Client, - max_objects_in_get: usize, -) -> Vec { - let mut position = 0; - let mut results = Vec::new(); - - loop { - let mut request = client.build(); - let query_result = request - .query_sieve_script() - .calculate_total(true) - .position(position) - .limit(max_objects_in_get) - .result_reference(); - request - .get_sieve_script() - .ids_ref(query_result) - .properties([ - sieve::Property::Id, - sieve::Property::Name, - sieve::Property::BlobId, - sieve::Property::IsActive, - ]); - - let mut response = request - .send() - .await - .unwrap_result("send JMAP request") - .unwrap_method_responses(); - if response.len() != 2 { - eprintln!("Invalid response while fetching sieve_scripts"); - std::process::exit(1); - } - let mut get_response = response - .pop() - .unwrap() - .unwrap_get_sieve_script() - .unwrap_result("fetch sieve_scripts"); - let sieve_scripts_part = get_response.take_list(); - let total_sieve_scripts = response - .pop() - .unwrap() - .unwrap_query_sieve_script() - .unwrap_result("query sieve_scripts") - .total() - .unwrap_or(0); - - let sieve_scripts_part_len = sieve_scripts_part.len(); - if sieve_scripts_part_len > 0 { - results.extend(sieve_scripts_part); - - if results.len() < total_sieve_scripts { - position += sieve_scripts_part_len as i32; - continue; - } - } - break; - } - results -} - -async fn export_sieve_scripts( - client: &jmap_client::client::Client, - max_objects_in_get: usize, - blobs: &mut Vec, - path: &Path, -) { - let sieves = fetch_sieve_scripts(client, max_objects_in_get).await; - for sieve in &sieves { - if let Some(blob_id) = sieve.blob_id() { - blobs.push(blob_id.to_string()); - } else { - eprintln!( - "Warning: sieve script {:?} has no blobId", - sieve.id().unwrap_or_default() - ); - } - } - - eprintln!( - "Exported {} sieve scripts.", - write_file(path, "sieve.json", sieves,).await - ); -} - -pub async fn fetch_identities(client: &jmap_client::client::Client) -> Vec { - let mut request = client.build(); - request.get_identity().properties([ - identity::Property::Id, - identity::Property::Name, - identity::Property::Email, - identity::Property::ReplyTo, - identity::Property::Bcc, - identity::Property::TextSignature, - identity::Property::HtmlSignature, - ]); - request - .send_get_identity() - .await - .unwrap_result("send JMAP request") - .take_list() -} - -async fn export_identities(client: &jmap_client::client::Client, path: &Path) { - eprintln!( - "Exported {} identities.", - write_file(path, "identities.json", fetch_identities(client).await).await - ); -} - -pub async fn fetch_vacation_responses( - client: &jmap_client::client::Client, -) -> Vec { - let mut request = client.build(); - request.get_vacation_response().properties([ - vacation_response::Property::Id, - vacation_response::Property::FromDate, - vacation_response::Property::ToDate, - vacation_response::Property::Subject, - vacation_response::Property::TextBody, - vacation_response::Property::HtmlBody, - vacation_response::Property::IsEnabled, - ]); - request - .send_get_vacation_response() - .await - .unwrap_result("send JMAP request") - .take_list() -} - -async fn export_vacation_responses(client: &jmap_client::client::Client, path: &Path) { - eprintln!( - "Exported {} vacation responses.", - write_file( - path, - "vacation.json", - fetch_vacation_responses(client).await - ) - .await - ); -} - -async fn write_file(path: &Path, name: &str, contents: Vec) -> usize { - let mut path = PathBuf::from(path); - path.push(name); - let len = contents.len(); - tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&path) - .await - .unwrap_result(&format!("open {}", path.display())) - .write_all(serde_json::to_string(&contents).unwrap().as_bytes()) - .await - .unwrap_result(&format!("write to {}", path.display())); - len -} diff --git a/crates/cli/src/modules/group.rs b/crates/cli/src/modules/group.rs deleted file mode 100644 index afc0b4c4..00000000 --- a/crates/cli/src/modules/group.rs +++ /dev/null @@ -1,152 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::vec; - -use reqwest::Method; -use serde_json::Value; - -use crate::modules::{Principal, Type}; - -use super::{ - PrincipalField, PrincipalUpdate, PrincipalValue, - cli::{Client, GroupCommands}, -}; - -impl GroupCommands { - pub async fn exec(self, client: Client) { - match self { - GroupCommands::Create { - name, - email, - description, - members, - } => { - let principal = Principal { - typ: Some(Type::Group), - name: name.clone().into(), - emails: email.map(|e| vec![e]).unwrap_or_default(), - description, - ..Default::default() - }; - let account_id = client - .http_request::(Method::POST, "/api/principal", Some(principal)) - .await; - if let Some(members) = members { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some(vec![PrincipalUpdate::set( - PrincipalField::Members, - PrincipalValue::StringList(members), - )]), - ) - .await; - } - eprintln!("Successfully created group {name:?} with id {account_id}."); - } - GroupCommands::Update { - name, - new_name, - email, - description, - members, - } => { - let mut changes = Vec::new(); - if let Some(new_name) = new_name { - changes.push(PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String(new_name), - )); - } - if let Some(email) = email { - changes.push(PrincipalUpdate::set( - PrincipalField::Emails, - PrincipalValue::StringList(vec![email]), - )); - } - if let Some(members) = members { - changes.push(PrincipalUpdate::set( - PrincipalField::Members, - PrincipalValue::StringList(members), - )); - } - if let Some(description) = description { - changes.push(PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String(description), - )); - } - - if !changes.is_empty() { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some(changes), - ) - .await; - eprintln!("Successfully updated group {name:?}."); - } else { - eprintln!("No changes to apply."); - } - } - GroupCommands::AddMembers { name, members } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - members - .into_iter() - .map(|group| { - PrincipalUpdate::add_item( - PrincipalField::Members, - PrincipalValue::String(group), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated group {name:?}."); - } - GroupCommands::RemoveMembers { name, members } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - members - .into_iter() - .map(|group| { - PrincipalUpdate::remove_item( - PrincipalField::Members, - PrincipalValue::String(group), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated group {name:?}."); - } - GroupCommands::Display { name } => { - client.display_principal(&name).await; - } - GroupCommands::List { - filter, - limit, - page, - } => { - client - .list_principals("group", "Group", filter, page, limit) - .await; - } - } - } -} diff --git a/crates/cli/src/modules/import.rs b/crates/cli/src/modules/import.rs deleted file mode 100644 index 6f338ab8..00000000 --- a/crates/cli/src/modules/import.rs +++ /dev/null @@ -1,1039 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::{ - collections::{HashMap, HashSet}, - io::{self, Cursor}, - path::{Path, PathBuf}, - sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, -}; - -use console::style; -use futures::{StreamExt, stream::FuturesUnordered}; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use jmap_client::{ - core::set::SetObject, - mailbox::{self, Role}, -}; -use mail_parser::mailbox::{ - maildir, - mbox::{self, MessageIterator}, -}; -use rand::Rng; -use serde::de::DeserializeOwned; -use tokio::{fs::File, io::AsyncReadExt}; - -use crate::modules::{RETRY_ATTEMPTS, UnwrapResult, name_to_id}; - -use super::{ - cli::{Client, ImportCommands, MailboxFormat}, - export::{ - fetch_emails, fetch_identities, fetch_mailboxes, fetch_sieve_scripts, - fetch_vacation_responses, - }, - read_file, -}; - -enum Mailbox { - Mbox(mbox::MessageIterator>>), - Maildir(maildir::MessageIterator), - None, -} - -#[derive(Debug)] -enum MailboxId<'x> { - ExistingId(&'x str), - CreateId(String), - None, -} - -#[derive(Debug)] -struct Message { - identifier: String, - flags: Vec, - internal_date: u64, - contents: Vec, -} -impl ImportCommands { - pub async fn exec(self, client: Client) { - let mut client = client.into_jmap_client().await; - - match self { - ImportCommands::Messages { - num_concurrent, - format, - account, - path, - } => { - client.set_default_account_id(name_to_id(&client, &account).await); - let mut create_mailboxes = Vec::new(); - let mut create_mailbox_names = Vec::new(); - let mut create_mailbox_ids = Vec::new(); - - eprintln!("{} Parsing mailbox...", style("[1/4]").bold().dim(),); - - match format { - MailboxFormat::Mbox => { - create_mailbox_names.push(Vec::new()); - create_mailboxes.push(Mailbox::Mbox(MessageIterator::new(Cursor::new( - read_file(&path), - )))); - } - MailboxFormat::Maildir | MailboxFormat::MaildirNested => { - let (folder_sep, folder_split) = if format == MailboxFormat::Maildir { - (Some("."), ".") - } else { - (None, "/") - }; - - for folder in maildir::FolderIterator::new(path, folder_sep) - .unwrap_result("read Maildir folder") - { - let folder = folder.unwrap_result("read Maildir folder"); - if let Some(folder_name) = folder.name() { - let mut folder_parts = Vec::new(); - for folder_name in folder_name.split(folder_split) { - let mut folder_name = folder_name.trim(); - if folder_name.is_empty() { - folder_name = "."; - } - folder_parts.push(folder_name.to_string()); - if !create_mailbox_names.contains(&folder_parts) { - create_mailboxes.push(Mailbox::None); - create_mailbox_names.push(folder_parts.clone()); - } - } - - *create_mailboxes.last_mut().unwrap() = Mailbox::Maildir(folder); - } else { - create_mailboxes.push(Mailbox::Maildir(folder)); - create_mailbox_names.push(Vec::new()); - }; - } - } - } - - // Fetch all mailboxes for the account - eprintln!( - "{} Fetching existing mailboxes for account...", - style("[2/4]").bold().dim(), - ); - - let mut inbox_id = None; - let mut mailbox_ids = HashMap::new(); - let mut children: HashMap, Vec<&str>> = - HashMap::from_iter([(None, Vec::new())]); - let mut request = client.build(); - request.get_mailbox().properties([ - mailbox::Property::Name, - mailbox::Property::ParentId, - mailbox::Property::Role, - mailbox::Property::Id, - ]); - let response = request - .send_get_mailbox() - .await - .unwrap_result("fetch mailboxes"); - for mailbox in response.list() { - let mailbox_id = mailbox.id().unwrap(); - if mailbox.role() == Role::Inbox { - inbox_id = mailbox_id.into(); - } - children - .entry(mailbox.parent_id()) - .or_default() - .push(mailbox_id); - mailbox_ids.insert(mailbox_id, mailbox.name().unwrap_or("Untitled")); - } - let inbox_id = inbox_id - .unwrap_result("locate Inbox on account, please check the server logs."); - let mut it = children.get(&None).unwrap().iter(); - let mut it_stack = Vec::new(); - let mut name_stack = Vec::new(); - let mut mailbox_names = HashMap::with_capacity(mailbox_ids.len()); - - // Build mailbox hierarchy on the server - eprintln!( - "{} Creating missing mailboxes...", - style("[3/4]").bold().dim(), - ); - - loop { - while let Some(mailbox_id) = it.next() { - let name = mailbox_ids[mailbox_id]; - let mut mailbox_name = name_stack.clone(); - mailbox_name.push(name.to_string()); - - mailbox_names.insert(mailbox_name, mailbox_id); - if let Some(next_it) = children.get(&Some(mailbox_id)).map(|c| c.iter()) { - name_stack.push(name.to_string()); - it_stack.push(it); - it = next_it; - } - } - - if let Some(prev_it) = it_stack.pop() { - name_stack.pop(); - it = prev_it; - } else { - break; - } - } - - // Check whether the mailboxes to be created already exist - let mut has_missing_mailboxes = false; - for mailbox_name in &create_mailbox_names { - create_mailbox_ids.push(if !mailbox_name.is_empty() { - if let Some(mailbox_id) = mailbox_names.get(mailbox_name) { - MailboxId::ExistingId(mailbox_id) - } else { - has_missing_mailboxes = true; - MailboxId::None - } - } else { - MailboxId::ExistingId(inbox_id) - }); - } - - // Create any missing mailboxes - if has_missing_mailboxes { - let mut request = client.build(); - let set_request = request.set_mailbox(); - - for pos in 0..create_mailbox_ids.len() { - if let MailboxId::None = create_mailbox_ids[pos] { - let mailbox_name = &create_mailbox_names[pos]; - let create_request = - set_request.create().name(mailbox_name.last().unwrap()); - - if mailbox_name.len() > 1 { - let parent_mailbox_name = &mailbox_name[..mailbox_name.len() - 1]; - let parent_mailbox_pos = create_mailbox_names - .iter() - .position(|n| n == parent_mailbox_name) - .unwrap(); - match &create_mailbox_ids[parent_mailbox_pos] { - MailboxId::ExistingId(id) => { - create_request.parent_id((*id).into()); - } - MailboxId::CreateId(id_ref) => { - create_request.parent_id_ref(id_ref); - } - MailboxId::None => unreachable!(), - } - } else { - create_request.parent_id(None::); - } - create_mailbox_ids[pos] = - MailboxId::CreateId(create_request.create_id().unwrap()); - } - } - - // Create mailboxes - let mut response = request - .send_set_mailbox() - .await - .unwrap_result("create mailboxes"); - for create_mailbox_id in create_mailbox_ids.iter_mut() { - if let MailboxId::CreateId(id) = create_mailbox_id { - *id = response - .created(id) - .unwrap_result("create mailbox") - .take_id(); - } - } - } - - // Import messages - eprintln!("{} Importing messages...", style("[4/4]").bold().dim(),); - - let client = Arc::new(client); - let total_imported = Arc::new(AtomicUsize::from(0)); - let m = MultiProgress::new(); - let num_concurrent = num_concurrent.unwrap_or_else(num_cpus::get); - let spinner_style = - ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}") - .unwrap() - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "); - let pbs = Arc::new(Mutex::new(( - (0..num_concurrent) - .map(|n| { - let pb = m.add(ProgressBar::new(40)); - pb.set_style(spinner_style.clone()); - pb.set_prefix(format!("[{}/?]", n + 1)); - pb - }) - .collect::>(), - 0usize, - ))); - let failures = Arc::new(Mutex::new(Vec::new())); - let mut message_num = 0; - - for ((mut mailbox, mailbox_id), mailbox_name) in create_mailboxes - .into_iter() - .zip(create_mailbox_ids) - .zip(create_mailbox_names) - { - let mut futures = FuturesUnordered::new(); - let mailbox_id = Arc::new(match mailbox_id { - MailboxId::ExistingId(id) => id.to_string(), - MailboxId::CreateId(id) => id, - MailboxId::None => unreachable!(), - }); - let mailbox_name = Arc::new(if !mailbox_name.is_empty() { - mailbox_name.join("/") - } else { - "Inbox".to_string() - }); - - for result in mailbox.by_ref() { - match result { - Ok(message) => { - message_num += 1; - let client = client.clone(); - let mailbox_id = mailbox_id.clone(); - let mailbox_name = mailbox_name.clone(); - let total_imported = total_imported.clone(); - let pbs = pbs.clone(); - let failures = failures.clone(); - - futures.push(async move { - // Update progress bar - { - let mut pbs = pbs.lock().unwrap(); - let pb = &pbs.0[pbs.1 % pbs.0.len()]; - pb.set_message(format!( - "Importing {}: {}/{}", - message_num, mailbox_name, message.identifier - )); - pb.inc(1); - pbs.1 += 1; - } - - let mut retry_count = 0; - loop { - // Sanitize message - let mut contents = - Vec::with_capacity(message.contents.len()); - let mut last_ch = 0; - for &ch in message.contents.iter() { - if ch == b'\n' && last_ch != b'\r' { - contents.push(b'\r'); - } - contents.push(ch); - last_ch = ch; - } - - match client - .email_import( - contents, - [mailbox_id.as_ref()], - if !message.flags.is_empty() { - message - .flags - .iter() - .map(|f| match f { - maildir::Flag::Passed => "$passed", - maildir::Flag::Replied => "$answered", - maildir::Flag::Seen => "$seen", - maildir::Flag::Trashed => "$deleted", - maildir::Flag::Draft => "$draft", - maildir::Flag::Flagged => "$flagged", - }) - .into() - } else { - None - }, - if message.internal_date > 0 { - (message.internal_date as i64).into() - } else { - None - }, - ) - .await - { - Ok(_) => { - total_imported.fetch_add(1, Ordering::Relaxed); - } - Err(_) if retry_count < RETRY_ATTEMPTS => { - let backoff = rand::rng().random_range(50..=300); - tokio::time::sleep(Duration::from_millis(backoff)) - .await; - retry_count += 1; - continue; - } - Err(err) => { - failures.lock().unwrap().push(format!( - concat!( - "Failed to import message {} ", - "with identifier '{}': {}" - ), - message_num, message.identifier, err - )); - } - } - break; - } - }); - - if futures.len() == num_concurrent { - futures.next().await.unwrap(); - } - } - Err(e) => { - failures - .lock() - .unwrap() - .push(format!("I/O error reading message: {}", e)); - } - } - } - - // Wait for remaining futures - while futures.next().await.is_some() {} - } - - // Done - for pb in pbs.lock().unwrap().0.iter() { - pb.finish_with_message("Done"); - } - let failures = failures.lock().unwrap(); - eprintln!( - "\n\nSuccessfully imported {} messages.\n", - total_imported.load(Ordering::Relaxed) - ); - - if !failures.is_empty() { - eprintln!("There were {} failures:\n", failures.len()); - for failure in failures.iter() { - eprintln!("{}", failure); - } - } - } - - ImportCommands::Account { - num_concurrent, - account, - path, - } => { - client.set_default_account_id(name_to_id(&client, &account).await); - let path = PathBuf::from(path); - if !path.exists() { - eprintln!("Path '{}' does not exist.", path.display()); - return; - } - let num_concurrent = num_concurrent.unwrap_or_else(num_cpus::get); - - // Import objects - import_emails( - &client, - &path, - import_mailboxes(&client, &path).await.into(), - num_concurrent, - ) - .await; - import_sieve_scripts(&client, &path, num_concurrent).await; - import_identities(&client, &path).await; - import_vacation_responses(&client, &path).await; - } - } - } -} - -async fn import_mailboxes( - client: &jmap_client::client::Client, - path: &Path, -) -> HashMap { - // Deserialize mailboxes - let mailboxes = read_json::(path, "mailboxes.json").await; - if mailboxes.is_empty() { - return HashMap::new(); - } - - // Obtain current mailboxes - let existing_mailboxes = fetch_mailboxes( - client, - client - .session() - .core_capabilities() - .map(|c| c.max_objects_in_get()) - .unwrap_or(500), - ) - .await; - let nested_existing_mailboxes = build_mailbox_tree(&existing_mailboxes); - let mut id_mappings: HashMap = HashMap::new(); - let mut id_missing = Vec::new(); - for (path, mailbox) in build_mailbox_tree(&mailboxes) { - let id = mailbox.id().unwrap_result("obtain mailbox id"); - // Find existing mailbox based on role - if !matches!(mailbox.role(), Role::None) - && let Some(existing_mailbox) = existing_mailboxes - .iter() - .find(|m| m.role() == mailbox.role()) - { - id_mappings.insert( - id.to_string(), - existing_mailbox - .id() - .unwrap_result("obtain mailbox id") - .to_string(), - ); - continue; - } - - // Find existing mailbox by name - if let Some(mailbox) = nested_existing_mailboxes.get(&path) { - id_mappings.insert( - id.to_string(), - mailbox.id().unwrap_result("obtain mailbox id").to_string(), - ); - } else { - id_missing.push(id); - } - } - let mut total_imported = 0; - let mut total_existing = 0; - if !id_missing.is_empty() { - let mut request = client.build(); - let set_request = request.set_mailbox(); - - for mailbox in &mailboxes { - // Skip if mailbox already exists - let id = mailbox.id().unwrap_result("obtain mailbox id").to_string(); - if id_mappings.contains_key(&id) { - total_existing += 1; - continue; - } - let create_request = set_request - .create_with_id(&id) - .name(mailbox.name().unwrap()) - .role(mailbox.role()); - if let Some(parent_id) = mailbox.parent_id() { - if let Some(existing_id) = id_mappings.get(parent_id) { - create_request.parent_id(Some(existing_id.to_string())); - } else { - create_request.parent_id_ref(parent_id); - } - } else { - create_request.parent_id(None::); - } - if mailbox.sort_order() > 0 { - create_request.sort_order(mailbox.sort_order()); - } - /*if let Some(acls) = mailbox.acl() { - create_request.acls(acls.clone().into_iter()); - }*/ - if mailbox.is_subscribed() { - create_request.is_subscribed(true); - } - } - - // Create mailboxes - let mut response = request - .send_set_mailbox() - .await - .unwrap_result("create mailboxes"); - for missing_id in id_missing { - id_mappings.insert( - missing_id.to_string(), - response - .created(missing_id) - .unwrap_result("create mailbox") - .take_id(), - ); - total_imported += 1; - } - } else { - total_existing = mailboxes.len(); - } - - eprintln!( - "Successfully processed {} mailboxes ({} imported, {} already exist).", - total_existing + total_imported, - total_imported, - total_existing - ); - - id_mappings -} - -async fn import_emails( - client: &jmap_client::client::Client, - path: &Path, - mailbox_ids: Arc>, - num_concurrent: usize, -) { - // Deserialize emails - let emails = read_json::(path, "emails.json").await; - if emails.is_empty() { - return; - } - - // Obtain existing emails - let existing_emails = fetch_emails( - client, - client - .session() - .core_capabilities() - .map(|c| c.max_objects_in_get()) - .unwrap_or(500), - ) - .await; - let existing_ids = existing_emails - .iter() - .map(|email| (email.message_id(), email.received_at())) - .collect::>(); - let mut futures = FuturesUnordered::new(); - let total_imported = Arc::new(AtomicUsize::from(0)); - let mut total_existing = 0; - let mut path = PathBuf::from(path); - path.push("blobs"); - - for email in emails { - // Skip messages that already exist in the server - if existing_ids.contains(&(email.message_id(), email.received_at())) { - total_existing += 1; - continue; - } - - // Spawn import tasks - let mailbox_ids = mailbox_ids.clone(); - let mut path = path.clone(); - let total_imported = total_imported.clone(); - - futures.push(async move { - // Obtain mailbox ids - let id = if let Some(id) = email.id() { - id - } else { - eprintln!("Skipping email with no id"); - return; - }; - if email.mailbox_ids().is_empty() { - eprintln!("Skipping emailId {id} with no mailboxIds"); - return; - } - let mut mailboxes = Vec::with_capacity(email.mailbox_ids().len()); - for mailbox_id in email.mailbox_ids() { - if let Some(mailbox_id) = mailbox_ids.get(mailbox_id) { - mailboxes.push(mailbox_id.to_string()); - } else { - eprintln!("Skipping emailId {id} with unknown mailboxId {mailbox_id}"); - return; - } - } - let keywords = email.keywords(); - - // Read blob - if let Some(blob_id) = email.blob_id() { - path.push(blob_id); - } else { - eprintln!("Skipping emailId {id} with no blobId"); - return; - } - let mut contents = vec![]; - match File::open(&path).await { - Ok(mut file) => match file.read_to_end(&mut contents).await { - Ok(_) => {} - Err(err) => { - eprintln!( - "Failed to read blob file for emailId {id} at {path:?}: {err}", - id = id, - path = path, - err = err - ); - return; - } - }, - Err(err) => { - eprintln!( - "Failed to open blob file for emailId {id} at {path:?}: {err}", - id = id, - path = path, - err = err - ); - return; - } - } - - let mut retry_count = 0; - loop { - match client - .email_import( - contents.clone(), - mailboxes.clone(), - if !keywords.is_empty() { - Some(keywords.clone()) - } else { - None - }, - email.received_at(), - ) - .await - { - Ok(_) => { - total_imported.fetch_add(1, Ordering::Relaxed); - } - Err(_) if retry_count < RETRY_ATTEMPTS => { - retry_count += 1; - continue; - } - Err(err) => { - eprintln!("Failed to import emailId {id}: {err}"); - } - } - break; - } - }); - - if futures.len() == num_concurrent { - futures.next().await.unwrap(); - } - } - - // Wait for remaining futures - while futures.next().await.is_some() {} - - // Done - eprintln!( - "Successfully processed {} emails ({} imported, {} already exist).", - total_imported.load(Ordering::Relaxed) + total_existing, - total_imported.load(Ordering::Relaxed), - total_existing - ); -} - -async fn import_sieve_scripts( - client: &jmap_client::client::Client, - path: &Path, - num_concurrent: usize, -) { - // Deserialize scripts - let scripts = read_json::(path, "sieve.json").await; - if scripts.is_empty() { - return; - } - let existing_scripts = fetch_sieve_scripts( - client, - client - .session() - .core_capabilities() - .map(|c| c.max_objects_in_get()) - .unwrap_or(500), - ) - .await; - let mut path = PathBuf::from(path); - path.push("blobs"); - - // Spawn tasks - let mut futures = FuturesUnordered::new(); - let total_imported = Arc::new(AtomicUsize::from(0)); - let mut total_existing = 0; - - 'outer: for script in scripts { - // Skip scripts that already exist - for existing_script in &existing_scripts { - if existing_script.name() == script.name() { - total_existing += 1; - continue 'outer; - } - } - let mut path = path.clone(); - let total_imported = total_imported.clone(); - - futures.push(async move { - let id = if let Some(id) = script.id() { - id - } else { - eprintln!("Skipping script with no id."); - return; - }; - - // Read blob - let name = if let (Some(blob_id), Some(name)) = (script.blob_id(), script.name()) { - path.push(blob_id); - name - } else { - eprintln!("Skipping script {id} with no blobId and/or name"); - return; - }; - let mut contents = vec![]; - match File::open(&path).await { - Ok(mut file) => match file.read_to_end(&mut contents).await { - Ok(_) => {} - Err(err) => { - eprintln!( - "Failed to read blob file for script {id} at {path:?}: {err}", - id = id, - path = path, - err = err - ); - return; - } - }, - Err(err) => { - eprintln!( - "Failed to open blob file for script {id} at {path:?}: {err}", - id = id, - path = path, - err = err - ); - return; - } - } - - // Upload blob - match client - .sieve_script_create(name, contents, script.is_active()) - .await - { - Ok(_) => { - total_imported.fetch_add(1, Ordering::Relaxed); - } - Err(err) => { - eprintln!("Failed to import script {id}: {err}"); - } - } - }); - - if futures.len() == num_concurrent { - futures.next().await.unwrap(); - } - } - - // Wait for remaining futures - while futures.next().await.is_some() {} - - // Done - eprintln!( - "Successfully processed {} sieve scripts ({} imported, {} already exist).", - total_imported.load(Ordering::Relaxed) + total_existing, - total_imported.load(Ordering::Relaxed), - total_existing - ); -} - -async fn import_identities(client: &jmap_client::client::Client, path: &Path) { - // Deserialize mailboxes - let identities = read_json::(path, "identities.json").await; - if identities.is_empty() { - return; - } - let existing_identities = fetch_identities(client).await; - let mut request = client.build(); - let set_request = request.set_identity(); - let mut create_ids = Vec::new(); - let mut total_existing = 0; - - 'outer: for identity in &identities { - for existing_identity in &existing_identities { - if identity.name() == existing_identity.name() - && identity.email() == existing_identity.email() - { - total_existing += 1; - continue 'outer; - } - } - - if let (Some(id), Some(name), Some(email)) = - (identity.id(), identity.name(), identity.email()) - { - if name != "vacation" { - create_ids.push(id); - let create_request = set_request.create_with_id(id).name(name).email(email); - if let Some(reply_to) = identity.reply_to() { - create_request.reply_to(reply_to.iter().cloned().into()); - } - if let Some(bcc) = identity.bcc() { - create_request.bcc(bcc.iter().cloned().into()); - } - if let Some(html_signature) = identity.html_signature() { - create_request.html_signature(html_signature); - } - if let Some(text_signature) = identity.text_signature() { - create_request.text_signature(text_signature); - } - } - } else { - eprintln!("Skipping identity with no id, name, and/or email."); - continue; - } - } - - let mut total_imported = 0; - if !create_ids.is_empty() { - match request.send_set_identity().await { - Ok(mut response) => { - for id in create_ids { - if let Err(err) = response.created(id) { - eprintln!("Failed to import identity {id}: {err}"); - } else { - total_imported += 1; - } - } - } - Err(err) => { - eprintln!("Failed to import identities: {err}"); - return; - } - } - } - - eprintln!( - "Successfully processed {} identities ({} imported, {} already exist).", - total_imported + total_existing, - total_imported, - total_existing - ); -} - -async fn import_vacation_responses(client: &jmap_client::client::Client, path: &Path) { - // Deserialize mailboxes - let vacation_responses = - read_json::(path, "vacation.json").await; - if vacation_responses.is_empty() { - return; - } - let existing_vacation_responses = fetch_vacation_responses(client).await; - if !existing_vacation_responses.is_empty() { - eprintln!("Successfully processed 1 vacation response (0 imported, 1 already exist).",); - return; - } - - let vacation_response = vacation_responses.into_iter().next().unwrap(); - let mut request = client.build(); - let set_request = request.set_vacation_response().create(); - - if vacation_response.is_enabled() { - set_request.is_enabled(true); - } - if let Some(from_date) = vacation_response.from_date() { - set_request.from_date(from_date.into()); - } - if let Some(to_date) = vacation_response.to_date() { - set_request.to_date(to_date.into()); - } - if let Some(subject) = vacation_response.subject() { - set_request.subject(subject.into()); - } - if let Some(text_body) = vacation_response.text_body() { - set_request.text_body(text_body.into()); - } - if let Some(html_body) = vacation_response.html_body() { - set_request.html_body(html_body.into()); - } - let create_id = set_request.create_id().unwrap(); - - match request.send_set_vacation_response().await { - Ok(mut response) => { - if let Err(err) = response.created(&create_id) { - eprintln!("Failed to import vacation response: {err}"); - } else { - eprintln!( - "Successfully processed 1 vacation response (1 imported, 0 already exist).", - ); - } - } - Err(err) => { - eprintln!("Failed to import vacation response: {err}"); - } - } -} - -fn build_mailbox_tree( - mailboxes: &[jmap_client::mailbox::Mailbox], -) -> HashMap, &jmap_client::mailbox::Mailbox> { - let mut path = Vec::new(); - let mut parent_id = None; - let mut mailboxes_iter = mailboxes.iter(); - let mut stack = Vec::new(); - let mut results = HashMap::with_capacity(mailboxes.len()); - let parents = mailboxes - .iter() - .map(|m| m.parent_id()) - .collect::>(); - - 'outer: loop { - while let Some(mailbox) = mailboxes_iter.next() { - if parent_id == mailbox.parent_id() { - let name = mailbox.name().unwrap_result("obtain mailbox name"); - if parents.contains(&mailbox.id()) { - stack.push((path.clone(), parent_id, mailboxes_iter)); - parent_id = mailbox.id(); - path.push(name); - results.insert(path.clone(), mailbox); - mailboxes_iter = mailboxes.iter(); - continue 'outer; - } else { - let mut path = path.clone(); - path.push(name); - results.insert(path, mailbox); - } - } - } - if let Some((prev_path, prev_parent_id, prev_iter)) = stack.pop() { - parent_id = prev_parent_id; - path = prev_path; - mailboxes_iter = prev_iter; - } else { - break; - } - } - debug_assert_eq!(results.len(), mailboxes.len()); - - results -} - -async fn read_json(path: &Path, filename: &str) -> Vec { - let mut path = PathBuf::from(path); - path.push(filename); - if path.exists() { - let mut file = File::open(path).await.unwrap_result("open file"); - let mut contents = String::new(); - file.read_to_string(&mut contents) - .await - .unwrap_result("read file"); - serde_json::from_str(&contents).unwrap_result("parse JSON") - } else { - Vec::new() - } -} - -impl Iterator for Mailbox { - type Item = io::Result; - - fn next(&mut self) -> Option { - match self { - Mailbox::Mbox(it) => it.next().map(|r| { - r.map(|m| Message { - identifier: m.from().to_string(), - flags: Vec::new(), - internal_date: m.internal_date(), - contents: m.unwrap_contents(), - }) - .map_err(|_| std::io::Error::other("Failed to parse from mbox file.")) - }), - Mailbox::Maildir(it) => it.next().map(|r| { - r.map(|m| Message { - identifier: m - .path() - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or("unknown") - .to_string(), - flags: m.flags().to_vec(), - internal_date: m.internal_date(), - contents: m.unwrap_contents(), - }) - }), - Mailbox::None => None, - } - } -} diff --git a/crates/cli/src/modules/list.rs b/crates/cli/src/modules/list.rs deleted file mode 100644 index f8837f3d..00000000 --- a/crates/cli/src/modules/list.rs +++ /dev/null @@ -1,152 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::vec; - -use reqwest::Method; -use serde_json::Value; - -use crate::modules::{Principal, Type}; - -use super::{ - PrincipalField, PrincipalUpdate, PrincipalValue, - cli::{Client, ListCommands}, -}; - -impl ListCommands { - pub async fn exec(self, client: Client) { - match self { - ListCommands::Create { - name, - email, - description, - members, - } => { - let principal = Principal { - typ: Some(Type::List), - name: name.clone().into(), - emails: vec![email], - description, - ..Default::default() - }; - let account_id = client - .http_request::(Method::POST, "/api/principal", Some(principal)) - .await; - if let Some(members) = members { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some(vec![PrincipalUpdate::set( - PrincipalField::Members, - PrincipalValue::StringList(members), - )]), - ) - .await; - } - eprintln!("Successfully created mailing list {name:?} with id {account_id}."); - } - ListCommands::Update { - name, - new_name, - email, - description, - members, - } => { - let mut changes = Vec::new(); - if let Some(new_name) = new_name { - changes.push(PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String(new_name), - )); - } - if let Some(email) = email { - changes.push(PrincipalUpdate::set( - PrincipalField::Emails, - PrincipalValue::StringList(vec![email]), - )); - } - if let Some(members) = members { - changes.push(PrincipalUpdate::set( - PrincipalField::Members, - PrincipalValue::StringList(members), - )); - } - if let Some(description) = description { - changes.push(PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String(description), - )); - } - - if !changes.is_empty() { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some(changes), - ) - .await; - eprintln!("Successfully updated mailing list {name:?}."); - } else { - eprintln!("No changes to apply."); - } - } - ListCommands::AddMembers { name, members } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - members - .into_iter() - .map(|group| { - PrincipalUpdate::add_item( - PrincipalField::Members, - PrincipalValue::String(group), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated mailing list {name:?}."); - } - ListCommands::RemoveMembers { name, members } => { - client - .http_request::( - Method::PATCH, - &format!("/api/principal/{name}"), - Some( - members - .into_iter() - .map(|group| { - PrincipalUpdate::remove_item( - PrincipalField::Members, - PrincipalValue::String(group), - ) - }) - .collect::>(), - ), - ) - .await; - eprintln!("Successfully updated mailing list {name:?}."); - } - ListCommands::Display { name } => { - client.display_principal(&name).await; - } - ListCommands::List { - filter, - limit, - page, - } => { - client - .list_principals("list", "Mailing List", filter, page, limit) - .await; - } - } - } -} diff --git a/crates/cli/src/modules/mod.rs b/crates/cli/src/modules/mod.rs deleted file mode 100644 index 5fba37b6..00000000 --- a/crates/cli/src/modules/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::{collections::HashMap, fmt::Display, io::Read}; - -use jmap_client::{ - client::Client, - principal::query::{self}, -}; -use serde::{Deserialize, Serialize}; - -pub mod account; -pub mod cli; -pub mod database; -pub mod dkim; -pub mod domain; -pub mod export; -pub mod group; -pub mod import; -pub mod list; -pub mod queue; -pub mod report; - -const RETRY_ATTEMPTS: usize = 5; - -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Principal { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - - #[serde(rename = "type")] - pub typ: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quota: Option, - - #[serde(rename = "usedQuota")] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub used_quota: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub secrets: Vec, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub emails: Vec, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[serde(rename = "memberOf")] - pub member_of: Vec, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[serde(rename = "members")] - pub members: Vec, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Type { - #[serde(rename = "individual")] - #[default] - Individual = 0, - #[serde(rename = "group")] - Group = 1, - #[serde(rename = "resource")] - Resource = 2, - #[serde(rename = "location")] - Location = 3, - #[serde(rename = "superuser")] - Superuser = 4, - #[serde(rename = "list")] - List = 5, - #[serde(rename = "other")] - Other = 6, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PrincipalField { - #[serde(rename = "name")] - Name, - #[serde(rename = "type")] - Type, - #[serde(rename = "quota")] - Quota, - #[serde(rename = "description")] - Description, - #[serde(rename = "secrets")] - Secrets, - #[serde(rename = "emails")] - Emails, - #[serde(rename = "memberOf")] - MemberOf, - #[serde(rename = "members")] - Members, -} - -#[derive(Clone, serde::Serialize, serde::Deserialize, Default)] -pub struct List { - pub items: Vec, - pub total: u64, -} - -#[derive(Clone, serde::Serialize, serde::Deserialize, Default)] -pub struct Response { - pub items: T, - pub total: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct PrincipalUpdate { - action: PrincipalAction, - field: PrincipalField, - value: PrincipalValue, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PrincipalAction { - #[serde(rename = "set")] - Set, - #[serde(rename = "addItem")] - AddItem, - #[serde(rename = "removeItem")] - RemoveItem, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(untagged)] -pub enum PrincipalValue { - String(String), - StringList(Vec), - Integer(u64), -} - -impl PrincipalUpdate { - pub fn set(field: PrincipalField, value: PrincipalValue) -> PrincipalUpdate { - PrincipalUpdate { - action: PrincipalAction::Set, - field, - value, - } - } - - pub fn add_item(field: PrincipalField, value: PrincipalValue) -> PrincipalUpdate { - PrincipalUpdate { - action: PrincipalAction::AddItem, - field, - value, - } - } - - pub fn remove_item(field: PrincipalField, value: PrincipalValue) -> PrincipalUpdate { - PrincipalUpdate { - action: PrincipalAction::RemoveItem, - field, - value, - } - } -} - -pub trait UnwrapResult { - fn unwrap_result(self, action: &str) -> T; -} - -impl UnwrapResult for Option { - fn unwrap_result(self, message: &str) -> T { - match self { - Some(result) => result, - None => { - eprintln!("Failed to {}", message); - std::process::exit(1); - } - } - } -} - -impl UnwrapResult for Result { - fn unwrap_result(self, message: &str) -> T { - match self { - Ok(result) => result, - Err(err) => { - eprintln!("Failed to {}: {}", message, err); - std::process::exit(1); - } - } - } -} - -pub fn read_file(path: &str) -> Vec { - if path == "-" { - let mut stdin = std::io::stdin().lock(); - let mut raw_message = Vec::with_capacity(1024); - let mut buf = [0; 1024]; - loop { - let n = stdin.read(&mut buf).unwrap(); - if n == 0 { - break; - } - raw_message.extend_from_slice(&buf[..n]); - } - raw_message - } else { - std::fs::read(path).unwrap_or_else(|_| { - eprintln!("Failed to read file: {}", path); - std::process::exit(1); - }) - } -} - -pub async fn name_to_id(client: &Client, name: &str) -> String { - let filter = if name.contains('@') { - query::Filter::email(name) - } else { - query::Filter::name(name) - }; - let mut response = client - .principal_query(filter.into(), None::>) - .await - .unwrap_result("query principals"); - match response.ids().len() { - 1 => response.take_ids().pop().unwrap(), - 0 => { - eprintln!("Error: No principal found with name '{}'.", name); - std::process::exit(1); - } - _ => { - eprintln!("Error: Multiple principals found with name '{}'.", name); - std::process::exit(1); - } - } -} - -pub fn host(url: &str) -> Option<&str> { - url.split_once("://") - .map(|(_, url)| url.split_once('/').map_or(url, |(host, _)| host)) - .map(|host| host.rsplit_once(':').map_or(host, |(host, _)| host)) -} - -pub fn is_localhost(url: &str) -> bool { - host(url).is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "[::1]") -} - -pub trait OAuthResponse { - fn property(&self, name: &str) -> &str; -} - -impl OAuthResponse for HashMap { - fn property(&self, name: &str) -> &str { - self.get(name) - .unwrap_result(&format!("find '{}' in OAuth response", name)) - .as_str() - .unwrap_result(&format!("invalid '{}' value", name)) - } -} diff --git a/crates/cli/src/modules/queue.rs b/crates/cli/src/modules/queue.rs deleted file mode 100644 index 1794ebc9..00000000 --- a/crates/cli/src/modules/queue.rs +++ /dev/null @@ -1,473 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use super::{ - List, - cli::{Client, QueueCommands}, -}; -use console::Term; -use human_size::{Byte, SpecificSize}; -use mail_parser::DateTime; -use prettytable::{Attr, Cell, Row, Table, format::Alignment}; -use reqwest::Method; -use serde::{Deserialize, Deserializer}; - -#[derive(Debug, Deserialize, PartialEq, Eq)] -pub struct Message { - pub id: u64, - pub return_path: String, - pub recipients: Vec, - #[serde(deserialize_with = "deserialize_datetime")] - pub created: DateTime, - pub size: usize, - #[serde(default)] - pub priority: i16, - #[serde(default)] - pub env_id: Option, - pub blob_hash: String, -} - -#[derive(Debug, Deserialize, PartialEq, Eq)] -pub struct Recipient { - pub address: String, - pub status: Status, - pub queue: String, - pub retry_num: u32, - #[serde(deserialize_with = "deserialize_maybe_datetime", default)] - pub next_retry: Option, - #[serde(deserialize_with = "deserialize_maybe_datetime", default)] - pub next_notify: Option, - #[serde(deserialize_with = "deserialize_maybe_datetime", default)] - pub expires: Option, - #[serde(default)] - pub orcpt: Option, -} - -#[derive(Debug, PartialEq, Eq, Deserialize)] -pub enum Status { - #[serde(rename = "scheduled")] - Scheduled, - #[serde(rename = "completed")] - Completed(String), - #[serde(rename = "temp_fail")] - TemporaryFailure(String), - #[serde(rename = "perm_fail")] - PermanentFailure(String), -} - -impl QueueCommands { - pub async fn exec(self, client: Client) { - match self { - QueueCommands::List { - sender, - rcpt, - before, - after, - page_size, - } => { - let stdout = Term::buffered_stdout(); - let ids = client.query_messages(&sender, &rcpt, &before, &after).await; - let ids_len = ids.len(); - let page_size = page_size.map(|p| std::cmp::max(p, 1)).unwrap_or(20); - let pages_total = (ids_len as f64 / page_size as f64).ceil() as usize; - for (page_num, chunk) in ids.chunks(page_size).enumerate() { - // Build table - let mut table = Table::new(); - table.add_row(Row::new( - ["ID", "Delivery Due", "Sender", "Recipients", "Size"] - .iter() - .map(|p| Cell::new(p).with_style(Attr::Bold)) - .collect(), - )); - for id in chunk { - let message = client - .http_request::( - Method::GET, - &format!("/api/queue/messages/{id}"), - None, - ) - .await; - - let mut rcpts = String::new(); - let mut deliver_at = i64::MAX; - let mut deliver_pos = 0; - - for (pos, rcpt) in message.recipients.iter().enumerate() { - if let Some(next_retry) = &rcpt.next_retry { - let ts = next_retry.to_timestamp(); - if ts < deliver_at { - deliver_at = ts; - deliver_pos = pos; - } - } - if !rcpts.is_empty() { - rcpts.push('\n'); - } - rcpts.push_str(&rcpt.address); - rcpts.push_str(" ("); - rcpts.push_str(rcpt.status.status_short()); - rcpts.push(')'); - } - - let mut cells = Vec::new(); - cells.push(Cell::new(&format!("{id:X}"))); - cells.push(if deliver_at != i64::MAX { - Cell::new( - &message.recipients[deliver_pos] - .next_retry - .as_ref() - .unwrap() - .to_rfc822(), - ) - } else { - Cell::new("None") - }); - cells.push(Cell::new(if !message.return_path.is_empty() { - &message.return_path - } else { - "<>" - })); - cells.push(Cell::new(&rcpts)); - cells.push(Cell::new( - &SpecificSize::new(message.size as u32, Byte) - .unwrap() - .to_string(), - )); - table.add_row(Row::new(cells)); - } - - eprintln!(); - table.printstd(); - eprintln!(); - if page_num + 1 != pages_total { - eprintln!("\n--- Press any key to continue or 'q' to exit ---"); - if let Ok('q' | 'Q') = stdout.read_char() { - break; - } - } - } - eprintln!("\n{ids_len} queued message(s) found.") - } - QueueCommands::Status { ids } => { - for (uid, id) in parse_ids(&ids).into_iter().zip(ids) { - let message = client - .try_http_request::( - Method::GET, - &format!("/api/queue/messages/{uid}"), - None, - ) - .await; - let mut table = Table::new(); - table.add_row(Row::new(vec![ - Cell::new("ID").with_style(Attr::Bold), - Cell::new(&id), - ])); - - if let Some(message) = message { - table.add_row(Row::new(vec![ - Cell::new("Sender").with_style(Attr::Bold), - Cell::new(if !message.return_path.is_empty() { - &message.return_path - } else { - "<>" - }), - ])); - table.add_row(Row::new(vec![ - Cell::new("Created").with_style(Attr::Bold), - Cell::new(&message.created.to_rfc822()), - ])); - table.add_row(Row::new(vec![ - Cell::new("Size").with_style(Attr::Bold), - Cell::new( - &SpecificSize::new(message.size as u32, Byte) - .unwrap() - .to_string(), - ), - ])); - if let Some(env_id) = &message.env_id { - table.add_row(Row::new(vec![ - Cell::new("Env-Id").with_style(Attr::Bold), - Cell::new(env_id), - ])); - } - if message.priority != 0 { - table.add_row(Row::new(vec![ - Cell::new("Priority").with_style(Attr::Bold), - Cell::new(&message.priority.to_string()), - ])); - } - for rcpt in &message.recipients { - table.add_row(Row::new(vec![ - Cell::new_align(&rcpt.address, Alignment::RIGHT) - .with_style(Attr::Bold) - .with_style(Attr::Italic(true)) - .with_hspan(2), - ])); - table.add_row(Row::new(vec![ - Cell::new("Status").with_style(Attr::Bold), - Cell::new(rcpt.status.status()), - ])); - table.add_row(Row::new(vec![ - Cell::new("Details").with_style(Attr::Bold), - Cell::new(rcpt.status.details()), - ])); - table.add_row(Row::new(vec![ - Cell::new("Retry #").with_style(Attr::Bold), - Cell::new(&rcpt.retry_num.to_string()), - ])); - if let Some(dt) = &rcpt.next_retry { - table.add_row(Row::new(vec![ - Cell::new("Delivery Due").with_style(Attr::Bold), - Cell::new(&dt.to_rfc822()), - ])); - } - if let Some(dt) = &rcpt.next_notify { - table.add_row(Row::new(vec![ - Cell::new("Notify at").with_style(Attr::Bold), - Cell::new(&dt.to_rfc822()), - ])); - } - table.add_row(Row::new(vec![ - Cell::new("Expires").with_style(Attr::Bold), - if let Some(dt) = &rcpt.expires { - Cell::new(&dt.to_rfc822()) - } else { - Cell::new("N/A") - }, - ])); - } - } else { - table.add_row(Row::new(vec![ - Cell::new_align("-- Not found --", Alignment::CENTER).with_hspan(2), - ])); - } - - eprintln!(); - table.printstd(); - eprintln!(); - } - } - QueueCommands::Retry { - sender, - domain, - before, - after, - time, - ids, - } => { - let (parsed_ids, ids) = if ids.is_empty() { - if sender.is_some() || domain.is_some() || before.is_some() || after.is_some() { - let parsed_ids = client - .query_messages(&sender, &domain, &before, &after) - .await; - let ids = parsed_ids.iter().map(|id| format!("{id:X}")).collect(); - (parsed_ids, ids) - } else { - (vec![], vec![]) - } - } else { - (parse_ids(&ids), ids) - }; - - if ids.is_empty() { - eprintln!("No messages were found."); - std::process::exit(1); - } - - let mut success_count = 0; - let mut failed_list = vec![]; - - for id in parsed_ids { - let mut query = - form_urlencoded::Serializer::new(format!("/api/queue/messages/{id}")); - - if let Some(filter) = &domain { - query.append_pair("filter", filter); - } - if let Some(at) = time { - query.append_pair("at", &at.to_rfc3339()); - } - - if client - .try_http_request::(Method::PATCH, &query.finish(), None) - .await - .unwrap_or(false) - { - success_count += 1; - } else { - failed_list.push(id.to_string()); - } - } - - eprint!("\nSuccessfully rescheduled {success_count} message(s)."); - if !failed_list.is_empty() { - eprint!(" Unable to reschedule id(s): {}.", failed_list.join(", ")); - } - eprintln!(); - } - QueueCommands::Cancel { - sender, - rcpt, - before, - after, - ids, - } => { - let (parsed_ids, ids) = if ids.is_empty() { - if sender.is_some() || rcpt.is_some() || before.is_some() || after.is_some() { - let parsed_ids = - client.query_messages(&sender, &rcpt, &before, &after).await; - let ids = parsed_ids.iter().map(|id| format!("{id:X}")).collect(); - (parsed_ids, ids) - } else { - (vec![], vec![]) - } - } else { - (parse_ids(&ids), ids) - }; - - if ids.is_empty() { - eprintln!("No messages were found."); - std::process::exit(1); - } - - let mut success_count = 0; - let mut failed_list = vec![]; - - for id in parsed_ids { - let mut query = - form_urlencoded::Serializer::new(format!("/api/queue/messages/{id}")); - - if let Some(filter) = &rcpt { - query.append_pair("filter", filter); - } - - if client - .try_http_request::(Method::DELETE, &query.finish(), None) - .await - .unwrap_or(false) - { - success_count += 1; - } else { - failed_list.push(id.to_string()); - } - } - - eprint!("\nCancelled delivery of {success_count} message(s)."); - if !failed_list.is_empty() { - eprint!( - " Unable to cancel delivery for id(s): {}.", - failed_list.join(", ") - ); - } - eprintln!(); - } - } - } -} - -impl Client { - async fn query_messages( - &self, - from: &Option, - rcpt: &Option, - before: &Option, - after: &Option, - ) -> Vec { - let mut query = form_urlencoded::Serializer::new("/api/queue/messages".to_string()); - - if let Some(sender) = from { - query.append_pair("from", sender); - } - if let Some(rcpt) = rcpt { - query.append_pair("to", rcpt); - } - if let Some(before) = before { - query.append_pair("before", &before.to_rfc3339()); - } - if let Some(after) = after { - query.append_pair("after", &after.to_rfc3339()); - } - - self.http_request::, String>(Method::GET, &query.finish(), None) - .await - .items - } -} - -fn deserialize_maybe_datetime<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - if let Some(value) = Option::<&str>::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) - } -} - -pub fn deserialize_datetime<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - if let Some(value) = DateTime::parse_rfc3339(<&str>::deserialize(deserializer)?) { - Ok(value) - } else { - Err(serde::de::Error::custom( - "Failed to parse RFC3339 timestamp", - )) - } -} - -fn parse_ids(ids: &[String]) -> Vec { - let mut result = Vec::with_capacity(ids.len()); - for id in ids { - match u64::from_str_radix(id, 16) { - Ok(id) => { - result.push(id); - } - Err(_) => { - eprintln!("Failed to parse id {id:?}."); - std::process::exit(1); - } - } - } - result -} - -impl Status { - fn status_short(&self) -> &str { - match self { - Status::Scheduled => "scheduled", - Status::Completed(_) => "delivered", - Status::TemporaryFailure(_) => "tempfail", - Status::PermanentFailure(_) => "permfail", - } - } - - fn status(&self) -> &str { - match self { - Status::Scheduled => "Scheduled", - Status::Completed(_) => "Delivered", - Status::TemporaryFailure(_) => "Temporary Failure", - Status::PermanentFailure(_) => "Permanent Failure", - } - } - - fn details(&self) -> &str { - match self { - Status::Scheduled => "", - Status::Completed(status) => status, - Status::TemporaryFailure(status) => status, - Status::PermanentFailure(status) => status, - } - } -} diff --git a/crates/cli/src/modules/report.rs b/crates/cli/src/modules/report.rs deleted file mode 100644 index f70a9b93..00000000 --- a/crates/cli/src/modules/report.rs +++ /dev/null @@ -1,249 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use super::cli::{Client, ReportCommands, ReportFormat}; -use crate::modules::{List, queue::deserialize_datetime}; -use console::Term; -use human_size::{Byte, SpecificSize}; -use mail_auth::{ - dmarc::URI, - mta_sts::ReportUri, - report::{self, tlsrpt::TlsReport}, -}; -use mail_parser::DateTime; -use prettytable::{Attr, Cell, Row, Table, format}; -use reqwest::Method; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -#[serde(rename_all = "camelCase")] -pub enum Report { - Tls { - id: String, - domain: String, - #[serde(deserialize_with = "deserialize_datetime")] - range_from: DateTime, - #[serde(deserialize_with = "deserialize_datetime")] - range_to: DateTime, - report: TlsReport, - rua: Vec, - }, - Dmarc { - id: String, - domain: String, - #[serde(deserialize_with = "deserialize_datetime")] - range_from: DateTime, - #[serde(deserialize_with = "deserialize_datetime")] - range_to: DateTime, - report: report::Report, - rua: Vec, - }, -} - -impl Report { - pub fn domain(&self) -> &str { - match self { - Report::Tls { domain, .. } => domain, - Report::Dmarc { domain, .. } => domain, - } - } - - pub fn type_(&self) -> &str { - match self { - Report::Tls { .. } => "TLS", - Report::Dmarc { .. } => "DMARC", - } - } - - pub fn range_from(&self) -> &DateTime { - match self { - Report::Tls { range_from, .. } => range_from, - Report::Dmarc { range_from, .. } => range_from, - } - } - - pub fn range_to(&self) -> &DateTime { - match self { - Report::Tls { range_to, .. } => range_to, - Report::Dmarc { range_to, .. } => range_to, - } - } - - pub fn num_records(&self) -> usize { - match self { - Report::Tls { report, .. } => report - .policies - .iter() - .map(|p| p.failure_details.len()) - .sum(), - Report::Dmarc { report, .. } => report.records().len(), - } - } -} - -impl ReportCommands { - pub async fn exec(self, client: Client) { - match self { - ReportCommands::List { - domain, - format, - page_size, - } => { - let stdout = Term::buffered_stdout(); - let mut query = form_urlencoded::Serializer::new("/api/queue/reports".to_string()); - - if let Some(domain) = &domain { - query.append_pair("domain", domain); - } - if let Some(format) = &format { - query.append_pair("type", format.id()); - } - - let ids = client - .http_request::, String>(Method::GET, &query.finish(), None) - .await - .items; - let ids_len = ids.len(); - let page_size = page_size.map(|p| std::cmp::max(p, 1)).unwrap_or(20); - let pages_total = (ids_len as f64 / page_size as f64).ceil() as usize; - for (page_num, chunk) in ids.chunks(page_size).enumerate() { - // Build table - let mut table = Table::new(); - table.add_row(Row::new( - ["ID", "Domain", "Type", "From Date", "To Date", "Records"] - .iter() - .map(|p| Cell::new(p).with_style(Attr::Bold)) - .collect(), - )); - for id in chunk { - let report = client - .try_http_request::( - Method::GET, - &format!("/api/queue/reports/{id}"), - None, - ) - .await; - - if let Some(report) = report { - table.add_row(Row::new(vec![ - Cell::new(id), - Cell::new(report.domain()), - Cell::new(report.type_()), - Cell::new(&report.range_from().to_rfc822()), - Cell::new(&report.range_to().to_rfc822()), - Cell::new( - &SpecificSize::new(report.num_records() as u32, Byte) - .unwrap() - .to_string(), - ), - ])); - } - } - - eprintln!(); - table.printstd(); - eprintln!(); - if page_num + 1 != pages_total { - eprintln!("\n--- Press any key to continue or 'q' to exit ---"); - if let Ok('q' | 'Q') = stdout.read_char() { - break; - } - } - } - eprintln!("\n{ids_len} queued message(s) found.") - } - ReportCommands::Status { ids } => { - for id in ids { - let report = client - .try_http_request::( - Method::GET, - &format!("/api/queue/reports/{id}"), - None, - ) - .await; - - let mut table = Table::new(); - table.add_row(Row::new(vec![ - Cell::new("ID").with_style(Attr::Bold), - Cell::new(&id), - ])); - if let Some(report) = report { - table.add_row(Row::new(vec![ - Cell::new("Domain Name").with_style(Attr::Bold), - Cell::new(report.domain()), - ])); - table.add_row(Row::new(vec![ - Cell::new("Type").with_style(Attr::Bold), - Cell::new(report.type_()), - ])); - table.add_row(Row::new(vec![ - Cell::new("From Date").with_style(Attr::Bold), - Cell::new(&report.range_from().to_rfc822()), - ])); - table.add_row(Row::new(vec![ - Cell::new("To Date").with_style(Attr::Bold), - Cell::new(&report.range_to().to_rfc822()), - ])); - table.add_row(Row::new(vec![ - Cell::new("Records").with_style(Attr::Bold), - Cell::new( - &SpecificSize::new(report.num_records() as u32, Byte) - .unwrap() - .to_string(), - ), - ])); - } else { - table.add_row(Row::new(vec![ - Cell::new_align("-- Not found --", format::Alignment::CENTER) - .with_hspan(2), - ])); - } - - eprintln!(); - table.printstd(); - eprintln!(); - } - } - ReportCommands::Cancel { ids } => { - let mut success_count = 0; - let mut failed_list = vec![]; - for id in ids { - let success = client - .try_http_request::( - Method::DELETE, - &format!("/api/queue/reports/{id}"), - None, - ) - .await; - - if success.unwrap_or_default() { - success_count += 1; - } else { - failed_list.push(id); - } - } - eprint!("\nRemoved {success_count} report(s)."); - if !failed_list.is_empty() { - eprint!( - " Unable to remove report id(s): {}.", - failed_list.join(", ") - ); - } - eprintln!(); - } - } - } -} - -impl ReportFormat { - fn id(&self) -> &'static str { - match self { - ReportFormat::Dmarc => "dmarc", - ReportFormat::Tls => "tls", - } - } -}