CLI account management + Directory refactoring

This commit is contained in:
mdecimus
2023-12-18 22:25:42 +01:00
parent 566a2a0ab8
commit ea94de6d77
91 changed files with 3531 additions and 1846 deletions
+1
View File
@@ -27,3 +27,4 @@ csv = "1.1"
form_urlencoded = "1.1.0"
human-size = "0.4.2"
futures = "0.3.28"
pwhash = "1.0.0"
+165 -52
View File
@@ -29,17 +29,13 @@ use std::{
use clap::Parser;
use console::style;
use jmap_client::client::{Client, Credentials};
use jmap_client::client::Credentials;
use modules::{
cli::{Cli, Commands},
database::cmd_database,
export::cmd_export,
get,
import::cmd_import,
is_localhost, post,
queue::cmd_queue,
report::cmd_report,
cli::{Cli, Client, Commands},
is_localhost, UnwrapResult,
};
use reqwest::{header::AUTHORIZATION, Method, StatusCode};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::modules::OAuthResponse;
@@ -48,56 +44,43 @@ 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 {
let credentials = rpassword::prompt_password(
"\nEnter administrator credentials or press [ENTER] to use OAuth: ",
)
.unwrap();
if !credentials.is_empty() {
let client = Client {
credentials: if let Some(credentials) = args.credentials {
parse_credentials(&credentials)
} else {
oauth(&args.url).await
}
let credentials = rpassword::prompt_password(
"\nEnter administrator credentials or press [ENTER] to use OAuth: ",
)
.unwrap();
if !credentials.is_empty() {
parse_credentials(&credentials)
} else {
oauth(&args.url).await
}
},
timeout: args.timeout,
url: args.url,
};
if is_jmap {
match args.command {
Commands::Import(command) => {
cmd_import(build_client(&args.url, credentials).await, command).await
}
Commands::Export(command) => {
cmd_export(build_client(&args.url, credentials).await, command).await
}
Commands::Database(command) => cmd_database(&args.url, credentials, command).await,
Commands::Queue(_) | Commands::Report(_) => unreachable!(),
match args.command {
Commands::Import(command) => {
command.exec(client).await;
}
} else {
match args.command {
Commands::Queue(command) => cmd_queue(&args.url, credentials, command).await,
Commands::Report(command) => cmd_report(&args.url, credentials, command).await,
_ => unreachable!(),
Commands::Export(command) => {
command.exec(client).await;
}
Commands::Database(command) => command.exec(client).await,
Commands::Account(_) => todo!(),
Commands::Domain(_) => todo!(),
Commands::List(_) => todo!(),
Commands::Group(_) => todo!(),
Commands::Queue(command) => command.exec(client).await,
Commands::Report(command) => command.exec(client).await,
}
Ok(())
}
async fn build_client(url: &str, credentials: Credentials) -> Client {
Client::new()
.credentials(credentials)
.accept_invalid_certs(is_localhost(url))
.timeout(Duration::from_secs(60))
.connect(url)
.await
.unwrap_or_else(|err| {
eprintln!("Failed to connect to JMAP server {}: {}.", url, err);
std::process::exit(1);
})
}
fn parse_credentials(credentials: &str) -> Credentials {
if let Some((account, secret)) = credentials.split_once(':') {
Credentials::basic(account, secret)
@@ -107,10 +90,39 @@ fn parse_credentials(credentials: &str) -> Credentials {
}
async fn oauth(url: &str) -> Credentials {
let metadata = get(&format!("{}/.well-known/oauth-authorization-server", url)).await;
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::from_iter([("client_id".to_string(), "Stalwart_CLI".to_string())]);
let response = post(metadata.property("device_authorization_endpoint"), &params).await;
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(&params)
.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(),
@@ -130,7 +142,22 @@ async fn oauth(url: &str) -> Credentials {
std::io::stdout().flush().unwrap();
std::io::stdin().lock().lines().next();
let mut response = post(token_endpoint, &params).await;
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(&params)
.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 {
@@ -144,3 +171,89 @@ async fn oauth(url: &str) -> Credentials {
std::process::exit(1);
}
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Response<T> {
Data { data: T },
Error { error: String, 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))
.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 {
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::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);
}
}
match serde_json::from_slice::<Response<R>>(
&response.bytes().await.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);
}
}
}
}
+360
View File
@@ -0,0 +1,360 @@
/*
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::fmt::Display;
use prettytable::{Attr, Cell, Row, Table};
use pwhash::sha512_crypt;
use reqwest::Method;
use serde_json::Value;
use super::{
cli::{AccountCommands, Client},
Principal, PrincipalField, PrincipalUpdate, PrincipalValue, Type,
};
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 {
id: None,
typ: if is_admin.unwrap_or_default() {
Type::Superuser
} else {
Type::Individual
}
.into(),
quota,
used_quota: None,
name: name.clone().into(),
secrets: vec![sha512_crypt::hash(password).unwrap()],
emails: addresses.unwrap_or_default(),
member_of: member_of.unwrap_or_default(),
description,
};
let account_id = client
.http_request::<u32, _>(Method::POST, "/admin/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::set(
PrincipalField::Secrets,
PrincipalValue::StringList(vec![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::Type(if is_admin {
Type::Superuser
} else {
Type::Individual
}),
));
}
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!("/admin/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!("/admin/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!("/admin/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!("/admin/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!("/admin/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!("/admin/principal/{name}"),
None,
)
.await;
eprintln!("Successfully deleted account {name:?}.");
}
AccountCommands::Display { name } => {
client.display_principal(&name).await;
}
AccountCommands::List { from, limit } => {
client
.list_principals("individual", "Account", from, limit)
.await;
}
}
}
}
impl Client {
pub async fn display_principal(&self, name: &str) {
let principal = self
.http_request::<Principal, String>(
Method::GET,
&format!("/admin/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),
]));
}
let is_list = if let Some(typ) = principal.typ {
table.add_row(Row::new(vec![
Cell::new("Type").with_style(Attr::Bold),
Cell::new(&typ.to_string()),
]));
matches!(typ, Type::List)
} else {
false
};
if let Some(description) = principal.description {
table.add_row(Row::new(vec![
Cell::new("Description").with_style(Attr::Bold),
Cell::new(&description),
]));
}
if let Some(quota) = principal.quota {
table.add_row(Row::new(vec![
Cell::new("Quota").with_style(Attr::Bold),
Cell::new(&quota.to_string()),
]));
}
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.member_of.is_empty() {
table.add_row(Row::new(vec![
Cell::new(if is_list { "List members" } else { "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,
from: Option<String>,
limit: Option<usize>,
) {
let mut query = form_urlencoded::Serializer::new("/admin/principal?".to_string());
query.append_pair("type", record_type);
if let Some(from) = &from {
query.append_pair("from", from);
}
if let Some(limit) = limit {
query.append_pair("limit", &limit.to_string());
}
let results = self
.http_request::<Vec<String>, String>(Method::GET, &query.finish(), None)
.await;
if !results.is_empty() {
let mut table = Table::new();
table.add_row(Row::new(vec![
Cell::new(&format!("{record_name} Name")).with_style(Attr::Bold)
]));
for domain in &results {
table.add_row(Row::new(vec![Cell::new(domain)]));
}
eprintln!();
table.printstd();
eprintln!();
}
eprintln!(
"\n\n{} {}{} found.\n",
results.len(),
record_name.to_ascii_lowercase(),
if results.len() == 1 { "" } else { "s" }
);
}
}
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"),
}
}
}
+290 -22
View File
@@ -22,6 +22,7 @@
*/
use clap::{Parser, Subcommand, ValueEnum};
use jmap_client::client::Credentials;
use mail_parser::DateTime;
use serde::Deserialize;
@@ -37,10 +38,29 @@ pub struct Cli {
/// Authentication credentials
#[clap(short, long)]
pub credentials: Option<String>,
/// Connection timeout in seconds
#[clap(short, long)]
pub timeout: Option<u64>,
}
#[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),
/// Import JMAP accounts and Maildir/mbox mailboxes
#[clap(subcommand)]
Import(ImportCommands),
@@ -62,6 +82,274 @@ pub enum Commands {
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<u32>,
/// 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 {
/// Starting point for listing accounts
from: Option<String>,
/// Maximum number of accounts to list
limit: 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 {
/// Starting point for listing mailing lists
from: Option<String>,
/// Maximum number of mailing lists to list
limit: 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>,
/// Groups that this group is a member of
#[clap(short, long)]
member_of: 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)]
member_of: Option<Vec<String>>,
},
/// Add a group to other groups
AddToGroup {
/// Group name
name: String,
/// Groups to add
#[clap(required = true)]
member_of: Vec<String>,
},
/// Remove a group account from groups
RemoveFromGroup {
/// Group name
name: String,
/// Groups to remove
#[clap(required = true)]
member_of: Vec<String>,
},
/// Display an existing group
Display {
/// Group name to display
name: String,
},
/// List all groups
List {
/// Starting point for listing groups
from: Option<String>,
/// Maximum number of groups to list
limit: 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 all domains
List {
/// Starting point for listing domains
from: Option<String>,
/// Maximum number of domains to list
limit: Option<usize>,
},
}
#[derive(Subcommand)]
pub enum ImportCommands {
/// Import messages and folders
@@ -112,22 +400,8 @@ pub enum ExportCommands {
#[derive(Subcommand)]
pub enum DatabaseCommands {
/// Delete a JMAP account
Delete {
/// Account name to delete
account: String,
},
/// Rename a JMAP account
Rename {
/// Account name to rename
account: String,
/// New account name
new_account: String,
},
/// Purge expired blobs
Purge {},
/// Perform database maintenance
Maintenance {},
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
@@ -243,12 +517,6 @@ pub enum ReportCommands {
},
}
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
+13 -35
View File
@@ -21,42 +21,20 @@
* for more details.
*/
use jmap_client::client::Credentials;
use reqwest::header::AUTHORIZATION;
use reqwest::Method;
use serde_json::Value;
use super::{cli::DatabaseCommands, is_localhost, UnwrapResult};
use super::cli::{Client, DatabaseCommands};
pub async fn cmd_database(url: &str, credentials: Credentials, command: DatabaseCommands) {
let url = match command {
DatabaseCommands::Delete { account } => format!("{}/admin/account/delete/{}", url, account),
DatabaseCommands::Rename {
account,
new_account,
} => format!("{}/admin/account/rename/{}/{}", url, account, new_account),
DatabaseCommands::Purge {} => format!("{}/admin/blob/purge", url),
};
let response = reqwest::Client::builder()
.danger_accept_invalid_certs(is_localhost(&url))
.build()
.unwrap_or_default()
.get(url)
.header(
AUTHORIZATION,
match credentials {
Credentials::Basic(s) => format!("Basic {s}"),
Credentials::Bearer(s) => format!("Bearer {s}"),
},
)
.send()
.await
.unwrap_result("send GET request");
if response.status().is_success() {
eprintln!("Success.");
} else {
eprintln!(
"Request Failed: {}",
response.text().await.unwrap_result("fetch text")
);
impl DatabaseCommands {
pub async fn exec(self, client: Client) {
match self {
DatabaseCommands::Maintenance {} => {
client
.http_request::<Value, String>(Method::GET, "/admin/store/maintenance", None)
.await;
eprintln!("Success.");
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::borrow::Cow;
use prettytable::{Attr, Cell, Row, Table};
use reqwest::Method;
use serde_json::Value;
use super::cli::{Client, DomainCommands};
impl DomainCommands {
pub async fn exec(self, client: Client) {
match self {
DomainCommands::Create { name } => {
client
.http_request::<Value, String>(
Method::POST,
&format!("/admin/domain/{name}"),
None,
)
.await;
eprintln!("Successfully created domain {name:?}");
}
DomainCommands::Delete { name } => {
client
.http_request::<Value, String>(
Method::DELETE,
&format!("/admin/domain/{name}"),
None,
)
.await;
eprintln!("Successfully deleted domain {name:?}");
}
DomainCommands::List { from, limit } => {
let query = if from.is_none() && limit.is_none() {
Cow::Borrowed("/admin/domain")
} else {
let mut query = "/admin/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::<Vec<String>, String>(Method::GET, query.as_ref(), None)
.await;
if !domains.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 {
table.add_row(Row::new(vec![Cell::new(domain)]));
}
eprintln!();
table.printstd();
eprintln!();
}
eprintln!(
"\n\n{} domain{} found.\n",
domains.len(),
if domains.len() == 1 { "" } else { "s" }
);
}
}
}
}
+117 -97
View File
@@ -28,7 +28,6 @@ use std::{
use futures::{stream::FuturesUnordered, StreamExt};
use jmap_client::{
client::Client,
email::{self, Email},
identity::{self, Identity},
mailbox::{self, Mailbox},
@@ -40,102 +39,111 @@ use tokio::io::AsyncWriteExt;
use crate::modules::RETRY_ATTEMPTS;
use super::{cli::ExportCommands, name_to_id, UnwrapResult};
use super::{
cli::{Client, ExportCommands},
name_to_id, UnwrapResult,
};
pub async fn cmd_export(mut client: Client, command: ExportCommands) {
match command {
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);
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());
// Create directory
let mut path = PathBuf::from(path);
if !path.is_dir() {
eprintln!("Directory {} does not exist.", 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);
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();
}
}
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);
});
}
// Wait for remaining futures
while futures.next().await.is_some() {}
// 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);
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: &Client, max_objects_in_get: usize) -> Vec<Mailbox> {
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 {
@@ -192,7 +200,11 @@ pub async fn fetch_mailboxes(client: &Client, max_objects_in_get: usize) -> Vec<
results
}
async fn export_mailboxes(client: &Client, max_objects_in_get: usize, path: &Path) {
async fn export_mailboxes(
client: &jmap_client::client::Client,
max_objects_in_get: usize,
path: &Path,
) {
eprintln!(
"Exported {} mailboxes.",
write_file(
@@ -204,7 +216,10 @@ async fn export_mailboxes(client: &Client, max_objects_in_get: usize, path: &Pat
);
}
pub async fn fetch_emails(client: &Client, max_objects_in_get: usize) -> Vec<Email> {
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();
@@ -263,7 +278,7 @@ pub async fn fetch_emails(client: &Client, max_objects_in_get: usize) -> Vec<Ema
}
async fn export_emails(
client: &Client,
client: &jmap_client::client::Client,
max_objects_in_get: usize,
blobs: &mut Vec<String>,
path: &Path,
@@ -287,7 +302,10 @@ async fn export_emails(
);
}
pub async fn fetch_sieve_scripts(client: &Client, max_objects_in_get: usize) -> Vec<SieveScript> {
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();
@@ -347,7 +365,7 @@ pub async fn fetch_sieve_scripts(client: &Client, max_objects_in_get: usize) ->
}
async fn export_sieve_scripts(
client: &Client,
client: &jmap_client::client::Client,
max_objects_in_get: usize,
blobs: &mut Vec<String>,
path: &Path,
@@ -370,7 +388,7 @@ async fn export_sieve_scripts(
);
}
pub async fn fetch_identities(client: &Client) -> Vec<Identity> {
pub async fn fetch_identities(client: &jmap_client::client::Client) -> Vec<Identity> {
let mut request = client.build();
request.get_identity().properties([
identity::Property::Id,
@@ -388,14 +406,16 @@ pub async fn fetch_identities(client: &Client) -> Vec<Identity> {
.take_list()
}
async fn export_identities(client: &Client, path: &Path) {
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: &Client) -> Vec<VacationResponse> {
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,
@@ -413,7 +433,7 @@ pub async fn fetch_vacation_responses(client: &Client) -> Vec<VacationResponse>
.take_list()
}
async fn export_vacation_responses(client: &Client, path: &Path) {
async fn export_vacation_responses(client: &jmap_client::client::Client, path: &Path) {
eprintln!(
"Exported {} vacation responses.",
write_file(
+153
View File
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use reqwest::Method;
use serde_json::Value;
use crate::modules::{Principal, Type};
use super::{
cli::{Client, GroupCommands},
PrincipalField, PrincipalUpdate, PrincipalValue,
};
impl GroupCommands {
pub async fn exec(self, client: Client) {
match self {
GroupCommands::Create {
name,
email,
description,
member_of,
} => {
let principal = Principal {
id: None,
typ: Some(Type::Group),
quota: None,
used_quota: None,
name: name.clone().into(),
secrets: vec![],
emails: email.map(|e| vec![e]).unwrap_or_default(),
member_of: member_of.unwrap_or_default(),
description,
};
let account_id = client
.http_request::<u32, _>(Method::POST, "/admin/principal", Some(principal))
.await;
eprintln!("Successfully created group {name:?} with id {account_id}.");
}
GroupCommands::Update {
name,
new_name,
email,
description,
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(email) = email {
changes.push(PrincipalUpdate::set(
PrincipalField::Emails,
PrincipalValue::StringList(vec![email]),
));
}
if let Some(member_of) = member_of {
changes.push(PrincipalUpdate::set(
PrincipalField::MemberOf,
PrincipalValue::StringList(member_of),
));
}
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!("/admin/principal/{name}"),
Some(changes),
)
.await;
eprintln!("Successfully updated group {name:?}.");
} else {
eprintln!("No changes to apply.");
}
}
GroupCommands::AddToGroup { name, member_of } => {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
Some(
member_of
.into_iter()
.map(|group| {
PrincipalUpdate::add_item(
PrincipalField::MemberOf,
PrincipalValue::String(group),
)
})
.collect::<Vec<_>>(),
),
)
.await;
eprintln!("Successfully updated group {name:?}.");
}
GroupCommands::RemoveFromGroup { name, member_of } => {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
Some(
member_of
.into_iter()
.map(|group| {
PrincipalUpdate::remove_item(
PrincipalField::MemberOf,
PrincipalValue::String(group),
)
})
.collect::<Vec<_>>(),
),
)
.await;
eprintln!("Successfully updated group {name:?}.");
}
GroupCommands::Display { name } => {
client.display_principal(&name).await;
}
GroupCommands::List { from, limit } => {
client.list_principals("group", "Group", from, limit).await;
}
}
}
}
+378 -369
View File
@@ -35,7 +35,6 @@ use console::style;
use futures::{stream::FuturesUnordered, StreamExt};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use jmap_client::{
client::Client,
core::set::SetObject,
mailbox::{self, Role},
};
@@ -49,7 +48,7 @@ use tokio::{fs::File, io::AsyncReadExt};
use crate::modules::{name_to_id, UnwrapResult, RETRY_ATTEMPTS};
use super::{
cli::{ImportCommands, MailboxFormat},
cli::{Client, ImportCommands, MailboxFormat},
export::{
fetch_emails, fetch_identities, fetch_mailboxes, fetch_sieve_scripts,
fetch_vacation_responses,
@@ -77,375 +76,381 @@ struct Message {
internal_date: u64,
contents: Vec<u8>,
}
impl ImportCommands {
pub async fn exec(self, client: Client) {
let mut client = client.into_jmap_client().await;
pub async fn cmd_import(mut client: Client, command: ImportCommands) {
match command {
ImportCommands::Messages {
num_concurrent,
format,
account,
path,
} => {
client.set_default_account_id(name_to_id(&client, &account).await);
let mut create_mailboxes = Vec::new();
let mut create_mailbox_names = Vec::new();
let mut create_mailbox_ids = Vec::new();
eprintln!("{} Parsing mailbox...", style("[1/4]").bold().dim(),);
match format {
MailboxFormat::Mbox => {
create_mailbox_names.push(Vec::new());
create_mailboxes.push(Mailbox::Mbox(MessageIterator::new(Cursor::new(
read_file(&path),
))));
}
MailboxFormat::Maildir | MailboxFormat::MaildirNested => {
let (folder_sep, folder_split) = if format == MailboxFormat::Maildir {
(Some("."), ".")
} else {
(None, "/")
};
for folder in maildir::FolderIterator::new(path, folder_sep)
.unwrap_result("read Maildir folder")
{
let folder = folder.unwrap_result("read Maildir folder");
if let Some(folder_name) = folder.name() {
let mut folder_parts = Vec::new();
for folder_name in folder_name.split(folder_split) {
let mut folder_name = folder_name.trim();
if folder_name.is_empty() {
folder_name = ".";
}
folder_parts.push(folder_name.to_string());
if !create_mailbox_names.contains(&folder_parts) {
create_mailboxes.push(Mailbox::None);
create_mailbox_names.push(folder_parts.clone());
}
}
*create_mailboxes.last_mut().unwrap() = Mailbox::Maildir(folder);
} else {
create_mailboxes.push(Mailbox::Maildir(folder));
create_mailbox_names.push(Vec::new());
};
}
}
}
// Fetch all mailboxes for the account
eprintln!(
"{} Fetching existing mailboxes for account...",
style("[2/4]").bold().dim(),
);
let mut inbox_id = None;
let mut mailbox_ids = HashMap::new();
let mut children: HashMap<Option<&str>, Vec<&str>> =
HashMap::from_iter([(None, Vec::new())]);
let mut request = client.build();
request.get_mailbox().properties([
mailbox::Property::Name,
mailbox::Property::ParentId,
mailbox::Property::Role,
mailbox::Property::Id,
]);
let response = request
.send_get_mailbox()
.await
.unwrap_result("fetch mailboxes");
for mailbox in response.list() {
let mailbox_id = mailbox.id().unwrap();
if mailbox.role() == Role::Inbox {
inbox_id = mailbox_id.into();
}
children
.entry(mailbox.parent_id())
.or_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();
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_concurrent = num_concurrent.unwrap_or_else(num_cpus::get);
let spinner_style =
ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
let pbs = Arc::new(Mutex::new((
(0..num_concurrent)
.map(|n| {
let pb = m.add(ProgressBar::new(40));
pb.set_style(spinner_style.clone());
pb.set_prefix(format!("[{}/?]", n + 1));
pb
})
.collect::<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 mailbox_id = Arc::new(match mailbox_id {
MailboxId::ExistingId(id) => id.to_string(),
MailboxId::CreateId(id) => id,
MailboxId::None => unreachable!(),
});
let mailbox_name = Arc::new(if !mailbox_name.is_empty() {
mailbox_name.join("/")
} else {
"Inbox".to_string()
});
for result in mailbox.by_ref() {
match result {
Ok(message) => {
message_num += 1;
let client = client.clone();
let mailbox_id = mailbox_id.clone();
let mailbox_name = mailbox_name.clone();
let total_imported = total_imported.clone();
let pbs = pbs.clone();
let failures = failures.clone();
futures.push(async move {
// Update progress bar
{
let mut pbs = pbs.lock().unwrap();
let pb = &pbs.0[pbs.1 % pbs.0.len()];
pb.set_message(format!(
"Importing {}: {}/{}",
message_num, mailbox_name, message.identifier
));
pb.inc(1);
pbs.1 += 1;
}
let mut retry_count = 0;
loop {
match client
.email_import(
message.contents.clone(),
[mailbox_id.as_ref()],
if !message.flags.is_empty() {
message
.flags
.iter()
.map(|f| match f {
maildir::Flag::Passed => "$passed",
maildir::Flag::Replied => "$answered",
maildir::Flag::Seen => "$seen",
maildir::Flag::Trashed => "$deleted",
maildir::Flag::Draft => "$draft",
maildir::Flag::Flagged => "$flagged",
})
.into()
} else {
None
},
if message.internal_date > 0 {
(message.internal_date as i64).into()
} else {
None
},
)
.await
{
Ok(_) => {
total_imported.fetch_add(1, Ordering::Relaxed);
}
Err(_) if retry_count < RETRY_ATTEMPTS => {
retry_count += 1;
continue;
}
Err(err) => {
failures.lock().unwrap().push(format!(
concat!(
"Failed to import message {} ",
"with identifier '{}': {}"
),
message_num, message.identifier, err
));
}
}
break;
}
});
if futures.len() == num_concurrent {
futures.next().await.unwrap();
}
}
Err(e) => {
failures
.lock()
.unwrap()
.push(format!("I/O error reading message: {}", e));
}
}
}
// Wait for remaining futures
while futures.next().await.is_some() {}
}
// Done
for pb in pbs.lock().unwrap().0.iter() {
pb.finish_with_message("Done");
}
let failures = failures.lock().unwrap();
eprintln!(
"\n\nSuccessfully imported {} messages.\n",
total_imported.load(Ordering::Relaxed)
);
if !failures.is_empty() {
eprintln!("There were {} failures:\n", failures.len());
for failure in failures.iter() {
eprintln!("{}", failure);
}
}
}
ImportCommands::Account {
num_concurrent,
account,
path,
} => {
client.set_default_account_id(name_to_id(&client, &account).await);
let path = PathBuf::from(path);
if !path.exists() {
eprintln!("Path '{}' does not exist.", path.display());
return;
}
let num_concurrent = num_concurrent.unwrap_or_else(num_cpus::get);
// Import objects
import_emails(
&client,
&path,
import_mailboxes(&client, &path).await.into(),
match self {
ImportCommands::Messages {
num_concurrent,
)
.await;
import_sieve_scripts(&client, &path, num_concurrent).await;
import_identities(&client, &path).await;
import_vacation_responses(&client, &path).await;
format,
account,
path,
} => {
client.set_default_account_id(name_to_id(&client, &account).await);
let mut create_mailboxes = Vec::new();
let mut create_mailbox_names = Vec::new();
let mut create_mailbox_ids = Vec::new();
eprintln!("{} Parsing mailbox...", style("[1/4]").bold().dim(),);
match format {
MailboxFormat::Mbox => {
create_mailbox_names.push(Vec::new());
create_mailboxes.push(Mailbox::Mbox(MessageIterator::new(Cursor::new(
read_file(&path),
))));
}
MailboxFormat::Maildir | MailboxFormat::MaildirNested => {
let (folder_sep, folder_split) = if format == MailboxFormat::Maildir {
(Some("."), ".")
} else {
(None, "/")
};
for folder in maildir::FolderIterator::new(path, folder_sep)
.unwrap_result("read Maildir folder")
{
let folder = folder.unwrap_result("read Maildir folder");
if let Some(folder_name) = folder.name() {
let mut folder_parts = Vec::new();
for folder_name in folder_name.split(folder_split) {
let mut folder_name = folder_name.trim();
if folder_name.is_empty() {
folder_name = ".";
}
folder_parts.push(folder_name.to_string());
if !create_mailbox_names.contains(&folder_parts) {
create_mailboxes.push(Mailbox::None);
create_mailbox_names.push(folder_parts.clone());
}
}
*create_mailboxes.last_mut().unwrap() = Mailbox::Maildir(folder);
} else {
create_mailboxes.push(Mailbox::Maildir(folder));
create_mailbox_names.push(Vec::new());
};
}
}
}
// Fetch all mailboxes for the account
eprintln!(
"{} Fetching existing mailboxes for account...",
style("[2/4]").bold().dim(),
);
let mut inbox_id = None;
let mut mailbox_ids = HashMap::new();
let mut children: HashMap<Option<&str>, Vec<&str>> =
HashMap::from_iter([(None, Vec::new())]);
let mut request = client.build();
request.get_mailbox().properties([
mailbox::Property::Name,
mailbox::Property::ParentId,
mailbox::Property::Role,
mailbox::Property::Id,
]);
let response = request
.send_get_mailbox()
.await
.unwrap_result("fetch mailboxes");
for mailbox in response.list() {
let mailbox_id = mailbox.id().unwrap();
if mailbox.role() == Role::Inbox {
inbox_id = mailbox_id.into();
}
children
.entry(mailbox.parent_id())
.or_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();
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_concurrent = num_concurrent.unwrap_or_else(num_cpus::get);
let spinner_style =
ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
let pbs = Arc::new(Mutex::new((
(0..num_concurrent)
.map(|n| {
let pb = m.add(ProgressBar::new(40));
pb.set_style(spinner_style.clone());
pb.set_prefix(format!("[{}/?]", n + 1));
pb
})
.collect::<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 mailbox_id = Arc::new(match mailbox_id {
MailboxId::ExistingId(id) => id.to_string(),
MailboxId::CreateId(id) => id,
MailboxId::None => unreachable!(),
});
let mailbox_name = Arc::new(if !mailbox_name.is_empty() {
mailbox_name.join("/")
} else {
"Inbox".to_string()
});
for result in mailbox.by_ref() {
match result {
Ok(message) => {
message_num += 1;
let client = client.clone();
let mailbox_id = mailbox_id.clone();
let mailbox_name = mailbox_name.clone();
let total_imported = total_imported.clone();
let pbs = pbs.clone();
let failures = failures.clone();
futures.push(async move {
// Update progress bar
{
let mut pbs = pbs.lock().unwrap();
let pb = &pbs.0[pbs.1 % pbs.0.len()];
pb.set_message(format!(
"Importing {}: {}/{}",
message_num, mailbox_name, message.identifier
));
pb.inc(1);
pbs.1 += 1;
}
let mut retry_count = 0;
loop {
match client
.email_import(
message.contents.clone(),
[mailbox_id.as_ref()],
if !message.flags.is_empty() {
message
.flags
.iter()
.map(|f| match f {
maildir::Flag::Passed => "$passed",
maildir::Flag::Replied => "$answered",
maildir::Flag::Seen => "$seen",
maildir::Flag::Trashed => "$deleted",
maildir::Flag::Draft => "$draft",
maildir::Flag::Flagged => "$flagged",
})
.into()
} else {
None
},
if message.internal_date > 0 {
(message.internal_date as i64).into()
} else {
None
},
)
.await
{
Ok(_) => {
total_imported.fetch_add(1, Ordering::Relaxed);
}
Err(_) if retry_count < RETRY_ATTEMPTS => {
retry_count += 1;
continue;
}
Err(err) => {
failures.lock().unwrap().push(format!(
concat!(
"Failed to import message {} ",
"with identifier '{}': {}"
),
message_num, message.identifier, err
));
}
}
break;
}
});
if futures.len() == num_concurrent {
futures.next().await.unwrap();
}
}
Err(e) => {
failures
.lock()
.unwrap()
.push(format!("I/O error reading message: {}", e));
}
}
}
// Wait for remaining futures
while futures.next().await.is_some() {}
}
// Done
for pb in pbs.lock().unwrap().0.iter() {
pb.finish_with_message("Done");
}
let failures = failures.lock().unwrap();
eprintln!(
"\n\nSuccessfully imported {} messages.\n",
total_imported.load(Ordering::Relaxed)
);
if !failures.is_empty() {
eprintln!("There were {} failures:\n", failures.len());
for failure in failures.iter() {
eprintln!("{}", failure);
}
}
}
ImportCommands::Account {
num_concurrent,
account,
path,
} => {
client.set_default_account_id(name_to_id(&client, &account).await);
let path = PathBuf::from(path);
if !path.exists() {
eprintln!("Path '{}' does not exist.", path.display());
return;
}
let num_concurrent = num_concurrent.unwrap_or_else(num_cpus::get);
// Import objects
import_emails(
&client,
&path,
import_mailboxes(&client, &path).await.into(),
num_concurrent,
)
.await;
import_sieve_scripts(&client, &path, num_concurrent).await;
import_identities(&client, &path).await;
import_vacation_responses(&client, &path).await;
}
}
}
}
async fn import_mailboxes(client: &Client, path: &Path) -> HashMap<String, String> {
async fn import_mailboxes(
client: &jmap_client::client::Client,
path: &Path,
) -> HashMap<String, String> {
// Deserialize mailboxes
let mailboxes = read_json::<jmap_client::mailbox::Mailbox>(path, "mailboxes.json").await;
if mailboxes.is_empty() {
@@ -561,7 +566,7 @@ async fn import_mailboxes(client: &Client, path: &Path) -> HashMap<String, Strin
}
async fn import_emails(
client: &Client,
client: &jmap_client::client::Client,
path: &Path,
mailbox_ids: Arc<HashMap<String, String>>,
num_concurrent: usize,
@@ -706,7 +711,11 @@ async fn import_emails(
);
}
async fn import_sieve_scripts(client: &Client, path: &Path, num_concurrent: usize) {
async fn import_sieve_scripts(
client: &jmap_client::client::Client,
path: &Path,
num_concurrent: usize,
) {
// Deserialize scripts
let scripts = read_json::<jmap_client::sieve::SieveScript>(path, "sieve.json").await;
if scripts.is_empty() {
@@ -812,7 +821,7 @@ async fn import_sieve_scripts(client: &Client, path: &Path, num_concurrent: usiz
);
}
async fn import_identities(client: &Client, path: &Path) {
async fn import_identities(client: &jmap_client::client::Client, path: &Path) {
// Deserialize mailboxes
let identities = read_json::<jmap_client::identity::Identity>(path, "identities.json").await;
if identities.is_empty() {
@@ -886,7 +895,7 @@ async fn import_identities(client: &Client, path: &Path) {
);
}
async fn import_vacation_responses(client: &Client, path: &Path) {
async fn import_vacation_responses(client: &jmap_client::client::Client, path: &Path) {
// Deserialize mailboxes
let vacation_responses =
read_json::<jmap_client::vacation_response::VacationResponse>(path, "vacation.json").await;
+155
View File
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2020-2023, Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use reqwest::Method;
use serde_json::Value;
use crate::modules::{Principal, Type};
use super::{
cli::{Client, ListCommands},
PrincipalField, PrincipalUpdate, PrincipalValue,
};
impl ListCommands {
pub async fn exec(self, client: Client) {
match self {
ListCommands::Create {
name,
email,
description,
members,
} => {
let principal = Principal {
id: None,
typ: Some(Type::List),
quota: None,
used_quota: None,
name: name.clone().into(),
secrets: vec![],
emails: vec![email],
member_of: members.unwrap_or_default(),
description,
};
let account_id = client
.http_request::<u32, _>(Method::POST, "/admin/principal", Some(principal))
.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::MemberOf,
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!("/admin/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!("/admin/principal/{name}"),
Some(
members
.into_iter()
.map(|group| {
PrincipalUpdate::add_item(
PrincipalField::MemberOf,
PrincipalValue::String(group),
)
})
.collect::<Vec<_>>(),
),
)
.await;
eprintln!("Successfully updated mailing list {name:?}.");
}
ListCommands::RemoveMembers { name, members } => {
client
.http_request::<Value, _>(
Method::PATCH,
&format!("/admin/principal/{name}"),
Some(
members
.into_iter()
.map(|group| {
PrincipalUpdate::remove_item(
PrincipalField::MemberOf,
PrincipalValue::String(group),
)
})
.collect::<Vec<_>>(),
),
)
.await;
eprintln!("Successfully updated mailing list {name:?}.");
}
ListCommands::Display { name } => {
client.display_principal(&name).await;
}
ListCommands::List { from, limit } => {
client
.list_principals("list", "Mailing List", from, limit)
.await;
}
}
}
}
+114 -38
View File
@@ -30,16 +30,130 @@ use jmap_client::{
Property,
},
};
use serde::{Deserialize, Serialize};
pub mod account;
pub mod cli;
pub mod database;
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 {
pub id: Option<u32>,
#[serde(rename = "type")]
pub typ: Option<Type>,
pub quota: Option<u32>,
#[serde(rename = "usedQuota")]
pub used_quota: Option<u32>,
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 = "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,
}
#[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(u32),
Type(Type),
}
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;
}
@@ -114,44 +228,6 @@ pub fn read_file(path: &str) -> Vec<u8> {
}
}
pub async fn get(url: &str) -> HashMap<String, serde_json::Value> {
serde_json::from_slice(
&reqwest::Client::builder()
.danger_accept_invalid_certs(is_localhost(url))
.build()
.unwrap_or_default()
.get(url)
.send()
.await
.unwrap_result("send OAuth GET request")
.bytes()
.await
.unwrap_result("fetch bytes"),
)
.unwrap_result("deserialize OAuth GET response")
}
pub async fn post(
url: &str,
params: &HashMap<String, String>,
) -> HashMap<String, serde_json::Value> {
serde_json::from_slice(
&reqwest::Client::builder()
.danger_accept_invalid_certs(is_localhost(url))
.build()
.unwrap_or_default()
.post(url)
.form(params)
.send()
.await
.unwrap_result("send OAuth POST request")
.bytes()
.await
.unwrap_result("fetch bytes"),
)
.unwrap_result("deserialize OAuth POST response")
}
pub async fn name_to_id(client: &Client, name: &str) -> String {
let filter = if name.contains('@') {
query::Filter::email(name)
+343 -371
View File
@@ -21,14 +21,13 @@
* for more details.
*/
use super::{cli::QueueCommands, is_localhost, UnwrapResult};
use super::cli::{Client, QueueCommands};
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};
use reqwest::Method;
use serde::{Deserialize, Deserializer};
#[derive(Debug, Deserialize, PartialEq, Eq)]
pub struct Message {
@@ -76,391 +75,365 @@ pub enum Status {
PermanentFailure(String),
}
pub async 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).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 (message, id) in smtp_manage_request::<Vec<Option<Message>>>(
&build_query(url, "/admin/queue/status?ids=", chunk),
&credentials,
)
.await
.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, "/admin/queue/status?ids=", &parse_ids(&ids)),
&credentials,
)
.await
.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,
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 (message, id) in client
.http_request::<Vec<Option<Message>>, String>(
Method::GET,
&build_query("/admin/queue/status?ids=", chunk),
None,
)
.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()),
]));
.await
.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 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),
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 client
.http_request::<Vec<Option<Message>>, String>(
Method::GET,
&build_query("/admin/queue/status?ids=", &parse_ids(&ids)),
None,
)
.await
.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 {
"<>"
}),
]));
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("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),
]));
}
table.add_row(Row::new(vec![
Cell::new("Recipients").with_style(Attr::Bold),
Cell::from(&rcpts),
]));
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 = 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 {
table.add_row(Row::new(vec![Cell::new_align(
"-- Not found --",
Alignment::CENTER,
)
.with_hspan(2)]));
(parse_ids(&ids), ids)
};
if ids.is_empty() {
eprintln!("No messages were found.");
std::process::exit(1);
}
let mut query = form_urlencoded::Serializer::new("/admin/queue/retry?".to_string());
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 client
.http_request::<Vec<bool>, String>(Method::GET, &query.finish(), None)
.await
.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!();
table.printstd();
}
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 query =
form_urlencoded::Serializer::new("/admin/queue/cancel?".to_string());
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 client
.http_request::<Vec<bool>, String>(Method::GET, &query.finish(), None)
.await
.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!();
}
}
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).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 query = form_urlencoded::Serializer::new(format!("{url}/admin/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)
.await
.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).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 query = form_urlencoded::Serializer::new(format!("{url}/admin/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)
.await
.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 },
}
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("/admin/queue/list?".to_string());
pub async fn smtp_manage_request<T: DeserializeOwned>(url: &str, credentials: &Credentials) -> T {
match serde_json::from_slice::<Response<T>>(
&reqwest::Client::builder()
.danger_accept_invalid_certs(is_localhost(url))
.build()
.unwrap_or_default()
.get(url)
.header(
AUTHORIZATION,
match credentials {
Credentials::Basic(s) => format!("Basic {s}"),
Credentials::Bearer(s) => format!("Bearer {s}"),
},
)
.send()
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::<Vec<u64>, String>(Method::GET, &query.finish(), None)
.await
.unwrap_result("send GET request")
.bytes()
.await
.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);
}
}
}
async 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}/admin/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).await
}
fn deserialize_maybe_datetime<'de, D>(deserializer: D) -> Result<Option<DateTime>, D::Error>
where
D: Deserializer<'de>,
@@ -507,9 +480,8 @@ fn parse_ids(ids: &[String]) -> Vec<u64> {
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);
fn build_query(path: &str, ids: &[u64]) -> String {
let mut query = String::with_capacity(path.len() + (ids.len() * 10));
query.push_str(path);
append_ids(query, ids)
}
+133 -123
View File
@@ -21,13 +21,13 @@
* for more details.
*/
use super::cli::{ReportCommands, ReportFormat};
use crate::modules::queue::{deserialize_datetime, smtp_manage_request};
use super::cli::{Client, ReportCommands, ReportFormat};
use crate::modules::queue::deserialize_datetime;
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::Method;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
@@ -42,150 +42,160 @@ pub struct Report {
pub size: usize,
}
pub async 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}/admin/report/list?"));
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("/admin/report/list?".to_string());
if let Some(domain) = &domain {
query.append_pair("domain", domain);
}
if let Some(format) = &format {
query.append_pair("type", format.id());
}
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).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", "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}/admin/report/status?ids={}", chunk.join(",")),
&credentials,
)
.await
.into_iter()
.zip(chunk)
let ids = client
.http_request::<Vec<String>, String>(Method::GET, &query.finish(), None)
.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", "Domain", "Type", "From Date", "To Date", "Size"]
.iter()
.map(|p| Cell::new(p).with_style(Attr::Bold))
.collect(),
));
for (report, id) in client
.http_request::<Vec<Option<Report>>, String>(
Method::GET,
&format!("/admin/report/status?ids={}", chunk.join(",")),
None,
)
.await
.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 client
.http_request::<Vec<Option<Report>>, String>(
Method::GET,
&format!("/admin/report/status?ids={}", ids.join(",")),
None,
)
.await
.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(id),
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!();
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!();
table.printstd();
eprintln!();
}
}
eprintln!("\n{ids_len} queued message(s) found.")
}
ReportCommands::Status { ids } => {
for (report, id) in smtp_manage_request::<Vec<Option<Report>>>(
&format!("{url}/admin/report/status?ids={}", ids.join(",")),
&credentials,
)
.await
.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,
ReportCommands::Cancel { ids } => {
let mut success_count = 0;
let mut failed_list = vec![];
for (success, id) in client
.http_request::<Vec<bool>, String>(
Method::GET,
&format!("/admin/report/cancel?ids={}", ids.join(",")),
None,
)
.with_hspan(2)]));
.await
.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!();
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}/admin/report/cancel?ids={}", ids.join(",")),
&credentials,
)
.await
.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!();
}
}
}