//! Outgoing mail (hub spec §5.1.1, §10): SMTP through lettre, or, for //! development and tests, `file:///dir`, which writes each message to that //! directory instead of sending it. use crate::config::Email; use crate::Shared; pub const SETTING_SMTP: &str = "mail.smtp"; pub const SETTING_FROM: &str = "mail.from"; /// The mail settings in force: what the admin page saved (#101), else /// hub.toml's `[email]`. pub async fn load(db: &crate::db::Pool, from_config: &Email) -> Email { let mut mail = from_config.clone(); if let Ok(Some(s)) = crate::db::accounts::setting(db, SETTING_SMTP).await { mail.smtp = s; mail.from = crate::db::accounts::setting(db, SETTING_FROM).await.ok().flatten().unwrap_or_default(); } mail } pub fn settings(app: &Shared) -> Email { app.mail.read().unwrap_or_else(|p| p.into_inner()).clone() } /// Save the settings and use them from now on. An empty URL turns mail /// off. pub async fn save(app: &Shared, smtp: &str, from: &str) -> sqlx::Result<()> { crate::db::accounts::set_setting(&app.db, SETTING_SMTP, smtp).await?; crate::db::accounts::set_setting(&app.db, SETTING_FROM, from).await?; *app.mail.write().unwrap_or_else(|p| p.into_inner()) = Email { smtp: smtp.to_string(), from: from.to_string() }; Ok(()) } /// The SMTP URL with its password hidden, for the page. pub fn masked(smtp: &str) -> String { let Some((scheme, rest)) = smtp.split_once("://") else { return smtp.to_string() }; let Some((userinfo, host)) = rest.rsplit_once('@') else { return smtp.to_string() }; match userinfo.split_once(':') { Some((user, _)) => format!("{scheme}://{user}:***@{host}"), None => smtp.to_string(), } } /// Whether the hub can send mail at all. pub fn enabled(app: &Shared) -> bool { let cfg = settings(app); !cfg.smtp.is_empty() && !cfg.from.is_empty() } /// Send a plain-text message. The subject gets the site's name in brackets. pub async fn send(app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str) -> Result<(), String> { if !enabled(app) { return Err("this hub does not send mail".into()); } let cfg = settings(app); let subject = format!("[{}] {subject}", app.config.site.name); if let Some(dir) = cfg.smtp.strip_prefix("file://") { let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4)); let text = format!("From: {}\nTo: {to_name} <{to_addr}>\nSubject: {subject}\n\n{body}", cfg.from); return tokio::fs::write(std::path::Path::new(dir).join(name), text).await.map_err(|e| format!("write mail to {dir}: {e}")); } use lettre::message::header::ContentType; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?; let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?; let message = Message::builder() .from(from) .to(to) .subject(subject) .header(ContentType::TEXT_PLAIN) .body(body.to_string()) .map_err(|e| e.to_string())?; let transport = AsyncSmtpTransport::::from_url(&cfg.smtp).map_err(|e| format!("[email] smtp: {e}"))?.build(); transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}")) }