From b30e02248024e3262c9fdb929d1f4e4e821a4890 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Mon, 3 Jul 2023 18:48:43 +0200 Subject: [PATCH] Imported Stalwart CLI crate. --- Cargo.lock | 325 +++++++++++++++++- Cargo.toml | 83 ++--- crates/cli/Cargo.toml | 29 ++ crates/cli/src/main.rs | 135 ++++++++ crates/cli/src/modules/cli.rs | 213 ++++++++++++ crates/cli/src/modules/import.rs | 444 +++++++++++++++++++++++++ crates/cli/src/modules/mod.rs | 149 +++++++++ crates/cli/src/modules/queue.rs | 548 +++++++++++++++++++++++++++++++ crates/cli/src/modules/report.rs | 203 ++++++++++++ crates/main/Cargo.toml | 37 +++ tests/Cargo.toml | 2 +- tests/src/imap/search.rs | 2 +- 12 files changed, 2125 insertions(+), 45 deletions(-) create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/main.rs create mode 100644 crates/cli/src/modules/cli.rs create mode 100644 crates/cli/src/modules/import.rs create mode 100644 crates/cli/src/modules/mod.rs create mode 100644 crates/cli/src/modules/queue.rs create mode 100644 crates/cli/src/modules/report.rs create mode 100644 crates/main/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index f3e92ef0..d9f63e57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + [[package]] name = "anyhow" version = "1.0.71" @@ -642,6 +691,66 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384e169cc618c613d5e3ca6404dda77a8685a63e08660dcc64abaf7da7cb0c7a" +dependencies = [ + "clap_builder", + "clap_derive", + "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef137bbe35aab78bdb468ccfba75a5f4d8321ae011d34063770780545176af2d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.23", +] + +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "console" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" +dependencies = [ + "encode_unicode 0.3.6", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.45.0", +] + [[package]] name = "const-oid" version = "0.9.3" @@ -946,6 +1055,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -957,6 +1076,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.4" @@ -1042,6 +1172,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[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.32" @@ -1397,7 +1539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" dependencies = [ "libc", - "windows-targets", + "windows-targets 0.48.1", ] [[package]] @@ -1617,6 +1759,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "human-size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9994b79e8c1a39b3166c63ae7823bb2b00831e2a96a31399c50fe69df408eaeb" + [[package]] name = "hyper" version = "0.14.27" @@ -1776,6 +1924,19 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indicatif" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff8cc23a7393a397ed1d7f56e6365cba772aba9f9912ab968b03043c395d057" +dependencies = [ + "console", + "instant", + "number_prefix", + "portable-atomic", + "unicode-width", +] + [[package]] name = "inout" version = "0.1.3" @@ -1823,6 +1984,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +[[package]] +name = "is-terminal" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fddda5af7e54bf7da53067d6e802dbcc381d0a8eef629df528e3ebf68755cb" +dependencies = [ + "hermit-abi", + "rustix 0.38.2", + "windows-sys 0.48.0", +] + [[package]] name = "itertools" version = "0.10.5" @@ -2091,6 +2263,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + [[package]] name = "lock_api" version = "0.4.10" @@ -2467,6 +2645,12 @@ dependencies = [ "libc", ] +[[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.31.1" @@ -2693,7 +2877,7 @@ dependencies = [ "libc", "redox_syscall 0.3.5", "smallvec", - "windows-targets", + "windows-targets 0.48.1", ] [[package]] @@ -2922,6 +3106,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "767eb9f07d4a5ebcb39bbf2d452058a93c011373abf6832e24194a1c3f004794" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -2948,6 +3138,20 @@ dependencies = [ "syn 2.0.23", ] +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode 1.0.0", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + [[package]] name = "primeorder" version = "0.13.2" @@ -3306,6 +3510,17 @@ dependencies = [ "librocksdb-sys", ] +[[package]] +name = "rpassword" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678cf63ab3491898c0d021b493c94c9b221d91295294a2a5746eacbe5928322" +dependencies = [ + "libc", + "rtoolbox", + "winapi", +] + [[package]] name = "rsa" version = "0.8.2" @@ -3326,6 +3541,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtoolbox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034e22c514f5c0cb8a10ff341b9b048b5ceb21591f31c8f44c43b960f9b3524a" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "rusqlite" version = "0.29.0" @@ -3423,7 +3648,20 @@ dependencies = [ "errno", "io-lifetimes", "libc", - "linux-raw-sys", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aabcb0461ebd01d6b79945797c27f8529082226cb630a9865a71870ff63532a4" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys 0.4.3", "windows-sys 0.48.0", ] @@ -4090,6 +4328,28 @@ dependencies = [ "url", ] +[[package]] +name = "stalwart-cli" +version = "0.3.0" +dependencies = [ + "clap", + "console", + "csv", + "form_urlencoded", + "futures", + "human-size", + "indicatif", + "jmap-client", + "mail-parser", + "num_cpus", + "prettytable-rs", + "reqwest", + "rpassword", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4140,6 +4400,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.4.1" @@ -4196,10 +4462,21 @@ dependencies = [ "cfg-if", "fastrand", "redox_syscall 0.3.5", - "rustix", + "rustix 0.37.22", "windows-sys 0.48.0", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "tests" version = "0.1.0" @@ -4736,6 +5013,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "unicode-xid" version = "0.2.4" @@ -4781,6 +5064,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "utils" version = "0.1.0" @@ -5028,7 +5317,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-targets", + "windows-targets 0.48.1", ] [[package]] @@ -5046,13 +5335,37 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9158032c..167b196c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,42 +1,7 @@ -[package] -name = "mail-server" -description = "Stalwart Mail Server" -authors = [ "Stalwart Labs Ltd. "] -repository = "https://github.com/stalwartlabs/jmap-server" -homepage = "https://stalw.art" -keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] -categories = ["email"] -license = "AGPL-3.0-only" -version = "0.3.0" -edition = "2021" -resolver = "2" - -[[bin]] -name = "stalwart-mail" -path = "crates/main/src/main.rs" - -[dependencies] -store = { path = "crates/store" } -jmap = { path = "crates/jmap" } -jmap_proto = { path = "crates/jmap-proto" } -smtp = { path = "crates/smtp", features = ["local_delivery"] } -imap = { path = "crates/imap" } -managesieve = { path = "crates/managesieve" } -directory = { path = "crates/directory" } -utils = { path = "crates/utils" } -tokio = { version = "1.23", features = ["full"] } -tracing = "0.1" - -[target.'cfg(not(target_env = "msvc"))'.dependencies] -jemallocator = "0.5.0" - -[features] -default = ["sqlite"] -sqlite = ["store/sqlite"] -foundationdb = ["store/foundation"] - [workspace] +resolver = "2" members = [ + "crates/main", "crates/jmap", "crates/jmap-proto", "crates/imap", @@ -47,5 +12,49 @@ members = [ "crates/directory", "crates/utils", "crates/maybe-async", + "crates/cli", "tests", ] + +[profile.dev] +opt-level = 0 +debug = 1 +codegen-units = 4 +lto = false +incremental = true +panic = 'unwind' +debug-assertions = true +overflow-checks = false +rpath = false + +[profile.release] +opt-level = 3 +debug = false +codegen-units = 1 +lto = true +incremental = false +panic = 'unwind' +debug-assertions = false +overflow-checks = false +rpath = false +#strip = true + +[profile.test] +opt-level = 0 +debug = 1 +#codegen-units = 16 +lto = false +incremental = true +debug-assertions = true +overflow-checks = true +rpath = false + +[profile.bench] +opt-level = 3 +debug = false +codegen-units = 1 +lto = true +incremental = false +debug-assertions = false +overflow-checks = false +rpath = false diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 00000000..99e119e9 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "stalwart-cli" +description = "Stalwart Mail Server CLI" +authors = ["Stalwart Labs Ltd. "] +license = "AGPL-3.0-only" +repository = "https://github.com/stalwartlabs/cli" +homepage = "https://github.com/stalwartlabs/cli" +version = "0.3.0" +edition = "2021" +readme = "README.md" +resolver = "2" + +[dependencies] +jmap-client = { git = "https://github.com/stalwartlabs/jmap-client", features = ["async"] } +mail-parser = { git = "https://github.com/stalwartlabs/mail-parser", features = ["full_encoding", "serde_support", "ludicrous_mode"] } +reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"]} +tokio = { version = "1.23", 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" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 00000000..99cf3b5a --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2020-2023, Stalwart Labs Ltd. + * + * This file is part of the Stalwart Command Line Interface. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{ + collections::HashMap, + io::{BufRead, Write}, +}; + +use clap::Parser; +use console::style; +use jmap_client::client::{Client, Credentials}; +use modules::{ + cli::{Cli, Commands}, + get, + import::cmd_import, + post, + queue::cmd_queue, + report::cmd_report, +}; + +use crate::modules::OAuthResponse; + +pub mod modules; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let args = Cli::parse(); + let is_jmap = args.command.is_jmap(); + let credentials = if let Some(credentials) = args.credentials { + parse_credentials(&credentials) + } else if is_jmap { + let credentials = rpassword::prompt_password( + "\nEnter JMAP admin credentials or press [ENTER] to use OAuth: ", + ) + .unwrap(); + if !credentials.is_empty() { + parse_credentials(&credentials) + } else { + oauth(&args.url) + } + } else { + parse_credentials(&rpassword::prompt_password("\nEnter SMTP admin credentials: ").unwrap()) + }; + + if is_jmap { + let client = Client::new() + .credentials(credentials) + .connect(&args.url) + .await + .unwrap_or_else(|err| { + eprintln!("Failed to connect to JMAP server {}: {}.", args.url, err); + std::process::exit(1); + }); + + match args.command { + Commands::Import(command) => cmd_import(client, command).await, + Commands::Queue(_) | Commands::Report(_) => unreachable!(), + } + } else { + match args.command { + Commands::Queue(command) => cmd_queue(&args.url, credentials, command), + Commands::Report(command) => cmd_report(&args.url, credentials, command), + _ => unreachable!(), + } + } + + Ok(()) +} + +fn parse_credentials(credentials: &str) -> Credentials { + if let Some((account, secret)) = credentials.split_once(':') { + Credentials::basic(account, secret) + } else { + Credentials::basic("admin", credentials) + } +} + +fn oauth(url: &str) -> Credentials { + let metadata = get(&format!("{}/.well-known/oauth-authorization-server", url)); + let token_endpoint = metadata.property("token_endpoint"); + let mut params = HashMap::from_iter([("client_id".to_string(), "Stalwart_CLI".to_string())]); + let response = post(metadata.property("device_authorization_endpoint"), ¶ms); + + 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 = post(token_endpoint, ¶ms); + 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); + } +} diff --git a/crates/cli/src/modules/cli.rs b/crates/cli/src/modules/cli.rs new file mode 100644 index 00000000..7f4d7bcc --- /dev/null +++ b/crates/cli/src/modules/cli.rs @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2020-2023, Stalwart Labs Ltd. + * + * This file is part of the Stalwart Command Line Interface. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use clap::{Parser, Subcommand, ValueEnum}; +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, + /// JMAP or SMTP server base URL + #[clap(short, long)] + pub url: String, + /// Authentication credentials + #[clap(short, long)] + pub credentials: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Import accounts and domains + #[clap(subcommand)] + Import(ImportCommands), + + /// Manage SMTP message queue + #[clap(subcommand)] + Queue(QueueCommands), + + /// Manage SMTP DMARC/TLS report queue + #[clap(subcommand)] + Report(ReportCommands), +} + +#[derive(Subcommand)] +pub enum ImportCommands { + /// Import messages and folders + Messages { + #[clap(value_enum)] + #[clap(short, long)] + format: MailboxFormat, + + /// Number of threads to use for message import, defaults to the number of CPUs. + #[clap(short, long)] + num_threads: Option, + + /// Account id to import messages into + account_id: String, + + /// Path to the mailbox to import, or '-' for stdin (stdin only supported for mbox) + path: String, + }, +} + +#[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, + }, +} + +impl Commands { + pub fn is_jmap(&self) -> bool { + !matches!(self, Commands::Queue(_) | Commands::Report(_)) + } +} + +#[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/import.rs b/crates/cli/src/modules/import.rs new file mode 100644 index 00000000..0cfbdd67 --- /dev/null +++ b/crates/cli/src/modules/import.rs @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2020-2023, Stalwart Labs Ltd. + * + * This file is part of the Stalwart Command Line Interface. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{ + collections::HashMap, + io::{self, Cursor}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, +}; + +use console::style; +use futures::{stream::FuturesUnordered, StreamExt}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use jmap_client::{ + client::Client, + core::set::SetObject, + mailbox::{self, Role}, +}; +use mail_parser::mailbox::{ + maildir, + mbox::{self, MessageIterator}, +}; + +use crate::modules::UnwrapResult; + +use super::{ + cli::{ImportCommands, MailboxFormat}, + 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, +} + +pub async fn cmd_import(client: Client, command: ImportCommands) { + match command { + ImportCommands::Messages { + num_threads, + format, + account_id, + path, + } => { + let account_id = Arc::new(account_id); + 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() + .account_id(account_id.as_ref()) + .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_insert_with(Vec::new) + .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().account_id(account_id.as_ref()); + + 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_threads = num_threads.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_threads) + .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 mut outputs = Vec::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() + }); + + while let Some(result) = mailbox.next() { + match result { + Ok(message) => { + message_num += 1; + let client = client.clone(); + let account_id = account_id.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; + } + + if let Err(err) = client + .email_import_account( + &account_id, + message.contents, + [mailbox_id.as_ref()], + if !message.flags.is_empty() { + message + .flags + .into_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 + { + failures.lock().unwrap().push(format!( + concat!( + "Failed to import message {} ", + "with identifier '{}': {}" + ), + message_num, message.identifier, err + )); + } else { + total_imported.fetch_add(1, Ordering::Relaxed); + } + }); + + if futures.len() == num_threads { + outputs.push(futures.next().await.unwrap()); + } + } + Err(e) => { + failures + .lock() + .unwrap() + .push(format!("I/O error reading message: {}", e)); + } + } + } + + // Wait for remaining futures + while let Some(item) = futures.next().await { + outputs.push(item); + } + } + + // 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); + } + } + } + } +} + +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(|_| { + io::Error::new(io::ErrorKind::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/mod.rs b/crates/cli/src/modules/mod.rs new file mode 100644 index 00000000..c91933b8 --- /dev/null +++ b/crates/cli/src/modules/mod.rs @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2020-2023, Stalwart Labs Ltd. + * + * This file is part of the Stalwart Command Line Interface. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{collections::HashMap, fmt::Display, io::Read}; + +use jmap_client::principal::Property; + +pub mod cli; +pub mod import; +pub mod queue; +pub mod report; + +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); + } + } + } +} + +trait TableName { + fn table_name(&self) -> &'static str; +} + +impl TableName for Property { + fn table_name(&self) -> &'static str { + match self { + Property::Id => "Id", + Property::Type => "Type", + Property::Name => "Name", + Property::Description => "Description", + Property::Email => "E-mail", + Property::Timezone => "Timezone", + Property::Capabilities => "Capabilities", + Property::Aliases => "Aliases", + Property::Secret => "Secret", + Property::DKIM => "DKIM", + Property::Quota => "Quota", + Property::Picture => "Picture", + Property::Members => "Members", + Property::ACL => "ACL", + } + } +} + +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 fn get(url: &str) -> HashMap { + serde_json::from_slice( + &reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .get(url) + .send() + .unwrap_result("send OAuth GET request") + .bytes() + .unwrap_result("fetch bytes"), + ) + .unwrap_result("deserialize OAuth GET response") +} + +pub fn post(url: &str, params: &HashMap) -> HashMap { + serde_json::from_slice( + &reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url) + .form(params) + .send() + .unwrap_result("send OAuth POST request") + .bytes() + .unwrap_result("fetch bytes"), + ) + .unwrap_result("deserialize OAuth POST response") +} + +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 new file mode 100644 index 00000000..b72c5e46 --- /dev/null +++ b/crates/cli/src/modules/queue.rs @@ -0,0 +1,548 @@ +/* + * Copyright (c) 2020-2023, Stalwart Labs Ltd. + * + * This file is part of the Stalwart Command Line Interface. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::{cli::QueueCommands, UnwrapResult}; +use console::Term; +use human_size::{Byte, SpecificSize}; +use jmap_client::client::Credentials; +use mail_parser::DateTime; +use prettytable::{format::Alignment, Attr, Cell, Row, Table}; +use reqwest::header::AUTHORIZATION; +use serde::{de::DeserializeOwned, Deserialize, Deserializer}; + +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct Message { + pub return_path: String, + pub domains: Vec, + #[serde(deserialize_with = "deserialize_datetime")] + pub created: DateTime, + pub size: usize, + #[serde(default)] + pub priority: i16, + pub env_id: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct Domain { + pub name: String, + pub status: Status, + pub recipients: Vec, + + pub retry_num: u32, + #[serde(deserialize_with = "deserialize_maybe_datetime")] + pub next_retry: Option, + #[serde(deserialize_with = "deserialize_maybe_datetime")] + pub next_notify: Option, + #[serde(deserialize_with = "deserialize_datetime")] + pub expires: DateTime, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct Recipient { + pub address: String, + pub status: Status, + 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), +} + +pub fn cmd_queue(url: &str, credentials: Credentials, command: QueueCommands) { + match command { + QueueCommands::List { + sender, + rcpt, + before, + after, + page_size, + } => { + let stdout = Term::buffered_stdout(); + let ids = query_messages(url, &credentials, &sender, &rcpt, &before, &after); + 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 (message, id) in smtp_manage_request::>>( + &build_query(url, "/queue/status?ids=", chunk), + &credentials, + ) + .into_iter() + .zip(chunk) + { + if let Some(message) = message { + let mut rcpts = String::new(); + let mut deliver_at = i64::MAX; + let mut deliver_pos = 0; + for (pos, domain) in message.domains.iter().enumerate() { + if let Some(next_retry) = &domain.next_retry { + let ts = next_retry.to_timestamp(); + if ts < deliver_at { + deliver_at = ts; + deliver_pos = pos; + } + } + for rcpt in &domain.recipients { + 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.domains[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 (message, id) in smtp_manage_request::>>( + &build_query(url, "/queue/status?ids=", &parse_ids(&ids)), + &credentials, + ) + .into_iter() + .zip(&ids) + { + 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 domain in &message.domains { + table.add_row(Row::new(vec![Cell::new_align( + &domain.name, + 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(domain.status.status()), + ])); + table.add_row(Row::new(vec![ + Cell::new("Details").with_style(Attr::Bold), + Cell::new(domain.status.details()), + ])); + table.add_row(Row::new(vec![ + Cell::new("Retry #").with_style(Attr::Bold), + Cell::new(&domain.retry_num.to_string()), + ])); + if let Some(dt) = &domain.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) = &domain.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), + Cell::new(&domain.expires.to_rfc822()), + ])); + + let mut rcpts = Table::new(); + rcpts.add_row(Row::new(vec![ + Cell::new("Address").with_style(Attr::Bold), + Cell::new("Status").with_style(Attr::Bold), + Cell::new("Details").with_style(Attr::Bold), + ])); + for rcpt in &domain.recipients { + rcpts.add_row(Row::new(vec![ + Cell::new(&rcpt.address), + Cell::new(rcpt.status.status()), + Cell::new(rcpt.status.details()), + ])); + } + table.add_row(Row::new(vec![ + Cell::new("Recipients").with_style(Attr::Bold), + Cell::from(&rcpts), + ])); + } + } 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 = + query_messages(url, &credentials, &sender, &domain, &before, &after); + 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 query = form_urlencoded::Serializer::new(format!("{url}/queue/retry?")); + + if let Some(filter) = &domain { + query.append_pair("filter", filter); + } + if let Some(at) = time { + query.append_pair("at", &at.to_rfc3339()); + } + query.append_pair("ids", &append_ids(String::new(), &parsed_ids)); + + let mut success_count = 0; + let mut failed_list = vec![]; + for (success, id) in smtp_manage_request::>(&query.finish(), &credentials) + .into_iter() + .zip(ids) + { + if success { + success_count += 1; + } else { + failed_list.push(id); + } + } + 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 = + query_messages(url, &credentials, &sender, &rcpt, &before, &after); + 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 query = form_urlencoded::Serializer::new(format!("{url}/queue/cancel?")); + + if let Some(filter) = &rcpt { + query.append_pair("filter", filter); + } + query.append_pair("ids", &append_ids(String::new(), &parsed_ids)); + + let mut success_count = 0; + let mut failed_list = vec![]; + for (success, id) in smtp_manage_request::>(&query.finish(), &credentials) + .into_iter() + .zip(ids) + { + if success { + success_count += 1; + } else { + failed_list.push(id); + } + } + 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!(); + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum Response { + Data { data: T }, + Error { error: String, details: String }, +} + +pub fn smtp_manage_request(url: &str, credentials: &Credentials) -> T { + match serde_json::from_slice::>( + &reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(url.starts_with("https://127.0.0.1")) + .build() + .unwrap_or_default() + .get(url) + .header( + AUTHORIZATION, + match credentials { + Credentials::Basic(s) => format!("Basic {s}"), + Credentials::Bearer(s) => format!("Bearer {s}"), + }, + ) + .send() + .unwrap_result("send GET request") + .bytes() + .unwrap_result("fetch bytes"), + ) + .unwrap_result("deserialize response") + { + Response::Data { data } => data, + Response::Error { error, details } => { + eprintln!("Request failed: {details} ({error:?})"); + std::process::exit(1); + } + } +} + +fn query_messages( + url: &str, + credentials: &Credentials, + from: &Option, + rcpt: &Option, + before: &Option, + after: &Option, +) -> Vec { + let mut query = form_urlencoded::Serializer::new(format!("{url}/queue/list?")); + + 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()); + } + + smtp_manage_request::>(&query.finish(), credentials) +} + +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 +} + +fn build_query(url: &str, path: &str, ids: &[u64]) -> String { + let mut query = String::with_capacity(url.len() + path.len() + (ids.len() * 10)); + query.push_str(url); + query.push_str(path); + append_ids(query, ids) +} + +fn append_ids(mut query: String, ids: &[u64]) -> String { + for (pos, id) in ids.iter().enumerate() { + if pos != 0 { + query.push(','); + } + query.push_str(&id.to_string()); + } + query +} + +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 new file mode 100644 index 00000000..b8fa904b --- /dev/null +++ b/crates/cli/src/modules/report.rs @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2020-2023, Stalwart Labs Ltd. + * + * This file is part of the Stalwart Command Line Interface. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use super::cli::{ReportCommands, ReportFormat}; +use crate::modules::queue::{deserialize_datetime, smtp_manage_request}; +use console::Term; +use human_size::{Byte, SpecificSize}; +use jmap_client::client::Credentials; +use mail_parser::DateTime; +use prettytable::{format::Alignment, Attr, Cell, Row, Table}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Report { + pub domain: String, + #[serde(rename = "type")] + pub type_: ReportFormat, + #[serde(deserialize_with = "deserialize_datetime")] + pub range_from: DateTime, + #[serde(deserialize_with = "deserialize_datetime")] + pub range_to: DateTime, + pub size: usize, +} + +pub fn cmd_report(url: &str, credentials: Credentials, command: ReportCommands) { + match command { + ReportCommands::List { + domain, + format, + page_size, + } => { + let stdout = Term::buffered_stdout(); + let mut query = form_urlencoded::Serializer::new(format!("{url}/report/list?")); + + if let Some(domain) = &domain { + query.append_pair("domain", domain); + } + if let Some(format) = &format { + query.append_pair("type", format.id()); + } + + let ids = smtp_manage_request::>(&query.finish(), &credentials); + 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", "Size"] + .iter() + .map(|p| Cell::new(p).with_style(Attr::Bold)) + .collect(), + )); + for (report, id) in smtp_manage_request::>>( + &format!("{url}/report/status?ids={}", chunk.join(",")), + &credentials, + ) + .into_iter() + .zip(chunk) + { + if let Some(report) = report { + table.add_row(Row::new(vec![ + Cell::new(id), + Cell::new(&report.domain), + Cell::new(report.type_.name()), + Cell::new(&report.range_from.to_rfc822()), + Cell::new(&report.range_to.to_rfc822()), + Cell::new( + &SpecificSize::new(report.size 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 (report, id) in smtp_manage_request::>>( + &format!("{url}/report/status?ids={}", ids.join(",")), + &credentials, + ) + .into_iter() + .zip(&ids) + { + 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_.name()), + ])); + 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("Size").with_style(Attr::Bold), + Cell::new( + &SpecificSize::new(report.size as u32, Byte) + .unwrap() + .to_string(), + ), + ])); + } else { + table.add_row(Row::new(vec![Cell::new_align( + "-- Not found --", + Alignment::CENTER, + ) + .with_hspan(2)])); + } + + eprintln!(); + table.printstd(); + eprintln!(); + } + } + ReportCommands::Cancel { ids } => { + let mut success_count = 0; + let mut failed_list = vec![]; + for (success, id) in smtp_manage_request::>( + &format!("{url}/report/cancel?ids={}", ids.join(",")), + &credentials, + ) + .into_iter() + .zip(ids) + { + if success { + 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", + } + } + + fn name(&self) -> &'static str { + match self { + ReportFormat::Dmarc => "DMARC", + ReportFormat::Tls => "TLS", + } + } +} diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml new file mode 100644 index 00000000..b1849eb6 --- /dev/null +++ b/crates/main/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "mail-server" +description = "Stalwart Mail Server" +authors = [ "Stalwart Labs Ltd. "] +repository = "https://github.com/stalwartlabs/jmap-server" +homepage = "https://stalw.art" +keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] +categories = ["email"] +license = "AGPL-3.0-only" +version = "0.3.0" +edition = "2021" +resolver = "2" + +[[bin]] +name = "stalwart-mail" +path = "src/main.rs" + +[dependencies] +store = { path = "../store" } +jmap = { path = "../jmap" } +jmap_proto = { path = "../jmap-proto" } +smtp = { path = "../smtp", features = ["local_delivery"] } +imap = { path = "../imap" } +managesieve = { path = "../managesieve" } +directory = { path = "../directory" } +utils = { path = "../utils" } +tokio = { version = "1.23", features = ["full"] } +tracing = "0.1" + +[target.'cfg(not(target_env = "msvc"))'.dependencies] +jemallocator = "0.5.0" + +[features] +default = ["sqlite"] +sqlite = ["store/sqlite"] +foundationdb = ["store/foundation"] + diff --git a/tests/Cargo.toml b/tests/Cargo.toml index d07db74a..f666f3a0 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -9,7 +9,7 @@ default = ["sqlite"] sqlite = ["store/sqlite"] foundationdb = ["store/foundation"] -[dev-dependencies] +[dependencies] store = { path = "../crates/store", features = ["test_mode"] } directory = { path = "../crates/directory" } jmap = { path = "../crates/jmap", features = ["test_mode"] } diff --git a/tests/src/imap/search.rs b/tests/src/imap/search.rs index ed6e9941..c48197e6 100644 --- a/tests/src/imap/search.rs +++ b/tests/src/imap/search.rs @@ -134,5 +134,5 @@ pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection) { .await; imap.assert_read(Type::Tagged, ResponseType::Ok) .await - .assert_contains("COUNT 10 ALL 6,4:5,1,10,3,7:8,2,9"); + .assert_contains("COUNT 10 ALL 6,4:5,1,10,9,3,7:8,2"); }