#![allow(
clippy::collapsible_else_if,
clippy::collapsible_if,
clippy::fn_to_numeric_cast,
clippy::let_and_return,
clippy::let_unit_value
)]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod config;
#[cfg(feature = "pgp")]
mod pgp;
mod rand;
#[cfg(feature = "pgp")]
use std::borrow::Cow;
use std::marker::PhantomData;
use std::path::Path;
use std::str;
use anyhow::Context as _;
use anyhow::Error;
use anyhow::Result;
use lettre::message::header::ContentDisposition;
use lettre::message::header::ContentType;
use lettre::message::MaybeString;
use lettre::message::MultiPart;
use lettre::message::SinglePart;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
use lettre::Message;
use lettre::Tokio1Executor;
#[cfg(feature = "config")]
#[cfg_attr(docsrs, doc(cfg(feature = "config")))]
pub use crate::config::system_config;
#[cfg(feature = "config")]
#[cfg_attr(docsrs, doc(cfg(feature = "config")))]
pub use crate::config::system_config_path;
pub use crate::config::Account;
#[cfg(feature = "config")]
#[cfg_attr(docsrs, doc(cfg(feature = "config")))]
pub use crate::config::Config;
pub use crate::config::SmtpMode;
#[cfg(feature = "pgp")]
use crate::pgp::encrypt;
use crate::rand::RandExt as _;
use crate::rand::Rng;
#[cfg(feature = "tracing")]
#[macro_use]
#[allow(unused_imports)]
mod log {
pub(crate) use tracing::debug;
pub(crate) use tracing::error;
pub(crate) use tracing::info;
pub(crate) use tracing::instrument;
pub(crate) use tracing::trace;
pub(crate) use tracing::warn;
}
#[cfg(not(feature = "tracing"))]
#[macro_use]
#[allow(unused_imports)]
mod log {
macro_rules! debug {
($($args:tt)*) => {};
}
pub(crate) use debug;
pub(crate) use debug as error;
pub(crate) use debug as info;
pub(crate) use debug as trace;
pub(crate) use debug as warn;
}
#[derive(Clone, Debug, Default)]
pub struct EmailOpts<'input> {
#[cfg(feature = "pgp")]
#[cfg_attr(docsrs, doc(cfg(feature = "pgp")))]
pub pgp_keybox: Option<Cow<'input, Path>>,
#[doc(hidden)]
pub _phantom: PhantomData<&'input ()>,
}
#[cfg(not(feature = "pgp"))]
fn encrypt<R, S>(_message: &[u8], _keybox: &Path, _recipients: R) -> Result<Vec<u8>>
where
R: IntoIterator<Item = S>,
S: AsRef<str>,
{
unreachable!()
}
#[cfg_attr(feature = "tracing", log::instrument(skip_all, err, fields(subject = subject, from = %account.from)))]
async fn try_send_email<R, S>(
account: &Account<'_>,
subject: &str,
message: &[u8],
content_type: Option<&str>,
recipients: R,
opts: &EmailOpts<'_>,
) -> Result<()>
where
R: Iterator<Item = S> + Clone,
S: AsRef<str>,
{
let from = account
.from
.parse()
.with_context(|| format!("failed to parse 'From' specification: `{}`", account.from))?;
let content_type = content_type
.map(|content_type| {
ContentType::parse(content_type)
.with_context(|| format!("failed to parse content type specification `{content_type}`"))
})
.transpose()?
.unwrap_or(ContentType::TEXT_PLAIN);
let mut email = Message::builder().from(from).subject(subject);
let EmailOpts {
#[cfg(feature = "pgp")]
pgp_keybox,
_phantom: PhantomData,
} = opts;
#[cfg(not(feature = "pgp"))]
let pgp_keybox = None;
for recipient in recipients.clone() {
let recipient = recipient.as_ref();
let to = recipient
.parse()
.with_context(|| format!("failed to parse 'To' specification: `{recipient}`"))?;
email = email.to(to);
}
let creds = Credentials::new(account.user.to_string(), account.password.to_string());
let mailer = match account.smtp_mode {
SmtpMode::Unencrypted => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(account.smtp_host.to_string())
.credentials(creds)
.build()
},
SmtpMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&account.smtp_host)
.context("failed to create TLS SMTP mailer")?
.credentials(creds)
.build(),
SmtpMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&account.smtp_host)
.context("failed to create STARTTLS SMTP mailer")?
.credentials(creds)
.build(),
};
let email = if let Some(keybox) = pgp_keybox {
let inner = MultiPart::mixed().singlepart(
SinglePart::builder()
.header(content_type)
.body(message.to_vec()),
);
let message =
encrypt(&inner.formatted(), keybox, recipients).context("failed to encrypt message")?;
let message =
str::from_utf8(&message).context("PGP encrypted message is not a valid UTF-8 string")?;
let parts = MultiPart::encrypted("application/pgp-encrypted".to_owned())
.singlepart(
SinglePart::builder()
.header(
ContentType::parse("application/pgp-encrypted")
.context("failed to parse 'application/pgp-encrypted' content type header")?,
)
.body(String::from("Version: 1")),
)
.singlepart(
SinglePart::builder()
.header(
ContentType::parse(r#"application/octet-stream; name="encrypted.asc""#)
.context("failed to parse 'application/octet-stream' content type header")?,
)
.header(ContentDisposition::inline_with_name("encrypted.asc"))
.body(message.to_string()),
);
email
.multipart(parts)
.context("failed to create email message")?
} else {
let body = if let Ok(message) = str::from_utf8(message) {
MaybeString::String(message.to_string())
} else {
MaybeString::Binary(message.to_vec())
};
email
.header(content_type)
.body(body)
.context("failed to create email message")?
};
log::trace!(email = %String::from_utf8_lossy(&email.formatted()));
let _mailer = mailer
.send(email)
.await
.with_context(|| format!("failed to send email via {}", account.smtp_host))?;
log::debug!("email sent successfully");
Ok(())
}
pub async fn send_email<'acc, A, R, I, S>(
accounts: A,
subject: &str,
message: &[u8],
content_type: Option<&str>,
recipients: R,
opts: &EmailOpts<'_>,
) -> Result<()>
where
A: IntoIterator<Item = &'acc Account<'acc>>,
R: IntoIterator<IntoIter = I>,
I: Iterator<Item = S> + Clone,
S: AsRef<str>,
{
let mut accounts = accounts.into_iter().collect::<Vec<&Account<'_>>>();
let rng = Rng::new();
let () = rng.shuffle(&mut accounts);
let recipients = recipients.into_iter();
let mut overall_result = Result::<_, Error>::Ok(());
for account in accounts {
if let Err(err) = &overall_result {
let _result = try_send_email(
account,
"email error",
format!("{err:?}").as_bytes(),
None,
recipients.clone(),
opts,
)
.await;
}
let result = try_send_email(
account,
subject,
message,
content_type,
recipients.clone(),
opts,
)
.await;
match result {
Ok(()) => return Ok(()),
Err(err) => {
if let Err(overall_err) = overall_result {
overall_result = Err(overall_err.context(err));
} else {
overall_result = Err(err);
}
},
}
}
overall_result
}