use std::time::Duration;
use anyhow::{Context, Result};
use kanade_shared::config::{MailEncryption, MailSection};
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncTransport, Message, Tokio1Executor};
use tracing::warn;
const SMTP_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct Mailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
}
impl Mailer {
pub fn from_config(cfg: &MailSection, password: Option<String>) -> Result<Self> {
let from: Mailbox = cfg
.from
.parse()
.with_context(|| format!("invalid [mail] from address: {:?}", cfg.from))?;
let mut builder = match cfg.encryption {
MailEncryption::Starttls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
.with_context(|| format!("STARTTLS relay setup for {:?}", cfg.host))?
}
MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)
.with_context(|| format!("implicit-TLS relay setup for {:?}", cfg.host))?,
MailEncryption::None => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
}
}
.port(cfg.port)
.timeout(Some(SMTP_TIMEOUT));
match (cfg.username.as_deref(), password) {
(Some(user), Some(pass)) if !user.is_empty() => {
builder = builder.credentials(Credentials::new(user.to_owned(), pass));
}
(Some(user), None) if !user.is_empty() => {
warn!(
user,
"[mail] username is set but no MailPassword secret / \
$KANADE_MAIL_PASSWORD — sending unauthenticated"
);
}
_ => {}
}
Ok(Self {
transport: builder.build(),
from,
})
}
pub async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<()> {
let mailboxes: Vec<Mailbox> = to
.iter()
.filter_map(|addr| match addr.parse::<Mailbox>() {
Ok(mb) => Some(mb),
Err(e) => {
warn!(addr, error = %e, "skipping invalid email recipient");
None
}
})
.collect();
if mailboxes.is_empty() {
anyhow::bail!("no valid recipients among {} address(es)", to.len());
}
let mut delivered = 0usize;
let mut last_err = None;
for mb in mailboxes {
let email = Message::builder()
.from(self.from.clone())
.to(mb.clone())
.subject(subject)
.body(body.to_owned())
.context("build email message")?;
match self.transport.send(email).await {
Ok(_) => delivered += 1,
Err(e) => {
warn!(to = %mb, error = %e, "SMTP send to one recipient failed");
last_err = Some(e);
}
}
}
if delivered == 0 {
let e = last_err.expect("non-empty recipients ⇒ at least one attempt");
return Err(anyhow::Error::new(e).context("all SMTP sends failed"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(encryption: MailEncryption) -> MailSection {
MailSection {
host: "smtp.example.com".into(),
port: 587,
encryption,
from: "kanade-noreply@example.com".into(),
username: None,
}
}
#[tokio::test]
async fn builds_for_each_encryption() {
for enc in [
MailEncryption::Starttls,
MailEncryption::Tls,
MailEncryption::None,
] {
assert!(
Mailer::from_config(&cfg(enc), None).is_ok(),
"from_config should build for {enc:?}"
);
}
}
#[tokio::test]
async fn builds_with_credentials() {
let mut c = cfg(MailEncryption::Starttls);
c.username = Some("kanade-noreply".into());
assert!(Mailer::from_config(&c, Some("hunter2".into())).is_ok());
}
#[tokio::test]
async fn rejects_a_malformed_from_address() {
let mut c = cfg(MailEncryption::Starttls);
c.from = "not an email".into();
assert!(Mailer::from_config(&c, None).is_err());
}
}