RIP Stalwart CLI (closes #2455)
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
274
Cargo.lock
generated
274
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -27,7 +27,6 @@ members = [
|
||||
"crates/common",
|
||||
"crates/trc",
|
||||
"crates/migration",
|
||||
"crates/cli",
|
||||
"tests",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "stalwart-cli"
|
||||
description = "Stalwart Server CLI"
|
||||
authors = ["Stalwart Labs LLC <hello@stalw.art>"]
|
||||
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" }
|
||||
@@ -1,316 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<String, serde_json::Value> = 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<String, String> =
|
||||
HashMap::from_iter([("client_id".to_string(), "Stalwart_CLI".to_string())]);
|
||||
let response: HashMap<String, serde_json::Value> = 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<String, serde_json::Value> = 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("<unknown>")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Response<T> {
|
||||
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<R: DeserializeOwned, B: Serialize>(
|
||||
&self,
|
||||
method: Method,
|
||||
url: &str,
|
||||
body: Option<B>,
|
||||
) -> 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<R: DeserializeOwned, B: Serialize>(
|
||||
&self,
|
||||
method: Method,
|
||||
url: &str,
|
||||
body: Option<B>,
|
||||
) -> Option<R> {
|
||||
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::<Response<R>>(&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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::<u32, _>(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::<Value, _>(
|
||||
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::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
addresses
|
||||
.into_iter()
|
||||
.map(|address| {
|
||||
PrincipalUpdate::add_item(
|
||||
PrincipalField::Emails,
|
||||
PrincipalValue::String(address),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully updated account {name:?}.");
|
||||
}
|
||||
AccountCommands::RemoveEmail { name, addresses } => {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
addresses
|
||||
.into_iter()
|
||||
.map(|address| {
|
||||
PrincipalUpdate::remove_item(
|
||||
PrincipalField::Emails,
|
||||
PrincipalValue::String(address),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully updated account {name:?}.");
|
||||
}
|
||||
AccountCommands::AddToGroup { name, member_of } => {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
member_of
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
PrincipalUpdate::add_item(
|
||||
PrincipalField::MemberOf,
|
||||
PrincipalValue::String(group),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully updated account {name:?}.");
|
||||
}
|
||||
AccountCommands::RemoveFromGroup { name, member_of } => {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
member_of
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
PrincipalUpdate::remove_item(
|
||||
PrincipalField::MemberOf,
|
||||
PrincipalValue::String(group),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully updated account {name:?}.");
|
||||
}
|
||||
AccountCommands::Delete { name } => {
|
||||
client
|
||||
.http_request::<Value, String>(
|
||||
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::<Principal, String>(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<String>,
|
||||
page: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) {
|
||||
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::<ListResponse, String>(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<String>,
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<String>,
|
||||
/// Authentication credentials
|
||||
#[clap(short, long)]
|
||||
pub credentials: Option<String>,
|
||||
/// Connection timeout in seconds
|
||||
#[clap(short, long)]
|
||||
pub timeout: Option<u64>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Quota in bytes
|
||||
#[clap(short, long)]
|
||||
quota: Option<u32>,
|
||||
/// Whether the account is an administrator
|
||||
#[clap(short, long)]
|
||||
is_admin: Option<bool>,
|
||||
/// E-mail addresses
|
||||
#[clap(short, long)]
|
||||
addresses: Option<Vec<String>>,
|
||||
/// Groups this account is a member of
|
||||
#[clap(short, long)]
|
||||
member_of: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Update an existing user account
|
||||
Update {
|
||||
/// Account login
|
||||
name: String,
|
||||
/// Rename account login
|
||||
#[clap(short, long)]
|
||||
new_name: Option<String>,
|
||||
/// Update password
|
||||
#[clap(short, long)]
|
||||
password: Option<String>,
|
||||
/// Update account description
|
||||
#[clap(short, long)]
|
||||
description: Option<String>,
|
||||
/// Update quota in bytes
|
||||
#[clap(short, long)]
|
||||
quota: Option<u64>,
|
||||
/// Whether the account is an administrator
|
||||
#[clap(short, long)]
|
||||
is_admin: Option<bool>,
|
||||
/// Update e-mail addresses
|
||||
#[clap(short, long)]
|
||||
addresses: Option<Vec<String>>,
|
||||
/// Update groups this account is a member of
|
||||
#[clap(short, long)]
|
||||
member_of: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Add e-mail aliases to a user account
|
||||
AddEmail {
|
||||
/// Account login
|
||||
name: String,
|
||||
/// E-mail aliases to add
|
||||
#[clap(required = true)]
|
||||
addresses: Vec<String>,
|
||||
},
|
||||
|
||||
/// Remove e-mail aliases to a user account
|
||||
RemoveEmail {
|
||||
/// Account login
|
||||
name: String,
|
||||
/// E-mail aliases to remove
|
||||
#[clap(required = true)]
|
||||
addresses: Vec<String>,
|
||||
},
|
||||
|
||||
/// Add a user account to groups
|
||||
AddToGroup {
|
||||
/// Account login
|
||||
name: String,
|
||||
/// Groups to add
|
||||
#[clap(required = true)]
|
||||
member_of: Vec<String>,
|
||||
},
|
||||
|
||||
/// Remove a user account from groups
|
||||
RemoveFromGroup {
|
||||
/// Account login
|
||||
name: String,
|
||||
/// Groups to remove
|
||||
#[clap(required = true)]
|
||||
member_of: Vec<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
/// Maximum number of accounts to list
|
||||
limit: Option<usize>,
|
||||
/// Page number
|
||||
page: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Mailing list members
|
||||
#[clap(short, long)]
|
||||
members: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Update an existing mailing list
|
||||
Update {
|
||||
/// List Name
|
||||
name: String,
|
||||
/// Rename list
|
||||
new_name: Option<String>,
|
||||
/// List email address
|
||||
email: Option<String>,
|
||||
/// Description
|
||||
#[clap(short, long)]
|
||||
description: Option<String>,
|
||||
/// Mailing list members
|
||||
#[clap(short, long)]
|
||||
members: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Add members to a mailing list
|
||||
AddMembers {
|
||||
/// List Name
|
||||
name: String,
|
||||
/// Members to add
|
||||
#[clap(required = true)]
|
||||
members: Vec<String>,
|
||||
},
|
||||
|
||||
/// Remove members from a mailing list
|
||||
RemoveMembers {
|
||||
/// List Name
|
||||
name: String,
|
||||
/// Members to remove
|
||||
#[clap(required = true)]
|
||||
members: Vec<String>,
|
||||
},
|
||||
|
||||
/// Display an existing mailing list
|
||||
Display {
|
||||
/// Mailing list to display
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// List all mailing lists
|
||||
List {
|
||||
/// Filter mailing lists by keywords
|
||||
filter: Option<String>,
|
||||
/// Maximum number of mailing lists to list
|
||||
limit: Option<usize>,
|
||||
/// Page number
|
||||
page: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum GroupCommands {
|
||||
/// Create a group
|
||||
Create {
|
||||
/// Group Name
|
||||
name: String,
|
||||
/// Group email address
|
||||
email: Option<String>,
|
||||
/// Description
|
||||
#[clap(short, long)]
|
||||
description: Option<String>,
|
||||
/// Group members
|
||||
#[clap(short, long)]
|
||||
members: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Update an existing group
|
||||
Update {
|
||||
/// Group Name
|
||||
name: String,
|
||||
/// Rename group
|
||||
new_name: Option<String>,
|
||||
/// Group email address
|
||||
email: Option<String>,
|
||||
/// Description
|
||||
#[clap(short, long)]
|
||||
description: Option<String>,
|
||||
/// Update groups that this group is a member of
|
||||
#[clap(short, long)]
|
||||
members: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Add members to a group
|
||||
AddMembers {
|
||||
/// Group name
|
||||
name: String,
|
||||
/// Groups to add
|
||||
#[clap(required = true)]
|
||||
members: Vec<String>,
|
||||
},
|
||||
|
||||
/// Remove members from a group
|
||||
RemoveMembers {
|
||||
/// Group name
|
||||
name: String,
|
||||
/// Groups to remove
|
||||
#[clap(required = true)]
|
||||
members: Vec<String>,
|
||||
},
|
||||
|
||||
/// Display an existing group
|
||||
Display {
|
||||
/// Group name to display
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// List all groups
|
||||
List {
|
||||
/// Filter groups by keywords
|
||||
filter: Option<String>,
|
||||
/// Maximum number of groups to list
|
||||
limit: Option<usize>,
|
||||
/// Page number
|
||||
page: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Maximum number of domains to list
|
||||
limit: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Selector
|
||||
selector: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
/// Perform Healthcheck
|
||||
Healthcheck {
|
||||
/// Status `ready` (default) or `live` to check for
|
||||
check: Option<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<String>,
|
||||
/// Filter by recipient
|
||||
#[clap(short, long)]
|
||||
rcpt: Option<String>,
|
||||
/// Filter messages due for delivery before a certain datetime
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
before: Option<DateTime>,
|
||||
/// Filter messages due for delivery after a certain datetime
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
after: Option<DateTime>,
|
||||
/// Number of items to show per page
|
||||
#[clap(short, long)]
|
||||
page_size: Option<usize>,
|
||||
},
|
||||
|
||||
/// Displays details about a queued message
|
||||
Status {
|
||||
#[clap(required = true)]
|
||||
ids: Vec<String>,
|
||||
},
|
||||
|
||||
/// Reschedule delivery
|
||||
Retry {
|
||||
/// Apply to messages matching a sender address
|
||||
#[clap(short, long)]
|
||||
sender: Option<String>,
|
||||
/// Apply to a specific domain
|
||||
#[clap(short, long)]
|
||||
domain: Option<String>,
|
||||
/// Apply to messages due before a certain datetime
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
before: Option<DateTime>,
|
||||
/// Apply to messages due after a certain datetime
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
after: Option<DateTime>,
|
||||
/// Schedule delivery at a specific time
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
time: Option<DateTime>,
|
||||
// Reschedule one or multiple message ids
|
||||
ids: Vec<String>,
|
||||
},
|
||||
|
||||
/// Cancel delivery
|
||||
Cancel {
|
||||
/// Apply to messages matching a sender address
|
||||
#[clap(short, long)]
|
||||
sender: Option<String>,
|
||||
/// Apply to specific recipients or domains
|
||||
#[clap(short, long)]
|
||||
rcpt: Option<String>,
|
||||
/// Apply to messages due before a certain datetime
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
before: Option<DateTime>,
|
||||
/// Apply to messages due after a certain datetime
|
||||
#[clap(short, long)]
|
||||
#[arg(value_parser = parse_datetime)]
|
||||
after: Option<DateTime>,
|
||||
// Cancel one or multiple message ids
|
||||
ids: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ReportCommands {
|
||||
/// Shows reports queued for delivery
|
||||
List {
|
||||
/// Filter by report domain
|
||||
#[clap(short, long)]
|
||||
domain: Option<String>,
|
||||
/// Filter by report type
|
||||
#[clap(short, long)]
|
||||
#[clap(value_enum)]
|
||||
format: Option<ReportFormat>,
|
||||
/// Number of items to show per page
|
||||
#[clap(short, long)]
|
||||
page_size: Option<usize>,
|
||||
},
|
||||
|
||||
/// Displays details about a queued report
|
||||
Status {
|
||||
#[clap(required = true)]
|
||||
ids: Vec<String>,
|
||||
},
|
||||
|
||||
/// Cancel report delivery
|
||||
Cancel {
|
||||
#[clap(required = true)]
|
||||
ids: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<DateTime, &'static str> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<String>,
|
||||
},
|
||||
Clear {
|
||||
prefix: String,
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
},
|
||||
Insert {
|
||||
prefix: Option<String>,
|
||||
values: Vec<(String, String)>,
|
||||
assert_empty: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl ServerCommands {
|
||||
pub async fn exec(self, client: Client) {
|
||||
match self {
|
||||
ServerCommands::DatabaseMaintenance {} => {
|
||||
client
|
||||
.http_request::<Value, String>(Method::GET, "/api/store/maintenance", None)
|
||||
.await;
|
||||
eprintln!("Success.");
|
||||
}
|
||||
ServerCommands::ReloadCertificates {} => {
|
||||
client
|
||||
.http_request::<Value, String>(Method::GET, "/api/reload/certificate", None)
|
||||
.await;
|
||||
eprintln!("Success.");
|
||||
}
|
||||
ServerCommands::ReloadConfig {} => {
|
||||
client
|
||||
.http_request::<Value, String>(Method::GET, "/api/reload", None)
|
||||
.await;
|
||||
eprintln!("Success.");
|
||||
}
|
||||
ServerCommands::AddConfig { key, value } => {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
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::<Value, _>(
|
||||
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::<Response<HashMap<String, String>>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<String>,
|
||||
|
||||
pub algorithm: Algorithm,
|
||||
|
||||
pub domain: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
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::<Value, _>(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::<Value, String>(
|
||||
Method::GET,
|
||||
&format!("/api/dkim/{signature_id}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
eprintln!();
|
||||
eprintln!("Public DKIM key for signature {signature_id}: {response}");
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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::<Value, String>(
|
||||
Method::POST,
|
||||
&format!("/api/domain/{name}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully created domain {name:?}");
|
||||
}
|
||||
DomainCommands::Delete { name } => {
|
||||
client
|
||||
.http_request::<Value, String>(
|
||||
Method::DELETE,
|
||||
&format!("/api/domain/{name}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully deleted domain {name:?}");
|
||||
}
|
||||
DomainCommands::DNSRecords { name } => {
|
||||
let records = client
|
||||
.http_request::<Vec<DnsRecord>, 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::<List<String>, 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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<Mailbox> {
|
||||
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<Email> {
|
||||
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<String>,
|
||||
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<SieveScript> {
|
||||
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<String>,
|
||||
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<Identity> {
|
||||
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<VacationResponse> {
|
||||
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<T: Serialize>(path: &Path, name: &str, contents: Vec<T>) -> 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
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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::<u32, _>(Method::POST, "/api/principal", Some(principal))
|
||||
.await;
|
||||
if let Some(members) = members {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
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::<Value, _>(
|
||||
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::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
PrincipalUpdate::add_item(
|
||||
PrincipalField::Members,
|
||||
PrincipalValue::String(group),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully updated group {name:?}.");
|
||||
}
|
||||
GroupCommands::RemoveMembers { name, members } => {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
PrincipalUpdate::remove_item(
|
||||
PrincipalField::Members,
|
||||
PrincipalValue::String(group),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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::<u32, _>(Method::POST, "/api/principal", Some(principal))
|
||||
.await;
|
||||
if let Some(members) = members {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
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::<Value, _>(
|
||||
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::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
PrincipalUpdate::add_item(
|
||||
PrincipalField::Members,
|
||||
PrincipalValue::String(group),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
eprintln!("Successfully updated mailing list {name:?}.");
|
||||
}
|
||||
ListCommands::RemoveMembers { name, members } => {
|
||||
client
|
||||
.http_request::<Value, _>(
|
||||
Method::PATCH,
|
||||
&format!("/api/principal/{name}"),
|
||||
Some(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
PrincipalUpdate::remove_item(
|
||||
PrincipalField::Members,
|
||||
PrincipalValue::String(group),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<u32>,
|
||||
|
||||
#[serde(rename = "type")]
|
||||
pub typ: Option<Type>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quota: Option<u32>,
|
||||
|
||||
#[serde(rename = "usedQuota")]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub used_quota: Option<u64>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub secrets: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub emails: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(rename = "memberOf")]
|
||||
pub member_of: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(rename = "members")]
|
||||
pub members: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[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<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct Response<T> {
|
||||
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<String>),
|
||||
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<T> {
|
||||
fn unwrap_result(self, action: &str) -> T;
|
||||
}
|
||||
|
||||
impl<T> UnwrapResult<T> for Option<T> {
|
||||
fn unwrap_result(self, message: &str) -> T {
|
||||
match self {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
eprintln!("Failed to {}", message);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: Display> UnwrapResult<T> for Result<T, E> {
|
||||
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<u8> {
|
||||
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::<Vec<_>>)
|
||||
.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<String, serde_json::Value> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<Recipient>,
|
||||
#[serde(deserialize_with = "deserialize_datetime")]
|
||||
pub created: DateTime,
|
||||
pub size: usize,
|
||||
#[serde(default)]
|
||||
pub priority: i16,
|
||||
#[serde(default)]
|
||||
pub env_id: Option<String>,
|
||||
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<DateTime>,
|
||||
#[serde(deserialize_with = "deserialize_maybe_datetime", default)]
|
||||
pub next_notify: Option<DateTime>,
|
||||
#[serde(deserialize_with = "deserialize_maybe_datetime", default)]
|
||||
pub expires: Option<DateTime>,
|
||||
#[serde(default)]
|
||||
pub orcpt: Option<String>,
|
||||
}
|
||||
|
||||
#[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::<Message, String>(
|
||||
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::<Message, String>(
|
||||
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::<bool, String>(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::<bool, String>(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<String>,
|
||||
rcpt: &Option<String>,
|
||||
before: &Option<DateTime>,
|
||||
after: &Option<DateTime>,
|
||||
) -> Vec<u64> {
|
||||
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::<List<u64>, String>(Method::GET, &query.finish(), None)
|
||||
.await
|
||||
.items
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_maybe_datetime<'de, D>(deserializer: D) -> Result<Option<DateTime>, 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<DateTime, D::Error>
|
||||
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<u64> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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<ReportUri>,
|
||||
},
|
||||
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<URI>,
|
||||
},
|
||||
}
|
||||
|
||||
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::<List<String>, 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::<Report, String>(
|
||||
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::<Report, String>(
|
||||
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::<bool, String>(
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user