Imported Stalwart CLI crate.
This commit is contained in:
325
Cargo.lock
generated
325
Cargo.lock
generated
@@ -121,6 +121,55 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is-terminal",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.71"
|
||||
@@ -642,6 +691,66 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384e169cc618c613d5e3ca6404dda77a8685a63e08660dcc64abaf7da7cb0c7a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef137bbe35aab78bdb468ccfba75a5f4d8321ae011d34063770780545176af2d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8"
|
||||
dependencies = [
|
||||
"encode_unicode 0.3.6",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.3"
|
||||
@@ -946,6 +1055,16 @@ dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-next"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.7"
|
||||
@@ -957,6 +1076,17 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys-next"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.4"
|
||||
@@ -1042,6 +1172,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.32"
|
||||
@@ -1397,7 +1539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1617,6 +1759,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
|
||||
|
||||
[[package]]
|
||||
name = "human-size"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9994b79e8c1a39b3166c63ae7823bb2b00831e2a96a31399c50fe69df408eaeb"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.27"
|
||||
@@ -1776,6 +1924,19 @@ dependencies = [
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.17.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ff8cc23a7393a397ed1d7f56e6365cba772aba9f9912ab968b03043c395d057"
|
||||
dependencies = [
|
||||
"console",
|
||||
"instant",
|
||||
"number_prefix",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.3"
|
||||
@@ -1823,6 +1984,17 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24fddda5af7e54bf7da53067d6e802dbcc381d0a8eef629df528e3ebf68755cb"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"rustix 0.38.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
@@ -2091,6 +2263,12 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.10"
|
||||
@@ -2467,6 +2645,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "number_prefix"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.31.1"
|
||||
@@ -2693,7 +2877,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall 0.3.5",
|
||||
"smallvec",
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2922,6 +3106,12 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "767eb9f07d4a5ebcb39bbf2d452058a93c011373abf6832e24194a1c3f004794"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.17"
|
||||
@@ -2948,6 +3138,20 @@ dependencies = [
|
||||
"syn 2.0.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettytable-rs"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
|
||||
dependencies = [
|
||||
"csv",
|
||||
"encode_unicode 1.0.0",
|
||||
"is-terminal",
|
||||
"lazy_static",
|
||||
"term",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.2"
|
||||
@@ -3306,6 +3510,17 @@ dependencies = [
|
||||
"librocksdb-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6678cf63ab3491898c0d021b493c94c9b221d91295294a2a5746eacbe5928322"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.8.2"
|
||||
@@ -3326,6 +3541,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "034e22c514f5c0cb8a10ff341b9b048b5ceb21591f31c8f44c43b960f9b3524a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.29.0"
|
||||
@@ -3423,7 +3648,20 @@ dependencies = [
|
||||
"errno",
|
||||
"io-lifetimes",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.3.8",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aabcb0461ebd01d6b79945797c27f8529082226cb630a9865a71870ff63532a4"
|
||||
dependencies = [
|
||||
"bitflags 2.3.3",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.3",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
@@ -4090,6 +4328,28 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stalwart-cli"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"console",
|
||||
"csv",
|
||||
"form_urlencoded",
|
||||
"futures",
|
||||
"human-size",
|
||||
"indicatif",
|
||||
"jmap-client",
|
||||
"mail-parser",
|
||||
"num_cpus",
|
||||
"prettytable-rs",
|
||||
"reqwest",
|
||||
"rpassword",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -4140,6 +4400,12 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.4.1"
|
||||
@@ -4196,10 +4462,21 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"redox_syscall 0.3.5",
|
||||
"rustix",
|
||||
"rustix 0.37.22",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
|
||||
dependencies = [
|
||||
"dirs-next",
|
||||
"rustversion",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tests"
|
||||
version = "0.1.0"
|
||||
@@ -4736,6 +5013,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.4"
|
||||
@@ -4781,6 +5064,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
||||
|
||||
[[package]]
|
||||
name = "utils"
|
||||
version = "0.1.0"
|
||||
@@ -5028,7 +5317,7 @@ version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5046,13 +5335,37 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
83
Cargo.toml
83
Cargo.toml
@@ -1,42 +1,7 @@
|
||||
[package]
|
||||
name = "mail-server"
|
||||
description = "Stalwart Mail Server"
|
||||
authors = [ "Stalwart Labs Ltd. <hello@stalw.art>"]
|
||||
repository = "https://github.com/stalwartlabs/jmap-server"
|
||||
homepage = "https://stalw.art"
|
||||
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
|
||||
categories = ["email"]
|
||||
license = "AGPL-3.0-only"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "stalwart-mail"
|
||||
path = "crates/main/src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
store = { path = "crates/store" }
|
||||
jmap = { path = "crates/jmap" }
|
||||
jmap_proto = { path = "crates/jmap-proto" }
|
||||
smtp = { path = "crates/smtp", features = ["local_delivery"] }
|
||||
imap = { path = "crates/imap" }
|
||||
managesieve = { path = "crates/managesieve" }
|
||||
directory = { path = "crates/directory" }
|
||||
utils = { path = "crates/utils" }
|
||||
tokio = { version = "1.23", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
jemallocator = "0.5.0"
|
||||
|
||||
[features]
|
||||
default = ["sqlite"]
|
||||
sqlite = ["store/sqlite"]
|
||||
foundationdb = ["store/foundation"]
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/main",
|
||||
"crates/jmap",
|
||||
"crates/jmap-proto",
|
||||
"crates/imap",
|
||||
@@ -47,5 +12,49 @@ members = [
|
||||
"crates/directory",
|
||||
"crates/utils",
|
||||
"crates/maybe-async",
|
||||
"crates/cli",
|
||||
"tests",
|
||||
]
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = 1
|
||||
codegen-units = 4
|
||||
lto = false
|
||||
incremental = true
|
||||
panic = 'unwind'
|
||||
debug-assertions = true
|
||||
overflow-checks = false
|
||||
rpath = false
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
incremental = false
|
||||
panic = 'unwind'
|
||||
debug-assertions = false
|
||||
overflow-checks = false
|
||||
rpath = false
|
||||
#strip = true
|
||||
|
||||
[profile.test]
|
||||
opt-level = 0
|
||||
debug = 1
|
||||
#codegen-units = 16
|
||||
lto = false
|
||||
incremental = true
|
||||
debug-assertions = true
|
||||
overflow-checks = true
|
||||
rpath = false
|
||||
|
||||
[profile.bench]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
incremental = false
|
||||
debug-assertions = false
|
||||
overflow-checks = false
|
||||
rpath = false
|
||||
|
||||
29
crates/cli/Cargo.toml
Normal file
29
crates/cli/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "stalwart-cli"
|
||||
description = "Stalwart Mail Server CLI"
|
||||
authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/stalwartlabs/cli"
|
||||
homepage = "https://github.com/stalwartlabs/cli"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
resolver = "2"
|
||||
|
||||
[dependencies]
|
||||
jmap-client = { git = "https://github.com/stalwartlabs/jmap-client", features = ["async"] }
|
||||
mail-parser = { git = "https://github.com/stalwartlabs/mail-parser", features = ["full_encoding", "serde_support", "ludicrous_mode"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"]}
|
||||
tokio = { version = "1.23", features = ["full"] }
|
||||
num_cpus = "1.13.1"
|
||||
clap = { version = "4.1.6", features = ["derive"] }
|
||||
prettytable-rs = "0.10.0"
|
||||
rpassword = "7.0"
|
||||
indicatif = "0.17.0"
|
||||
console = { version = "0.15", default-features = false, features = ["ansi-parsing"] }
|
||||
serde = { version = "1.0", features = ["derive"]}
|
||||
serde_json = "1.0"
|
||||
csv = "1.1"
|
||||
form_urlencoded = "1.1.0"
|
||||
human-size = "0.4.2"
|
||||
futures = "0.3.28"
|
||||
135
crates/cli/src/main.rs
Normal file
135
crates/cli/src/main.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
|
||||
*
|
||||
* This file is part of the Stalwart Command Line Interface.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* in the LICENSE file at the top-level directory of this distribution.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* You can be released from the requirements of the AGPLv3 license by
|
||||
* purchasing a commercial license. Please contact licensing@stalw.art
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{BufRead, Write},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
use console::style;
|
||||
use jmap_client::client::{Client, Credentials};
|
||||
use modules::{
|
||||
cli::{Cli, Commands},
|
||||
get,
|
||||
import::cmd_import,
|
||||
post,
|
||||
queue::cmd_queue,
|
||||
report::cmd_report,
|
||||
};
|
||||
|
||||
use crate::modules::OAuthResponse;
|
||||
|
||||
pub mod modules;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let args = Cli::parse();
|
||||
let is_jmap = args.command.is_jmap();
|
||||
let credentials = if let Some(credentials) = args.credentials {
|
||||
parse_credentials(&credentials)
|
||||
} else if is_jmap {
|
||||
let credentials = rpassword::prompt_password(
|
||||
"\nEnter JMAP admin credentials or press [ENTER] to use OAuth: ",
|
||||
)
|
||||
.unwrap();
|
||||
if !credentials.is_empty() {
|
||||
parse_credentials(&credentials)
|
||||
} else {
|
||||
oauth(&args.url)
|
||||
}
|
||||
} else {
|
||||
parse_credentials(&rpassword::prompt_password("\nEnter SMTP admin credentials: ").unwrap())
|
||||
};
|
||||
|
||||
if is_jmap {
|
||||
let client = Client::new()
|
||||
.credentials(credentials)
|
||||
.connect(&args.url)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("Failed to connect to JMAP server {}: {}.", args.url, err);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
match args.command {
|
||||
Commands::Import(command) => cmd_import(client, command).await,
|
||||
Commands::Queue(_) | Commands::Report(_) => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
match args.command {
|
||||
Commands::Queue(command) => cmd_queue(&args.url, credentials, command),
|
||||
Commands::Report(command) => cmd_report(&args.url, credentials, command),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_credentials(credentials: &str) -> Credentials {
|
||||
if let Some((account, secret)) = credentials.split_once(':') {
|
||||
Credentials::basic(account, secret)
|
||||
} else {
|
||||
Credentials::basic("admin", credentials)
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth(url: &str) -> Credentials {
|
||||
let metadata = get(&format!("{}/.well-known/oauth-authorization-server", url));
|
||||
let token_endpoint = metadata.property("token_endpoint");
|
||||
let mut params = HashMap::from_iter([("client_id".to_string(), "Stalwart_CLI".to_string())]);
|
||||
let response = post(metadata.property("device_authorization_endpoint"), ¶ms);
|
||||
|
||||
params.insert(
|
||||
"grant_type".to_string(),
|
||||
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
||||
);
|
||||
params.insert(
|
||||
"device_code".to_string(),
|
||||
response.property("device_code").to_string(),
|
||||
);
|
||||
|
||||
print!(
|
||||
"\nAuthenticate this request using code {} at {}. Please ENTER when done.",
|
||||
style(response.property("user_code")).bold(),
|
||||
style(response.property("verification_uri")).bold().dim()
|
||||
);
|
||||
|
||||
std::io::stdout().flush().unwrap();
|
||||
std::io::stdin().lock().lines().next();
|
||||
|
||||
let mut response = post(token_endpoint, ¶ms);
|
||||
if let Some(serde_json::Value::String(access_token)) = response.remove("access_token") {
|
||||
Credentials::Bearer(access_token)
|
||||
} else {
|
||||
eprintln!(
|
||||
"OAuth failed with code {}.",
|
||||
response
|
||||
.get("error")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("<unknown>")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
213
crates/cli/src/modules/cli.rs
Normal file
213
crates/cli/src/modules/cli.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
|
||||
*
|
||||
* This file is part of the Stalwart Command Line Interface.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* in the LICENSE file at the top-level directory of this distribution.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* You can be released from the requirements of the AGPLv3 license by
|
||||
* purchasing a commercial license. Please contact licensing@stalw.art
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use mail_parser::DateTime;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[clap(version, about, long_about = None)]
|
||||
#[clap(name = "stalwart-cli")]
|
||||
pub struct Cli {
|
||||
#[clap(subcommand)]
|
||||
pub command: Commands,
|
||||
/// JMAP or SMTP server base URL
|
||||
#[clap(short, long)]
|
||||
pub url: String,
|
||||
/// Authentication credentials
|
||||
#[clap(short, long)]
|
||||
pub credentials: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Import accounts and domains
|
||||
#[clap(subcommand)]
|
||||
Import(ImportCommands),
|
||||
|
||||
/// Manage SMTP message queue
|
||||
#[clap(subcommand)]
|
||||
Queue(QueueCommands),
|
||||
|
||||
/// Manage SMTP DMARC/TLS report queue
|
||||
#[clap(subcommand)]
|
||||
Report(ReportCommands),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ImportCommands {
|
||||
/// Import messages and folders
|
||||
Messages {
|
||||
#[clap(value_enum)]
|
||||
#[clap(short, long)]
|
||||
format: MailboxFormat,
|
||||
|
||||
/// Number of threads to use for message import, defaults to the number of CPUs.
|
||||
#[clap(short, long)]
|
||||
num_threads: Option<usize>,
|
||||
|
||||
/// Account id to import messages into
|
||||
account_id: String,
|
||||
|
||||
/// Path to the mailbox to import, or '-' for stdin (stdin only supported for mbox)
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
|
||||
pub enum MailboxFormat {
|
||||
/// Mbox format
|
||||
Mbox,
|
||||
/// Maildir and Maildir++ formats
|
||||
Maildir,
|
||||
/// Maildir with hierarchical folders (i.e. Dovecot)
|
||||
MaildirNested,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum QueueCommands {
|
||||
/// Shows messages queued for delivery
|
||||
List {
|
||||
/// Filter by sender address
|
||||
#[clap(short, long)]
|
||||
sender: Option<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>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub fn is_jmap(&self) -> bool {
|
||||
!matches!(self, Commands::Queue(_) | Commands::Report(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Deserialize)]
|
||||
pub enum ReportFormat {
|
||||
/// DMARC report
|
||||
#[serde(rename = "dmarc")]
|
||||
Dmarc,
|
||||
/// TLS report
|
||||
#[serde(rename = "tls")]
|
||||
Tls,
|
||||
}
|
||||
|
||||
fn parse_datetime(arg: &str) -> Result<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")
|
||||
}
|
||||
}
|
||||
444
crates/cli/src/modules/import.rs
Normal file
444
crates/cli/src/modules/import.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
|
||||
*
|
||||
* This file is part of the Stalwart Command Line Interface.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* in the LICENSE file at the top-level directory of this distribution.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* You can be released from the requirements of the AGPLv3 license by
|
||||
* purchasing a commercial license. Please contact licensing@stalw.art
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{self, Cursor},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use console::style;
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use jmap_client::{
|
||||
client::Client,
|
||||
core::set::SetObject,
|
||||
mailbox::{self, Role},
|
||||
};
|
||||
use mail_parser::mailbox::{
|
||||
maildir,
|
||||
mbox::{self, MessageIterator},
|
||||
};
|
||||
|
||||
use crate::modules::UnwrapResult;
|
||||
|
||||
use super::{
|
||||
cli::{ImportCommands, MailboxFormat},
|
||||
read_file,
|
||||
};
|
||||
|
||||
enum Mailbox {
|
||||
Mbox(mbox::MessageIterator<Cursor<Vec<u8>>>),
|
||||
Maildir(maildir::MessageIterator),
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MailboxId<'x> {
|
||||
ExistingId(&'x str),
|
||||
CreateId(String),
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Message {
|
||||
identifier: String,
|
||||
flags: Vec<maildir::Flag>,
|
||||
internal_date: u64,
|
||||
contents: Vec<u8>,
|
||||
}
|
||||
|
||||
pub async fn cmd_import(client: Client, command: ImportCommands) {
|
||||
match command {
|
||||
ImportCommands::Messages {
|
||||
num_threads,
|
||||
format,
|
||||
account_id,
|
||||
path,
|
||||
} => {
|
||||
let account_id = Arc::new(account_id);
|
||||
let mut create_mailboxes = Vec::new();
|
||||
let mut create_mailbox_names = Vec::new();
|
||||
let mut create_mailbox_ids = Vec::new();
|
||||
|
||||
eprintln!("{} Parsing mailbox...", style("[1/4]").bold().dim(),);
|
||||
|
||||
match format {
|
||||
MailboxFormat::Mbox => {
|
||||
create_mailbox_names.push(Vec::new());
|
||||
create_mailboxes.push(Mailbox::Mbox(MessageIterator::new(Cursor::new(
|
||||
read_file(&path),
|
||||
))));
|
||||
}
|
||||
MailboxFormat::Maildir | MailboxFormat::MaildirNested => {
|
||||
let (folder_sep, folder_split) = if format == MailboxFormat::Maildir {
|
||||
(Some("."), ".")
|
||||
} else {
|
||||
(None, "/")
|
||||
};
|
||||
|
||||
for folder in maildir::FolderIterator::new(path, folder_sep)
|
||||
.unwrap_result("read Maildir folder")
|
||||
{
|
||||
let folder = folder.unwrap_result("read Maildir folder");
|
||||
if let Some(folder_name) = folder.name() {
|
||||
let mut folder_parts = Vec::new();
|
||||
for folder_name in folder_name.split(folder_split) {
|
||||
let mut folder_name = folder_name.trim();
|
||||
if folder_name.is_empty() {
|
||||
folder_name = ".";
|
||||
}
|
||||
folder_parts.push(folder_name.to_string());
|
||||
if !create_mailbox_names.contains(&folder_parts) {
|
||||
create_mailboxes.push(Mailbox::None);
|
||||
create_mailbox_names.push(folder_parts.clone());
|
||||
}
|
||||
}
|
||||
|
||||
*create_mailboxes.last_mut().unwrap() = Mailbox::Maildir(folder);
|
||||
} else {
|
||||
create_mailboxes.push(Mailbox::Maildir(folder));
|
||||
create_mailbox_names.push(Vec::new());
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all mailboxes for the account
|
||||
eprintln!(
|
||||
"{} Fetching existing mailboxes for account...",
|
||||
style("[2/4]").bold().dim(),
|
||||
);
|
||||
|
||||
let mut inbox_id = None;
|
||||
let mut mailbox_ids = HashMap::new();
|
||||
let mut children: HashMap<Option<&str>, Vec<&str>> =
|
||||
HashMap::from_iter([(None, Vec::new())]);
|
||||
let mut request = client.build();
|
||||
request
|
||||
.get_mailbox()
|
||||
.account_id(account_id.as_ref())
|
||||
.properties([
|
||||
mailbox::Property::Name,
|
||||
mailbox::Property::ParentId,
|
||||
mailbox::Property::Role,
|
||||
mailbox::Property::Id,
|
||||
]);
|
||||
let response = request
|
||||
.send_get_mailbox()
|
||||
.await
|
||||
.unwrap_result("fetch mailboxes");
|
||||
for mailbox in response.list() {
|
||||
let mailbox_id = mailbox.id().unwrap();
|
||||
if mailbox.role() == Role::Inbox {
|
||||
inbox_id = mailbox_id.into();
|
||||
}
|
||||
children
|
||||
.entry(mailbox.parent_id())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(mailbox_id);
|
||||
mailbox_ids.insert(mailbox_id, mailbox.name().unwrap_or("Untitled"));
|
||||
}
|
||||
let inbox_id =
|
||||
inbox_id.unwrap_result("locate Inbox on account, please check the server logs.");
|
||||
let mut it = children.get(&None).unwrap().iter();
|
||||
let mut it_stack = Vec::new();
|
||||
let mut name_stack = Vec::new();
|
||||
let mut mailbox_names = HashMap::with_capacity(mailbox_ids.len());
|
||||
|
||||
// Build mailbox hierarchy on the server
|
||||
eprintln!(
|
||||
"{} Creating missing mailboxes...",
|
||||
style("[3/4]").bold().dim(),
|
||||
);
|
||||
|
||||
loop {
|
||||
while let Some(mailbox_id) = it.next() {
|
||||
let name = mailbox_ids[mailbox_id];
|
||||
let mut mailbox_name = name_stack.clone();
|
||||
mailbox_name.push(name.to_string());
|
||||
|
||||
mailbox_names.insert(mailbox_name, mailbox_id);
|
||||
if let Some(next_it) = children.get(&Some(mailbox_id)).map(|c| c.iter()) {
|
||||
name_stack.push(name.to_string());
|
||||
it_stack.push(it);
|
||||
it = next_it;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prev_it) = it_stack.pop() {
|
||||
name_stack.pop();
|
||||
it = prev_it;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether the mailboxes to be created already exist
|
||||
let mut has_missing_mailboxes = false;
|
||||
for mailbox_name in &create_mailbox_names {
|
||||
create_mailbox_ids.push(if !mailbox_name.is_empty() {
|
||||
if let Some(mailbox_id) = mailbox_names.get(mailbox_name) {
|
||||
MailboxId::ExistingId(mailbox_id)
|
||||
} else {
|
||||
has_missing_mailboxes = true;
|
||||
MailboxId::None
|
||||
}
|
||||
} else {
|
||||
MailboxId::ExistingId(inbox_id)
|
||||
});
|
||||
}
|
||||
|
||||
// Create any missing mailboxes
|
||||
if has_missing_mailboxes {
|
||||
let mut request = client.build();
|
||||
let set_request = request.set_mailbox().account_id(account_id.as_ref());
|
||||
|
||||
for pos in 0..create_mailbox_ids.len() {
|
||||
if let MailboxId::None = create_mailbox_ids[pos] {
|
||||
let mailbox_name = &create_mailbox_names[pos];
|
||||
let create_request =
|
||||
set_request.create().name(mailbox_name.last().unwrap());
|
||||
|
||||
if mailbox_name.len() > 1 {
|
||||
let parent_mailbox_name = &mailbox_name[..mailbox_name.len() - 1];
|
||||
let parent_mailbox_pos = create_mailbox_names
|
||||
.iter()
|
||||
.position(|n| n == parent_mailbox_name)
|
||||
.unwrap();
|
||||
match &create_mailbox_ids[parent_mailbox_pos] {
|
||||
MailboxId::ExistingId(id) => {
|
||||
create_request.parent_id((*id).into());
|
||||
}
|
||||
MailboxId::CreateId(id_ref) => {
|
||||
create_request.parent_id_ref(id_ref);
|
||||
}
|
||||
MailboxId::None => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
create_request.parent_id(None::<String>);
|
||||
}
|
||||
create_mailbox_ids[pos] =
|
||||
MailboxId::CreateId(create_request.create_id().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Create mailboxes
|
||||
let mut response = request
|
||||
.send_set_mailbox()
|
||||
.await
|
||||
.unwrap_result("create mailboxes");
|
||||
for create_mailbox_id in create_mailbox_ids.iter_mut() {
|
||||
if let MailboxId::CreateId(id) = create_mailbox_id {
|
||||
*id = response
|
||||
.created(id)
|
||||
.unwrap_result("create mailbox")
|
||||
.take_id();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import messages
|
||||
eprintln!("{} Importing messages...", style("[4/4]").bold().dim(),);
|
||||
|
||||
let client = Arc::new(client);
|
||||
let total_imported = Arc::new(AtomicUsize::from(0));
|
||||
let m = MultiProgress::new();
|
||||
let num_threads = num_threads.unwrap_or_else(|| num_cpus::get());
|
||||
let spinner_style =
|
||||
ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
|
||||
.unwrap()
|
||||
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
|
||||
let pbs = Arc::new(Mutex::new((
|
||||
(0..num_threads)
|
||||
.map(|n| {
|
||||
let pb = m.add(ProgressBar::new(40));
|
||||
pb.set_style(spinner_style.clone());
|
||||
pb.set_prefix(format!("[{}/?]", n + 1));
|
||||
pb
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
0usize,
|
||||
)));
|
||||
let failures = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut message_num = 0;
|
||||
|
||||
for ((mut mailbox, mailbox_id), mailbox_name) in create_mailboxes
|
||||
.into_iter()
|
||||
.zip(create_mailbox_ids)
|
||||
.zip(create_mailbox_names)
|
||||
{
|
||||
let mut futures = FuturesUnordered::new();
|
||||
let mut outputs = Vec::new();
|
||||
let mailbox_id = Arc::new(match mailbox_id {
|
||||
MailboxId::ExistingId(id) => id.to_string(),
|
||||
MailboxId::CreateId(id) => id,
|
||||
MailboxId::None => unreachable!(),
|
||||
});
|
||||
let mailbox_name = Arc::new(if !mailbox_name.is_empty() {
|
||||
mailbox_name.join("/")
|
||||
} else {
|
||||
"Inbox".to_string()
|
||||
});
|
||||
|
||||
while let Some(result) = mailbox.next() {
|
||||
match result {
|
||||
Ok(message) => {
|
||||
message_num += 1;
|
||||
let client = client.clone();
|
||||
let account_id = account_id.clone();
|
||||
let mailbox_id = mailbox_id.clone();
|
||||
let mailbox_name = mailbox_name.clone();
|
||||
let total_imported = total_imported.clone();
|
||||
let pbs = pbs.clone();
|
||||
let failures = failures.clone();
|
||||
|
||||
futures.push(async move {
|
||||
// Update progress bar
|
||||
{
|
||||
let mut pbs = pbs.lock().unwrap();
|
||||
let pb = &pbs.0[pbs.1 % pbs.0.len()];
|
||||
pb.set_message(format!(
|
||||
"Importing {}: {}/{}",
|
||||
message_num, mailbox_name, message.identifier
|
||||
));
|
||||
pb.inc(1);
|
||||
pbs.1 += 1;
|
||||
}
|
||||
|
||||
if let Err(err) = client
|
||||
.email_import_account(
|
||||
&account_id,
|
||||
message.contents,
|
||||
[mailbox_id.as_ref()],
|
||||
if !message.flags.is_empty() {
|
||||
message
|
||||
.flags
|
||||
.into_iter()
|
||||
.map(|f| match f {
|
||||
maildir::Flag::Passed => "$passed",
|
||||
maildir::Flag::Replied => "$answered",
|
||||
maildir::Flag::Seen => "$seen",
|
||||
maildir::Flag::Trashed => "$deleted",
|
||||
maildir::Flag::Draft => "$draft",
|
||||
maildir::Flag::Flagged => "$flagged",
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if message.internal_date > 0 {
|
||||
(message.internal_date as i64).into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
failures.lock().unwrap().push(format!(
|
||||
concat!(
|
||||
"Failed to import message {} ",
|
||||
"with identifier '{}': {}"
|
||||
),
|
||||
message_num, message.identifier, err
|
||||
));
|
||||
} else {
|
||||
total_imported.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
if futures.len() == num_threads {
|
||||
outputs.push(futures.next().await.unwrap());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("I/O error reading message: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining futures
|
||||
while let Some(item) = futures.next().await {
|
||||
outputs.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Done
|
||||
for pb in pbs.lock().unwrap().0.iter() {
|
||||
pb.finish_with_message("Done");
|
||||
}
|
||||
let failures = failures.lock().unwrap();
|
||||
eprintln!(
|
||||
"\n\nSuccessfully imported {} messages.\n",
|
||||
total_imported.load(Ordering::Relaxed)
|
||||
);
|
||||
|
||||
if !failures.is_empty() {
|
||||
eprintln!("There were {} failures:\n", failures.len());
|
||||
for failure in failures.iter() {
|
||||
eprintln!("{}", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Mailbox {
|
||||
type Item = io::Result<Message>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self {
|
||||
Mailbox::Mbox(it) => it.next().map(|r| {
|
||||
r.map(|m| Message {
|
||||
identifier: m.from().to_string(),
|
||||
flags: Vec::new(),
|
||||
internal_date: m.internal_date(),
|
||||
contents: m.unwrap_contents(),
|
||||
})
|
||||
.map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::Other, "Failed to parse from mbox file.")
|
||||
})
|
||||
}),
|
||||
Mailbox::Maildir(it) => it.next().map(|r| {
|
||||
r.map(|m| Message {
|
||||
identifier: m
|
||||
.path()
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
flags: m.flags().to_vec(),
|
||||
internal_date: m.internal_date(),
|
||||
contents: m.unwrap_contents(),
|
||||
})
|
||||
}),
|
||||
Mailbox::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
149
crates/cli/src/modules/mod.rs
Normal file
149
crates/cli/src/modules/mod.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
|
||||
*
|
||||
* This file is part of the Stalwart Command Line Interface.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* in the LICENSE file at the top-level directory of this distribution.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* You can be released from the requirements of the AGPLv3 license by
|
||||
* purchasing a commercial license. Please contact licensing@stalw.art
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
use std::{collections::HashMap, fmt::Display, io::Read};
|
||||
|
||||
use jmap_client::principal::Property;
|
||||
|
||||
pub mod cli;
|
||||
pub mod import;
|
||||
pub mod queue;
|
||||
pub mod report;
|
||||
|
||||
pub trait UnwrapResult<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait TableName {
|
||||
fn table_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
impl TableName for Property {
|
||||
fn table_name(&self) -> &'static str {
|
||||
match self {
|
||||
Property::Id => "Id",
|
||||
Property::Type => "Type",
|
||||
Property::Name => "Name",
|
||||
Property::Description => "Description",
|
||||
Property::Email => "E-mail",
|
||||
Property::Timezone => "Timezone",
|
||||
Property::Capabilities => "Capabilities",
|
||||
Property::Aliases => "Aliases",
|
||||
Property::Secret => "Secret",
|
||||
Property::DKIM => "DKIM",
|
||||
Property::Quota => "Quota",
|
||||
Property::Picture => "Picture",
|
||||
Property::Members => "Members",
|
||||
Property::ACL => "ACL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_file(path: &str) -> Vec<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 fn get(url: &str) -> HashMap<String, serde_json::Value> {
|
||||
serde_json::from_slice(
|
||||
&reqwest::blocking::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
.get(url)
|
||||
.send()
|
||||
.unwrap_result("send OAuth GET request")
|
||||
.bytes()
|
||||
.unwrap_result("fetch bytes"),
|
||||
)
|
||||
.unwrap_result("deserialize OAuth GET response")
|
||||
}
|
||||
|
||||
pub fn post(url: &str, params: &HashMap<String, String>) -> HashMap<String, serde_json::Value> {
|
||||
serde_json::from_slice(
|
||||
&reqwest::blocking::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
.post(url)
|
||||
.form(params)
|
||||
.send()
|
||||
.unwrap_result("send OAuth POST request")
|
||||
.bytes()
|
||||
.unwrap_result("fetch bytes"),
|
||||
)
|
||||
.unwrap_result("deserialize OAuth POST response")
|
||||
}
|
||||
|
||||
pub trait OAuthResponse {
|
||||
fn property(&self, name: &str) -> &str;
|
||||
}
|
||||
|
||||
impl OAuthResponse for HashMap<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))
|
||||
}
|
||||
}
|
||||
548
crates/cli/src/modules/queue.rs
Normal file
548
crates/cli/src/modules/queue.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
|
||||
*
|
||||
* This file is part of the Stalwart Command Line Interface.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* in the LICENSE file at the top-level directory of this distribution.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* You can be released from the requirements of the AGPLv3 license by
|
||||
* purchasing a commercial license. Please contact licensing@stalw.art
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
use super::{cli::QueueCommands, UnwrapResult};
|
||||
use console::Term;
|
||||
use human_size::{Byte, SpecificSize};
|
||||
use jmap_client::client::Credentials;
|
||||
use mail_parser::DateTime;
|
||||
use prettytable::{format::Alignment, Attr, Cell, Row, Table};
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Deserializer};
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub return_path: String,
|
||||
pub domains: Vec<Domain>,
|
||||
#[serde(deserialize_with = "deserialize_datetime")]
|
||||
pub created: DateTime,
|
||||
pub size: usize,
|
||||
#[serde(default)]
|
||||
pub priority: i16,
|
||||
pub env_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct Domain {
|
||||
pub name: String,
|
||||
pub status: Status,
|
||||
pub recipients: Vec<Recipient>,
|
||||
|
||||
pub retry_num: u32,
|
||||
#[serde(deserialize_with = "deserialize_maybe_datetime")]
|
||||
pub next_retry: Option<DateTime>,
|
||||
#[serde(deserialize_with = "deserialize_maybe_datetime")]
|
||||
pub next_notify: Option<DateTime>,
|
||||
#[serde(deserialize_with = "deserialize_datetime")]
|
||||
pub expires: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct Recipient {
|
||||
pub address: String,
|
||||
pub status: Status,
|
||||
pub orcpt: Option<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),
|
||||
}
|
||||
|
||||
pub fn cmd_queue(url: &str, credentials: Credentials, command: QueueCommands) {
|
||||
match command {
|
||||
QueueCommands::List {
|
||||
sender,
|
||||
rcpt,
|
||||
before,
|
||||
after,
|
||||
page_size,
|
||||
} => {
|
||||
let stdout = Term::buffered_stdout();
|
||||
let ids = query_messages(url, &credentials, &sender, &rcpt, &before, &after);
|
||||
let ids_len = ids.len();
|
||||
let page_size = page_size.map(|p| std::cmp::max(p, 1)).unwrap_or(20);
|
||||
let pages_total = (ids_len as f64 / page_size as f64).ceil() as usize;
|
||||
for (page_num, chunk) in ids.chunks(page_size).enumerate() {
|
||||
// Build table
|
||||
let mut table = Table::new();
|
||||
table.add_row(Row::new(
|
||||
["ID", "Delivery Due", "Sender", "Recipients", "Size"]
|
||||
.iter()
|
||||
.map(|p| Cell::new(p).with_style(Attr::Bold))
|
||||
.collect(),
|
||||
));
|
||||
for (message, id) in smtp_manage_request::<Vec<Option<Message>>>(
|
||||
&build_query(url, "/queue/status?ids=", chunk),
|
||||
&credentials,
|
||||
)
|
||||
.into_iter()
|
||||
.zip(chunk)
|
||||
{
|
||||
if let Some(message) = message {
|
||||
let mut rcpts = String::new();
|
||||
let mut deliver_at = i64::MAX;
|
||||
let mut deliver_pos = 0;
|
||||
for (pos, domain) in message.domains.iter().enumerate() {
|
||||
if let Some(next_retry) = &domain.next_retry {
|
||||
let ts = next_retry.to_timestamp();
|
||||
if ts < deliver_at {
|
||||
deliver_at = ts;
|
||||
deliver_pos = pos;
|
||||
}
|
||||
}
|
||||
for rcpt in &domain.recipients {
|
||||
if !rcpts.is_empty() {
|
||||
rcpts.push('\n');
|
||||
}
|
||||
rcpts.push_str(&rcpt.address);
|
||||
rcpts.push_str(" (");
|
||||
rcpts.push_str(rcpt.status.status_short());
|
||||
rcpts.push(')');
|
||||
}
|
||||
}
|
||||
|
||||
let mut cells = Vec::new();
|
||||
cells.push(Cell::new(&format!("{id:X}")));
|
||||
cells.push(if deliver_at != i64::MAX {
|
||||
Cell::new(
|
||||
&message.domains[deliver_pos]
|
||||
.next_retry
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_rfc822(),
|
||||
)
|
||||
} else {
|
||||
Cell::new("None")
|
||||
});
|
||||
cells.push(Cell::new(if !message.return_path.is_empty() {
|
||||
&message.return_path
|
||||
} else {
|
||||
"<>"
|
||||
}));
|
||||
cells.push(Cell::new(&rcpts));
|
||||
cells.push(Cell::new(
|
||||
&SpecificSize::new(message.size as u32, Byte)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
));
|
||||
table.add_row(Row::new(cells));
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
table.printstd();
|
||||
eprintln!();
|
||||
if page_num + 1 != pages_total {
|
||||
eprintln!("\n--- Press any key to continue or 'q' to exit ---");
|
||||
if let Ok('q' | 'Q') = stdout.read_char() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("\n{ids_len} queued message(s) found.")
|
||||
}
|
||||
QueueCommands::Status { ids } => {
|
||||
for (message, id) in smtp_manage_request::<Vec<Option<Message>>>(
|
||||
&build_query(url, "/queue/status?ids=", &parse_ids(&ids)),
|
||||
&credentials,
|
||||
)
|
||||
.into_iter()
|
||||
.zip(&ids)
|
||||
{
|
||||
let mut table = Table::new();
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("ID").with_style(Attr::Bold),
|
||||
Cell::new(id),
|
||||
]));
|
||||
if let Some(message) = message {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Sender").with_style(Attr::Bold),
|
||||
Cell::new(if !message.return_path.is_empty() {
|
||||
&message.return_path
|
||||
} else {
|
||||
"<>"
|
||||
}),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Created").with_style(Attr::Bold),
|
||||
Cell::new(&message.created.to_rfc822()),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Size").with_style(Attr::Bold),
|
||||
Cell::new(
|
||||
&SpecificSize::new(message.size as u32, Byte)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
),
|
||||
]));
|
||||
if let Some(env_id) = &message.env_id {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Env-Id").with_style(Attr::Bold),
|
||||
Cell::new(env_id),
|
||||
]));
|
||||
}
|
||||
if message.priority != 0 {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Priority").with_style(Attr::Bold),
|
||||
Cell::new(&message.priority.to_string()),
|
||||
]));
|
||||
}
|
||||
for domain in &message.domains {
|
||||
table.add_row(Row::new(vec![Cell::new_align(
|
||||
&domain.name,
|
||||
Alignment::RIGHT,
|
||||
)
|
||||
.with_style(Attr::Bold)
|
||||
.with_style(Attr::Italic(true))
|
||||
.with_hspan(2)]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Status").with_style(Attr::Bold),
|
||||
Cell::new(domain.status.status()),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Details").with_style(Attr::Bold),
|
||||
Cell::new(domain.status.details()),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Retry #").with_style(Attr::Bold),
|
||||
Cell::new(&domain.retry_num.to_string()),
|
||||
]));
|
||||
if let Some(dt) = &domain.next_retry {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Delivery Due").with_style(Attr::Bold),
|
||||
Cell::new(&dt.to_rfc822()),
|
||||
]));
|
||||
}
|
||||
if let Some(dt) = &domain.next_notify {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Notify at").with_style(Attr::Bold),
|
||||
Cell::new(&dt.to_rfc822()),
|
||||
]));
|
||||
}
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Expires").with_style(Attr::Bold),
|
||||
Cell::new(&domain.expires.to_rfc822()),
|
||||
]));
|
||||
|
||||
let mut rcpts = Table::new();
|
||||
rcpts.add_row(Row::new(vec![
|
||||
Cell::new("Address").with_style(Attr::Bold),
|
||||
Cell::new("Status").with_style(Attr::Bold),
|
||||
Cell::new("Details").with_style(Attr::Bold),
|
||||
]));
|
||||
for rcpt in &domain.recipients {
|
||||
rcpts.add_row(Row::new(vec![
|
||||
Cell::new(&rcpt.address),
|
||||
Cell::new(rcpt.status.status()),
|
||||
Cell::new(rcpt.status.details()),
|
||||
]));
|
||||
}
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Recipients").with_style(Attr::Bold),
|
||||
Cell::from(&rcpts),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
table.add_row(Row::new(vec![Cell::new_align(
|
||||
"-- Not found --",
|
||||
Alignment::CENTER,
|
||||
)
|
||||
.with_hspan(2)]));
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
table.printstd();
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
QueueCommands::Retry {
|
||||
sender,
|
||||
domain,
|
||||
before,
|
||||
after,
|
||||
time,
|
||||
ids,
|
||||
} => {
|
||||
let (parsed_ids, ids) = if ids.is_empty() {
|
||||
if sender.is_some() || domain.is_some() || before.is_some() || after.is_some() {
|
||||
let parsed_ids =
|
||||
query_messages(url, &credentials, &sender, &domain, &before, &after);
|
||||
let ids = parsed_ids.iter().map(|id| format!("{id:X}")).collect();
|
||||
(parsed_ids, ids)
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
}
|
||||
} else {
|
||||
(parse_ids(&ids), ids)
|
||||
};
|
||||
|
||||
if ids.is_empty() {
|
||||
eprintln!("No messages were found.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut query = form_urlencoded::Serializer::new(format!("{url}/queue/retry?"));
|
||||
|
||||
if let Some(filter) = &domain {
|
||||
query.append_pair("filter", filter);
|
||||
}
|
||||
if let Some(at) = time {
|
||||
query.append_pair("at", &at.to_rfc3339());
|
||||
}
|
||||
query.append_pair("ids", &append_ids(String::new(), &parsed_ids));
|
||||
|
||||
let mut success_count = 0;
|
||||
let mut failed_list = vec![];
|
||||
for (success, id) in smtp_manage_request::<Vec<bool>>(&query.finish(), &credentials)
|
||||
.into_iter()
|
||||
.zip(ids)
|
||||
{
|
||||
if success {
|
||||
success_count += 1;
|
||||
} else {
|
||||
failed_list.push(id);
|
||||
}
|
||||
}
|
||||
eprint!("\nSuccessfully rescheduled {success_count} message(s).");
|
||||
if !failed_list.is_empty() {
|
||||
eprint!(" Unable to reschedule id(s): {}.", failed_list.join(", "));
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
QueueCommands::Cancel {
|
||||
sender,
|
||||
rcpt,
|
||||
before,
|
||||
after,
|
||||
ids,
|
||||
} => {
|
||||
let (parsed_ids, ids) = if ids.is_empty() {
|
||||
if sender.is_some() || rcpt.is_some() || before.is_some() || after.is_some() {
|
||||
let parsed_ids =
|
||||
query_messages(url, &credentials, &sender, &rcpt, &before, &after);
|
||||
let ids = parsed_ids.iter().map(|id| format!("{id:X}")).collect();
|
||||
(parsed_ids, ids)
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
}
|
||||
} else {
|
||||
(parse_ids(&ids), ids)
|
||||
};
|
||||
|
||||
if ids.is_empty() {
|
||||
eprintln!("No messages were found.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut query = form_urlencoded::Serializer::new(format!("{url}/queue/cancel?"));
|
||||
|
||||
if let Some(filter) = &rcpt {
|
||||
query.append_pair("filter", filter);
|
||||
}
|
||||
query.append_pair("ids", &append_ids(String::new(), &parsed_ids));
|
||||
|
||||
let mut success_count = 0;
|
||||
let mut failed_list = vec![];
|
||||
for (success, id) in smtp_manage_request::<Vec<bool>>(&query.finish(), &credentials)
|
||||
.into_iter()
|
||||
.zip(ids)
|
||||
{
|
||||
if success {
|
||||
success_count += 1;
|
||||
} else {
|
||||
failed_list.push(id);
|
||||
}
|
||||
}
|
||||
eprint!("\nCancelled delivery of {success_count} message(s).");
|
||||
if !failed_list.is_empty() {
|
||||
eprint!(
|
||||
" Unable to cancel delivery for id(s): {}.",
|
||||
failed_list.join(", ")
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Response<T> {
|
||||
Data { data: T },
|
||||
Error { error: String, details: String },
|
||||
}
|
||||
|
||||
pub fn smtp_manage_request<T: DeserializeOwned>(url: &str, credentials: &Credentials) -> T {
|
||||
match serde_json::from_slice::<Response<T>>(
|
||||
&reqwest::blocking::Client::builder()
|
||||
.danger_accept_invalid_certs(url.starts_with("https://127.0.0.1"))
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
.get(url)
|
||||
.header(
|
||||
AUTHORIZATION,
|
||||
match credentials {
|
||||
Credentials::Basic(s) => format!("Basic {s}"),
|
||||
Credentials::Bearer(s) => format!("Bearer {s}"),
|
||||
},
|
||||
)
|
||||
.send()
|
||||
.unwrap_result("send GET request")
|
||||
.bytes()
|
||||
.unwrap_result("fetch bytes"),
|
||||
)
|
||||
.unwrap_result("deserialize response")
|
||||
{
|
||||
Response::Data { data } => data,
|
||||
Response::Error { error, details } => {
|
||||
eprintln!("Request failed: {details} ({error:?})");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_messages(
|
||||
url: &str,
|
||||
credentials: &Credentials,
|
||||
from: &Option<String>,
|
||||
rcpt: &Option<String>,
|
||||
before: &Option<DateTime>,
|
||||
after: &Option<DateTime>,
|
||||
) -> Vec<u64> {
|
||||
let mut query = form_urlencoded::Serializer::new(format!("{url}/queue/list?"));
|
||||
|
||||
if let Some(sender) = from {
|
||||
query.append_pair("from", sender);
|
||||
}
|
||||
if let Some(rcpt) = rcpt {
|
||||
query.append_pair("to", rcpt);
|
||||
}
|
||||
if let Some(before) = before {
|
||||
query.append_pair("before", &before.to_rfc3339());
|
||||
}
|
||||
if let Some(after) = after {
|
||||
query.append_pair("after", &after.to_rfc3339());
|
||||
}
|
||||
|
||||
smtp_manage_request::<Vec<u64>>(&query.finish(), credentials)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn build_query(url: &str, path: &str, ids: &[u64]) -> String {
|
||||
let mut query = String::with_capacity(url.len() + path.len() + (ids.len() * 10));
|
||||
query.push_str(url);
|
||||
query.push_str(path);
|
||||
append_ids(query, ids)
|
||||
}
|
||||
|
||||
fn append_ids(mut query: String, ids: &[u64]) -> String {
|
||||
for (pos, id) in ids.iter().enumerate() {
|
||||
if pos != 0 {
|
||||
query.push(',');
|
||||
}
|
||||
query.push_str(&id.to_string());
|
||||
}
|
||||
query
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn status_short(&self) -> &str {
|
||||
match self {
|
||||
Status::Scheduled => "scheduled",
|
||||
Status::Completed(_) => "delivered",
|
||||
Status::TemporaryFailure(_) => "tempfail",
|
||||
Status::PermanentFailure(_) => "permfail",
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> &str {
|
||||
match self {
|
||||
Status::Scheduled => "Scheduled",
|
||||
Status::Completed(_) => "Delivered",
|
||||
Status::TemporaryFailure(_) => "Temporary Failure",
|
||||
Status::PermanentFailure(_) => "Permanent Failure",
|
||||
}
|
||||
}
|
||||
|
||||
fn details(&self) -> &str {
|
||||
match self {
|
||||
Status::Scheduled => "",
|
||||
Status::Completed(status) => status,
|
||||
Status::TemporaryFailure(status) => status,
|
||||
Status::PermanentFailure(status) => status,
|
||||
}
|
||||
}
|
||||
}
|
||||
203
crates/cli/src/modules/report.rs
Normal file
203
crates/cli/src/modules/report.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
|
||||
*
|
||||
* This file is part of the Stalwart Command Line Interface.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* in the LICENSE file at the top-level directory of this distribution.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* You can be released from the requirements of the AGPLv3 license by
|
||||
* purchasing a commercial license. Please contact licensing@stalw.art
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
use super::cli::{ReportCommands, ReportFormat};
|
||||
use crate::modules::queue::{deserialize_datetime, smtp_manage_request};
|
||||
use console::Term;
|
||||
use human_size::{Byte, SpecificSize};
|
||||
use jmap_client::client::Credentials;
|
||||
use mail_parser::DateTime;
|
||||
use prettytable::{format::Alignment, Attr, Cell, Row, Table};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Report {
|
||||
pub domain: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: ReportFormat,
|
||||
#[serde(deserialize_with = "deserialize_datetime")]
|
||||
pub range_from: DateTime,
|
||||
#[serde(deserialize_with = "deserialize_datetime")]
|
||||
pub range_to: DateTime,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
pub fn cmd_report(url: &str, credentials: Credentials, command: ReportCommands) {
|
||||
match command {
|
||||
ReportCommands::List {
|
||||
domain,
|
||||
format,
|
||||
page_size,
|
||||
} => {
|
||||
let stdout = Term::buffered_stdout();
|
||||
let mut query = form_urlencoded::Serializer::new(format!("{url}/report/list?"));
|
||||
|
||||
if let Some(domain) = &domain {
|
||||
query.append_pair("domain", domain);
|
||||
}
|
||||
if let Some(format) = &format {
|
||||
query.append_pair("type", format.id());
|
||||
}
|
||||
|
||||
let ids = smtp_manage_request::<Vec<String>>(&query.finish(), &credentials);
|
||||
let ids_len = ids.len();
|
||||
let page_size = page_size.map(|p| std::cmp::max(p, 1)).unwrap_or(20);
|
||||
let pages_total = (ids_len as f64 / page_size as f64).ceil() as usize;
|
||||
for (page_num, chunk) in ids.chunks(page_size).enumerate() {
|
||||
// Build table
|
||||
let mut table = Table::new();
|
||||
table.add_row(Row::new(
|
||||
["ID", "Domain", "Type", "From Date", "To Date", "Size"]
|
||||
.iter()
|
||||
.map(|p| Cell::new(p).with_style(Attr::Bold))
|
||||
.collect(),
|
||||
));
|
||||
for (report, id) in smtp_manage_request::<Vec<Option<Report>>>(
|
||||
&format!("{url}/report/status?ids={}", chunk.join(",")),
|
||||
&credentials,
|
||||
)
|
||||
.into_iter()
|
||||
.zip(chunk)
|
||||
{
|
||||
if let Some(report) = report {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new(id),
|
||||
Cell::new(&report.domain),
|
||||
Cell::new(report.type_.name()),
|
||||
Cell::new(&report.range_from.to_rfc822()),
|
||||
Cell::new(&report.range_to.to_rfc822()),
|
||||
Cell::new(
|
||||
&SpecificSize::new(report.size as u32, Byte)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
table.printstd();
|
||||
eprintln!();
|
||||
if page_num + 1 != pages_total {
|
||||
eprintln!("\n--- Press any key to continue or 'q' to exit ---");
|
||||
if let Ok('q' | 'Q') = stdout.read_char() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("\n{ids_len} queued message(s) found.")
|
||||
}
|
||||
ReportCommands::Status { ids } => {
|
||||
for (report, id) in smtp_manage_request::<Vec<Option<Report>>>(
|
||||
&format!("{url}/report/status?ids={}", ids.join(",")),
|
||||
&credentials,
|
||||
)
|
||||
.into_iter()
|
||||
.zip(&ids)
|
||||
{
|
||||
let mut table = Table::new();
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("ID").with_style(Attr::Bold),
|
||||
Cell::new(id),
|
||||
]));
|
||||
if let Some(report) = report {
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Domain Name").with_style(Attr::Bold),
|
||||
Cell::new(&report.domain),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Type").with_style(Attr::Bold),
|
||||
Cell::new(report.type_.name()),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("From Date").with_style(Attr::Bold),
|
||||
Cell::new(&report.range_from.to_rfc822()),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("To Date").with_style(Attr::Bold),
|
||||
Cell::new(&report.range_to.to_rfc822()),
|
||||
]));
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Size").with_style(Attr::Bold),
|
||||
Cell::new(
|
||||
&SpecificSize::new(report.size as u32, Byte)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
),
|
||||
]));
|
||||
} else {
|
||||
table.add_row(Row::new(vec![Cell::new_align(
|
||||
"-- Not found --",
|
||||
Alignment::CENTER,
|
||||
)
|
||||
.with_hspan(2)]));
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
table.printstd();
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
ReportCommands::Cancel { ids } => {
|
||||
let mut success_count = 0;
|
||||
let mut failed_list = vec![];
|
||||
for (success, id) in smtp_manage_request::<Vec<bool>>(
|
||||
&format!("{url}/report/cancel?ids={}", ids.join(",")),
|
||||
&credentials,
|
||||
)
|
||||
.into_iter()
|
||||
.zip(ids)
|
||||
{
|
||||
if success {
|
||||
success_count += 1;
|
||||
} else {
|
||||
failed_list.push(id);
|
||||
}
|
||||
}
|
||||
eprint!("\nRemoved {success_count} report(s).");
|
||||
if !failed_list.is_empty() {
|
||||
eprint!(
|
||||
" Unable to remove report id(s): {}.",
|
||||
failed_list.join(", ")
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportFormat {
|
||||
fn id(&self) -> &'static str {
|
||||
match self {
|
||||
ReportFormat::Dmarc => "dmarc",
|
||||
ReportFormat::Tls => "tls",
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ReportFormat::Dmarc => "DMARC",
|
||||
ReportFormat::Tls => "TLS",
|
||||
}
|
||||
}
|
||||
}
|
||||
37
crates/main/Cargo.toml
Normal file
37
crates/main/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "mail-server"
|
||||
description = "Stalwart Mail Server"
|
||||
authors = [ "Stalwart Labs Ltd. <hello@stalw.art>"]
|
||||
repository = "https://github.com/stalwartlabs/jmap-server"
|
||||
homepage = "https://stalw.art"
|
||||
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
|
||||
categories = ["email"]
|
||||
license = "AGPL-3.0-only"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "stalwart-mail"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
store = { path = "../store" }
|
||||
jmap = { path = "../jmap" }
|
||||
jmap_proto = { path = "../jmap-proto" }
|
||||
smtp = { path = "../smtp", features = ["local_delivery"] }
|
||||
imap = { path = "../imap" }
|
||||
managesieve = { path = "../managesieve" }
|
||||
directory = { path = "../directory" }
|
||||
utils = { path = "../utils" }
|
||||
tokio = { version = "1.23", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
jemallocator = "0.5.0"
|
||||
|
||||
[features]
|
||||
default = ["sqlite"]
|
||||
sqlite = ["store/sqlite"]
|
||||
foundationdb = ["store/foundation"]
|
||||
|
||||
@@ -9,7 +9,7 @@ default = ["sqlite"]
|
||||
sqlite = ["store/sqlite"]
|
||||
foundationdb = ["store/foundation"]
|
||||
|
||||
[dev-dependencies]
|
||||
[dependencies]
|
||||
store = { path = "../crates/store", features = ["test_mode"] }
|
||||
directory = { path = "../crates/directory" }
|
||||
jmap = { path = "../crates/jmap", features = ["test_mode"] }
|
||||
|
||||
@@ -134,5 +134,5 @@ pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection) {
|
||||
.await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::Ok)
|
||||
.await
|
||||
.assert_contains("COUNT 10 ALL 6,4:5,1,10,3,7:8,2,9");
|
||||
.assert_contains("COUNT 10 ALL 6,4:5,1,10,9,3,7:8,2");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user