Initial commit
commit
3e9242e731
|
@ -0,0 +1 @@
|
||||||
|
target/
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "sendemail"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = [
|
||||||
|
"Dustin C. Hatch <dustin@hatch.name>",
|
||||||
|
]
|
||||||
|
description = "Send e-mail messages from the command line"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
argh = "0.1.12"
|
||||||
|
lettre = { version = "0.11.2", default-features = false, features = ["builder", "native-tls", "smtp-transport", "tracing"] }
|
||||||
|
mime_guess2 = { version = "2.0.5", default-features = false }
|
||||||
|
serde_json = "1.0.108"
|
||||||
|
tera = { version = "1.19.1", default-features = false, features = ["chrono", "builtins"] }
|
||||||
|
thiserror = "1.0.51"
|
||||||
|
whoami = { version = "1.4.1", default-features = false }
|
|
@ -0,0 +1 @@
|
||||||
|
max_width = 79
|
|
@ -0,0 +1,281 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use lettre::address::AddressError;
|
||||||
|
use lettre::message::header::ContentType;
|
||||||
|
use lettre::message::{Attachment, Body};
|
||||||
|
use lettre::message::{MultiPart, SinglePart};
|
||||||
|
use lettre::transport::smtp::client::Tls;
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::Message;
|
||||||
|
use lettre::{SmtpTransport, Transport};
|
||||||
|
|
||||||
|
#[derive(argh::FromArgs)]
|
||||||
|
/// Send e-mail messages from the command line
|
||||||
|
struct Arguments {
|
||||||
|
#[argh(option, short = 't')]
|
||||||
|
/// message recipient(s)
|
||||||
|
to: Vec<String>,
|
||||||
|
#[argh(option, short = 'c')]
|
||||||
|
/// carbon copy recipient(s)
|
||||||
|
cc: Vec<String>,
|
||||||
|
#[argh(option, short = 'b')]
|
||||||
|
/// blind carbon copy recipient(s)
|
||||||
|
bcc: Vec<String>,
|
||||||
|
#[argh(option, short = 'f', default = "default_from()")]
|
||||||
|
/// message sender
|
||||||
|
from: String,
|
||||||
|
#[argh(option, short = 's', default = "Default::default()")]
|
||||||
|
/// message subject
|
||||||
|
subject: String,
|
||||||
|
|
||||||
|
#[argh(option, default = "false")]
|
||||||
|
/// force html body
|
||||||
|
html: bool,
|
||||||
|
#[argh(positional)]
|
||||||
|
/// message body file
|
||||||
|
body: Option<PathBuf>,
|
||||||
|
#[argh(option, short = 'a')]
|
||||||
|
/// alternative body file
|
||||||
|
alternative: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[argh(option, short = 'C')]
|
||||||
|
/// message template context
|
||||||
|
context: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[argh(option)]
|
||||||
|
/// add attachment
|
||||||
|
attach: Vec<PathBuf>,
|
||||||
|
#[argh(option)]
|
||||||
|
/// add inline attachment
|
||||||
|
attach_inline: Vec<PathBuf>,
|
||||||
|
|
||||||
|
#[argh(option, default = "\"localhost\".into()")]
|
||||||
|
/// smtp relay host
|
||||||
|
smtp_relay: String,
|
||||||
|
#[argh(option, default = "25")]
|
||||||
|
/// smtp relay port
|
||||||
|
smtp_port: u16,
|
||||||
|
#[argh(option, default = "false")]
|
||||||
|
/// use tls smtp security
|
||||||
|
tls: bool,
|
||||||
|
#[argh(option, default = "false")]
|
||||||
|
/// use starttls smtp security
|
||||||
|
starttls: bool,
|
||||||
|
#[argh(option)]
|
||||||
|
/// smtp authentication username
|
||||||
|
username: Option<String>,
|
||||||
|
#[argh(option)]
|
||||||
|
/// smtp authentication password file
|
||||||
|
password_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum SendEmailError {
|
||||||
|
#[error("Bad From address: {0}")]
|
||||||
|
BadFrom(AddressError),
|
||||||
|
#[error("Bad To address: {0}")]
|
||||||
|
BadTo(AddressError),
|
||||||
|
#[error("Bad CC address: {0}")]
|
||||||
|
BadCc(AddressError),
|
||||||
|
#[error("Bad BCC address: {0}")]
|
||||||
|
BadBcc(AddressError),
|
||||||
|
|
||||||
|
#[error("Error opening file: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Error building message: {0}")]
|
||||||
|
Message(#[from] lettre::error::Error),
|
||||||
|
|
||||||
|
#[error("Failed to load template context: {0}")]
|
||||||
|
Context(#[from] serde_json::Error),
|
||||||
|
#[error("Error rendering message template: {0}")]
|
||||||
|
Render(String),
|
||||||
|
|
||||||
|
#[error("Could not deliver message: {0}")]
|
||||||
|
Delivery(#[from] lettre::transport::smtp::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tera::Error> for SendEmailError {
|
||||||
|
fn from(e: tera::Error) -> Self {
|
||||||
|
if let Some(inner) = std::error::Error::source(&e) {
|
||||||
|
Self::Render(format!("{}: {}", e, inner))
|
||||||
|
} else {
|
||||||
|
Self::Render(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_from() -> String {
|
||||||
|
format!("{}@{}", whoami::username(), whoami::hostname())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Arguments = argh::from_env();
|
||||||
|
|
||||||
|
let message = match build_message(&args) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error building message: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mailer = match get_mailer(&args) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error configuring SMTP client: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = mailer.send(&message) {
|
||||||
|
eprintln!("Failed to send message: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_mailer(args: &Arguments) -> Result<SmtpTransport, SendEmailError> {
|
||||||
|
let mut builder = if args.starttls {
|
||||||
|
SmtpTransport::starttls_relay(&args.smtp_relay)?
|
||||||
|
} else {
|
||||||
|
let mut builder = SmtpTransport::relay(&args.smtp_relay)?;
|
||||||
|
if !args.tls {
|
||||||
|
builder = builder.tls(Tls::None);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
.port(args.smtp_port);
|
||||||
|
if let Some(path) = &args.password_file {
|
||||||
|
let password = std::fs::read_to_string(path)?;
|
||||||
|
let username = match &args.username {
|
||||||
|
Some(u) => u.into(),
|
||||||
|
None => whoami::username(),
|
||||||
|
};
|
||||||
|
builder = builder.credentials(Credentials::new(username, password));
|
||||||
|
|
||||||
|
}
|
||||||
|
Ok(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_message(args: &Arguments) -> Result<Message, SendEmailError> {
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(args.from.parse().map_err(SendEmailError::BadFrom)?)
|
||||||
|
.subject(&args.subject);
|
||||||
|
for addr in &args.to {
|
||||||
|
builder = builder.to(addr.parse().map_err(SendEmailError::BadTo)?);
|
||||||
|
}
|
||||||
|
for addr in &args.cc {
|
||||||
|
builder = builder.cc(addr.parse().map_err(SendEmailError::BadCc)?);
|
||||||
|
}
|
||||||
|
for addr in &args.bcc {
|
||||||
|
builder = builder.bcc(addr.parse().map_err(SendEmailError::BadBcc)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tera = tera::Tera::default();
|
||||||
|
let ctx = if let Some(context) = &args.context {
|
||||||
|
tera::Context::from_value(serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(context)?,
|
||||||
|
)?)?
|
||||||
|
} else {
|
||||||
|
tera::Context::new()
|
||||||
|
};
|
||||||
|
let body = if let Some(body) = &args.body {
|
||||||
|
let name = body.file_name().unwrap().to_string_lossy();
|
||||||
|
tera.add_template_file(body, Some(&name))?;
|
||||||
|
tera.render(&name, &ctx)?
|
||||||
|
} else {
|
||||||
|
let name = "<stdin>";
|
||||||
|
tera.add_raw_template(
|
||||||
|
name,
|
||||||
|
&std::io::read_to_string(std::io::stdin())?,
|
||||||
|
)?;
|
||||||
|
tera.render(name, &ctx)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let ct_header = if args.html {
|
||||||
|
ContentType::TEXT_HTML
|
||||||
|
} else if let Some(body) = &args.body {
|
||||||
|
guess_type(body)
|
||||||
|
} else {
|
||||||
|
ContentType::TEXT_PLAIN
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mp = if let Some(alt) = &args.alternative {
|
||||||
|
let alt = {
|
||||||
|
let name = alt.file_name().unwrap().to_string_lossy();
|
||||||
|
tera.add_template_file(alt, Some(&name))?;
|
||||||
|
tera.render(&name, &ctx)?
|
||||||
|
};
|
||||||
|
if !args.attach_inline.is_empty() {
|
||||||
|
let mut inner = MultiPart::related().singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_HTML)
|
||||||
|
.body(body),
|
||||||
|
);
|
||||||
|
inner = add_inline_attachments(inner, &args.attach_inline)?;
|
||||||
|
inner = MultiPart::alternative().multipart(inner).singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(alt),
|
||||||
|
);
|
||||||
|
if !args.attach.is_empty() {
|
||||||
|
inner = MultiPart::related().multipart(inner)
|
||||||
|
}
|
||||||
|
inner
|
||||||
|
} else {
|
||||||
|
let inner = MultiPart::alternative_plain_html(alt, body);
|
||||||
|
if !args.attach.is_empty() {
|
||||||
|
MultiPart::related().multipart(inner)
|
||||||
|
} else {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !args.attach_inline.is_empty() {
|
||||||
|
let mut inner = MultiPart::related()
|
||||||
|
.singlepart(SinglePart::builder().header(ct_header).body(body));
|
||||||
|
inner = add_inline_attachments(inner, &args.attach_inline)?;
|
||||||
|
if !args.attach.is_empty() {
|
||||||
|
inner = MultiPart::related().multipart(inner)
|
||||||
|
}
|
||||||
|
inner
|
||||||
|
} else if !args.attach.is_empty() {
|
||||||
|
MultiPart::related()
|
||||||
|
.singlepart(SinglePart::builder().header(ct_header).body(body))
|
||||||
|
} else {
|
||||||
|
builder = builder.header(ct_header);
|
||||||
|
return Ok(builder.body(body)?);
|
||||||
|
};
|
||||||
|
|
||||||
|
for path in &args.attach {
|
||||||
|
let content = std::fs::read(path)?;
|
||||||
|
let name = path.file_name().unwrap().to_string_lossy();
|
||||||
|
mp = mp.singlepart(
|
||||||
|
Attachment::new(name.to_string())
|
||||||
|
.body(Body::new(content), guess_type(path)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder.multipart(mp)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_inline_attachments(
|
||||||
|
mut mp: MultiPart,
|
||||||
|
files: &[PathBuf],
|
||||||
|
) -> std::io::Result<MultiPart> {
|
||||||
|
for (idx, path) in files.iter().enumerate() {
|
||||||
|
let content = std::fs::read(path)?;
|
||||||
|
let name = format!("{:03}", idx + 1);
|
||||||
|
mp = mp.singlepart(
|
||||||
|
Attachment::new_inline(name)
|
||||||
|
.body(Body::new(content), guess_type(path)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(mp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guess_type(path: &Path) -> ContentType {
|
||||||
|
mime_guess2::from_path(path)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string()
|
||||||
|
.parse()
|
||||||
|
.unwrap()
|
||||||
|
}
|
Loading…
Reference in New Issue