use boatramp_core::email_config::EmailProfileInfo;
use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error("reading SMTP password: {0}")]
Read(#[source] std::io::Error),
#[error("server returned HTTP {status}: {body}")]
Server { status: u16, body: String },
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct EmailArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[command(subcommand)]
command: EmailCommand,
}
#[derive(Debug, clap::Args)]
struct PasswordSource {
#[arg(long, group = "password_source", value_name = "PASSWORD")]
password: Option<String>,
#[arg(long, group = "password_source")]
password_stdin: bool,
}
#[derive(Debug, Subcommand)]
enum EmailCommand {
Set {
name: String,
#[arg(long)]
host: String,
#[arg(long)]
port: Option<u16>,
#[arg(long, default_value = "starttls")]
security: String,
#[arg(long)]
username: Option<String>,
#[command(flatten)]
password: PasswordSource,
#[arg(long)]
from: String,
#[arg(long)]
durable: bool,
},
Ls,
Show {
name: String,
},
Rm {
name: String,
},
}
pub async fn run(args: EmailArgs, config: &ProjectConfig) -> Result<()> {
let server = client::resolve_server(args.server, config)?;
let http = client::http_client(client::token(config).as_deref());
let seg = client::project_seg(&client::resolve_project(config), "email");
match args.command {
EmailCommand::Set {
name,
host,
port,
security,
username,
password,
from,
durable,
} => {
let password = read_password(password)?;
let resp = http
.put(format!("{server}/api/{seg}/profiles/{name}"))
.json(&SetProfileRequest {
host: &host,
port,
security: &security,
username: username.as_deref(),
password,
from: &from,
durable,
})
.send()
.await?;
let info: EmailProfileInfo = parse_json(resp).await?;
println!(
"set email profile {} ({} {}:{} from {})",
info.name, info.security, info.host, info.port, info.from
);
}
EmailCommand::Ls => {
let resp = http
.get(format!("{server}/api/{seg}/profiles"))
.send()
.await?;
let profiles: Vec<EmailProfileInfo> = parse_json(resp).await?;
if profiles.is_empty() {
println!("no email profiles");
return Ok(());
}
println!(
"{:<20} {:<28} {:<10} {:<28} DURABLE",
"NAME", "HOST", "SECURITY", "FROM"
);
for p in profiles {
println!(
"{:<20} {:<28} {:<10} {:<28} {}",
p.name,
format!("{}:{}", p.host, p.port),
p.security,
p.from,
p.durable
);
}
}
EmailCommand::Show { name } => {
let resp = http
.get(format!("{server}/api/{seg}/profiles/{name}"))
.send()
.await?;
let p: EmailProfileInfo = parse_json(resp).await?;
println!("name: {}", p.name);
println!("host: {}:{}", p.host, p.port);
println!("security: {}", p.security);
println!(
"username: {}",
p.username.as_deref().unwrap_or("(none — unauthenticated)")
);
println!(
"password: {}",
if p.has_password {
"(set, sealed)"
} else {
"(none)"
}
);
println!("from: {}", p.from);
println!("durable: {}", p.durable);
}
EmailCommand::Rm { name } => {
let resp = http
.delete(format!("{server}/api/{seg}/profiles/{name}"))
.send()
.await?;
check_no_content(resp).await?;
println!("removed email profile {name}");
}
}
Ok(())
}
#[derive(serde::Serialize)]
struct SetProfileRequest<'a> {
host: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
port: Option<u16>,
security: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<String>,
from: &'a str,
durable: bool,
}
fn read_password(source: PasswordSource) -> Result<Option<String>> {
use std::io::Read as _;
if source.password_stdin {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(Error::Read)?;
Ok(Some(buf.trim_end_matches('\n').to_string()))
} else if let Some(pw) = source.password {
Ok(Some(pw))
} else {
Ok(None)
}
}
async fn parse_json<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(Error::Server {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).trim().to_string(),
});
}
serde_json::from_slice(&bytes).map_err(|e| Error::Server {
status: status.as_u16(),
body: format!("could not parse response: {e}"),
})
}
async fn check_no_content(resp: reqwest::Response) -> Result<()> {
let status = resp.status();
if status.is_success() {
return Ok(());
}
let bytes = resp.bytes().await?;
Err(Error::Server {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[arg(long, global = true, env = "BOATRAMP_PROJECT")]
project: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Email(EmailArgs),
}
fn parse(argv: &[&str]) -> std::result::Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("boatramp").chain(argv.iter().copied()))
}
#[test]
fn set_parses_with_and_without_a_password_source() {
assert!(parse(&[
"email",
"set",
"default",
"--host",
"smtp.example.com",
"--from",
"a@b.com",
])
.is_ok());
assert!(parse(&[
"email",
"set",
"default",
"--host",
"h",
"--from",
"a@b.com",
"--password",
"pw",
])
.is_ok());
assert!(parse(&[
"email",
"set",
"default",
"--host",
"h",
"--from",
"a@b.com",
"--password-stdin",
])
.is_ok());
assert!(parse(&[
"email",
"set",
"default",
"--host",
"h",
"--from",
"a@b.com",
"--password",
"pw",
"--password-stdin",
])
.is_err());
}
#[test]
fn ls_show_rm_parse_and_project_flag_reaches_the_subcommand() {
assert!(parse(&["email", "ls"]).is_ok());
assert!(parse(&["email", "show", "default"]).is_ok());
assert!(parse(&["email", "rm", "default"]).is_ok());
let cli = parse(&["email", "--project", "acme", "ls"]).expect("parses");
assert_eq!(cli.project.as_deref(), Some("acme"));
}
}