Initial commit

master
Dustin 2023-12-19 17:09:28 -06:00
commit 3e9242e731
5 changed files with 1875 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
target/

1574
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -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 }

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
max_width = 79

281
src/main.rs Normal file
View File

@ -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()
}