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