//! 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() } /// The one thing a message asks of its reader, shown as its button. #[derive(Clone, Copy, Debug)] pub struct Action<'a> { pub label: &'a str, pub url: &'a str, } /// Send a message with no button. 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> { send_letter(app, to_name, to_addr, subject, body, None, None).await } /// Send a message as plain text and as HTML in the site's look /// (oxygenhub#182): the Oxygen mark, `action` as a button, and the site's /// links (with `unsubscribe`, when given) in the footer. The text part is /// `body` as written. pub async fn send_letter( app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str, action: Option>, unsubscribe: Option<&str>, ) -> Result<(), String> { if !enabled(app) { return Err("this hub does not send mail".into()); } let sent = deliver(app, to_name, to_addr, subject, body, action, unsubscribe).await; let counter = if sent.is_ok() { &crate::metrics::MAIL_SENT } else { &crate::metrics::MAIL_FAILED }; counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); sent } async fn deliver( app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str, action: Option>, unsubscribe: Option<&str>, ) -> Result<(), String> { let cfg = settings(app); let site = sender_name(&cfg.from).unwrap_or_else(|| app.config.site.name.clone()); let html = html(&site, app.config.base_url(), subject, body, action, unsubscribe); let subject = format!("[{site}] {subject}"); use lettre::message::header::ContentType; use lettre::message::{Attachment, MultiPart, SinglePart}; 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 png = ContentType::parse("image/png").map_err(|e| e.to_string())?; let message = Message::builder() .from(from) .to(to) .subject(subject) .multipart( MultiPart::alternative().singlepart(SinglePart::plain(body.to_string())).multipart( MultiPart::related() .singlepart(SinglePart::html(html)) .singlepart(Attachment::new_inline("logo".to_string()).body(LOGO.to_vec(), png)), ), ) .map_err(|e| e.to_string())?; if let Some(dir) = cfg.smtp.strip_prefix("file://") { let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4)); return tokio::fs::write(std::path::Path::new(dir).join(name), message.formatted()) .await .map_err(|e| format!("write mail to {dir}: {e}")); } let transport = AsyncSmtpTransport::::from_url(&secured(&cfg.smtp)).map_err(|e| format!("[email] smtp: {e}"))?.build(); transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}")) } /// The name mail goes by: the From address's display name /// (`OxygenHub `), in the subject's tag and the footer, so a /// site's mail can read `[OxygenHub]` while the site keeps its own name /// (which also names its apt repository, where a change makes every /// `apt update` ask). `None` without one. fn sender_name(from: &str) -> Option { let (name, _) = from.split_once('<')?; let name = name.trim().trim_matches('"').trim(); (!name.is_empty()).then(|| name.to_string()) } /// The Oxygen mark, sent inline with each message (`cid:logo`), so it /// shows without loading anything from the web. const LOGO: &[u8] = include_bytes!("../../../assets/icons/oxygenhub-mail.png"); fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } /// A paragraph's text as HTML: escaped, its web addresses linked. fn linked(text: &str) -> String { let re = regex::Regex::new(r"https?://[^\s<>]+[^\s<>.,;:!?)]").expect("a valid pattern"); let mut out = String::new(); let mut last = 0; for m in re.find_iter(text) { out.push_str(&esc(&text[last..m.start()])); let u = esc(m.as_str()); out.push_str(&format!(r#"{u}"#)); last = m.end(); } out.push_str(&esc(&text[last..])); out.replace('\n', "
") } /// The HTML part of a message (oxygenhub#182): a card on a light ground, /// the Oxygen band with the mark and wordmark, the subject as its heading, /// the body's paragraphs, `action` as a button with its link beneath for /// clients that hide buttons, and a footer of the site's links. Tables /// and inline styles, as mail clients need; dark-mode clients get their /// own colours. pub fn html(site: &str, base: &str, subject: &str, body: &str, action: Option>, unsubscribe: Option<&str>) -> String { let base = base.trim_end_matches('/'); let host = base.split("://").nth(1).unwrap_or(base); let button = action .map(|a| { let (label, url) = (esc(a.label), esc(a.url)); format!( r#"
{label}

Or open this link: {url}

"# ) }) .unwrap_or_default(); // The action's address becomes its button, where it stood in the text; // the footer says how to stop. let mut placed = false; let mut paras = String::new(); for p in body.split("\n\n").map(str::trim).filter(|p| !p.is_empty()) { if action.is_some_and(|a| p == a.url) { if !placed { paras.push_str(&button); placed = true; } continue; } if unsubscribe.is_some_and(|u| p.contains(u)) { continue; } paras.push_str(&format!( r#"

{}

"#, linked(p) )); } let button = if placed { String::new() } else { button }; let stop = unsubscribe.map(|u| format!(r#" · Stop these emails"#, esc(u))).unwrap_or_default(); let (site_e, subject_e, host_e) = (esc(site), esc(subject), esc(host)); format!( r#" {subject_e}
{subject_e}
"# ) } #[cfg(test)] mod html_tests { use super::*; #[test] fn a_message_is_a_card_with_its_button_and_the_site_s_links() { let body = "Confirm that a@example.org is yours:\n\nhttps://hub.example/verify/abc\n\nThe link works for 48 hours. Not HTML."; let h = html( "OxygenHub", "https://hub.example/", "Confirm your email address", body, Some(Action { label: "Confirm my email address", url: "https://hub.example/verify/abc" }), Some("https://hub.example/settings/notifications?unsubscribe=k"), ); assert!(h.contains(r#"Confirm my email address") && h.contains(r#"href="https://hub.example/verify/abc""#)); assert_eq!( h.matches("https://hub.example/verify/abc").count(), 1, "the address once, under the button, not as a paragraph too" ); assert!(h.contains("<b>Not</b> HTML."), "the body is escaped"); assert!(h.contains(r#"href="https://hub.example/explore""#) && h.contains("Stop these emails")); assert!(h.contains("at hub.example,")); } } /// An SMTP URL as it is sent with: `smtp://` that says nothing of TLS /// requires STARTTLS, so a password never crosses in the clear (lettre's /// own default is none), except to a relay on this machine. `smtps://` /// (TLS from the start, port 465) and an explicit `?tls=` stay as written. pub fn secured(url: &str) -> String { let Some(rest) = url.strip_prefix("smtp://") else { return url.to_string() }; if url.contains("tls=") { return url.to_string(); } let host = rest.rsplit('@').next().unwrap_or(rest); let host = host.split(['/', '?']).next().unwrap_or(host); let host = host.rsplit_once(':').map_or(host, |(h, _)| h).trim_matches(['[', ']']); if matches!(host, "localhost" | "127.0.0.1" | "::1") { return url.to_string(); } format!("{url}{}tls=required", if url.contains('?') { "&" } else { "?" }) } #[cfg(test)] mod secured_tests { use super::secured; #[test] fn smtp_urls_require_starttls_unless_they_say_otherwise() { assert_eq!(secured("smtp://resend:key@smtp.resend.com:587"), "smtp://resend:key@smtp.resend.com:587?tls=required"); assert_eq!(secured("smtps://resend:key@smtp.resend.com:465"), "smtps://resend:key@smtp.resend.com:465"); assert_eq!(secured("smtp://relay.example:25?tls=opportunistic"), "smtp://relay.example:25?tls=opportunistic"); assert_eq!(secured("smtp://localhost:25"), "smtp://localhost:25", "a relay on this machine"); assert_eq!(secured("smtp://u:p@127.0.0.1:1025"), "smtp://u:p@127.0.0.1:1025"); assert_eq!(secured("file:///tmp/mail"), "file:///tmp/mail"); } } /// The services the Mail panel knows (oxygenhub#181): each is SMTP /// underneath, so the hub stores and sends one standard SMTP URL; the /// panel only fills in the server and the login a service expects. pub const SERVICES: &[(&str, &str)] = &[ ("off", "Off"), ("resend", "Resend"), ("ses", "Amazon SES"), ("mailgun", "Mailgun"), ("smtp", "Another SMTP server"), ("url", "An SMTP URL (advanced)"), ]; /// A stored SMTP URL read back for the panel: which service, and its parts /// but the secret. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Choice { pub service: String, /// SES's AWS region, or Mailgun's `us` or `eu`. pub region: String, pub user: String, pub host: String, pub port: String, /// `tls` (from the start), `starttls` or `none`. pub security: String, /// Whether a password or key is saved. pub has_secret: bool, } /// Percent-encoding for a URL's user and password: all but unreserved /// characters (lettre decodes them). fn enc(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), _ => format!("%{b:02X}"), }) .collect() } fn dec(s: &str) -> String { let b = s.as_bytes(); let mut out = Vec::new(); let mut i = 0; while i < b.len() { if b[i] == b'%' && i + 2 < b.len() { if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(v); i += 3; continue; } } out.push(b[i]); i += 1; } String::from_utf8_lossy(&out).into_owned() } /// The parts of an SMTP URL: scheme, user, password (as stored, encoded), /// host, port, query. fn parts(url: &str) -> Option<(&str, &str, &str, &str, &str, &str)> { let (scheme, rest) = url.split_once("://")?; let (rest, query) = rest.split_once('?').unwrap_or((rest, "")); let (userinfo, hostport) = rest.rsplit_once('@').unwrap_or(("", rest)); let (user, pass) = userinfo.split_once(':').unwrap_or((userinfo, "")); let hostport = hostport.trim_end_matches('/'); let (host, port) = hostport.rsplit_once(':').unwrap_or((hostport, "")); Some((scheme, user, pass, host, port, query)) } /// How the panel shows a stored SMTP URL. pub fn describe(url: &str) -> Choice { if url.is_empty() { return Choice { service: "off".into(), ..Default::default() }; } let Some((scheme, user, pass, host, port, query)) = parts(url).filter(|p| p.0 == "smtp" || p.0 == "smtps") else { return Choice { service: "url".into(), ..Default::default() }; }; let mut c = Choice { user: dec(user), host: host.to_string(), port: port.to_string(), has_secret: !pass.is_empty(), security: if scheme == "smtps" { "tls".into() } else if query.contains("tls=none") { "none".into() } else { "starttls".into() }, ..Default::default() }; c.service = if host == "smtp.resend.com" { "resend".into() } else if let Some(region) = host.strip_prefix("email-smtp.").and_then(|h| h.strip_suffix(".amazonaws.com")) { c.region = region.to_string(); "ses".into() } else if host == "smtp.mailgun.org" || host == "smtp.eu.mailgun.org" { c.region = if host.contains(".eu.") { "eu".into() } else { "us".into() }; "mailgun".into() } else if query.contains("tls=opportunistic") { "url".into() } else { "smtp".into() }; c } /// What the panel asks for a service. #[derive(Clone, Debug, Default)] pub struct Asked { pub service: String, pub region: String, pub user: String, /// A new password or key; empty keeps the saved one where it still /// fits (the same service, server and user). pub secret: String, pub host: String, pub port: String, pub security: String, } /// The SMTP URL for what the panel asked, given the URL saved now; or what /// is missing, said plainly. pub fn compose(a: &Asked, current: &str) -> Result { let region = a.region.trim(); let (scheme, user, host, port, query): (&str, String, String, String, &str) = match a.service.as_str() { "resend" => ("smtps", "resend".into(), "smtp.resend.com".into(), "465".into(), ""), "ses" => { if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err("Give the SES region, such as eu-west-2.".into()); } if a.user.trim().is_empty() { return Err("Give the SES SMTP user name (from SES › SMTP settings; not your AWS access key).".into()); } ("smtps", a.user.trim().to_string(), format!("email-smtp.{region}.amazonaws.com"), "465".into(), "") } "mailgun" => { let host = match region { "eu" => "smtp.eu.mailgun.org", "us" | "" => "smtp.mailgun.org", _ => return Err("Mailgun's region is us or eu.".into()), }; if a.user.trim().is_empty() { return Err("Give the Mailgun SMTP login, such as postmaster@mg.example.org.".into()); } ("smtps", a.user.trim().to_string(), host.into(), "465".into(), "") } "smtp" => { let host = a.host.trim(); if host.is_empty() || host.contains(['/', '@', ' ']) { return Err("Give the SMTP server's host name.".into()); } let port = a.port.trim(); if !port.is_empty() && port.parse::().is_err() { return Err("The port is a number, such as 465 or 587.".into()); } match a.security.as_str() { "tls" => ("smtps", a.user.trim().to_string(), host.into(), if port.is_empty() { "465".into() } else { port.into() }, ""), "none" => { ("smtp", a.user.trim().to_string(), host.into(), if port.is_empty() { "25".into() } else { port.into() }, "tls=none") } _ => ( "smtp", a.user.trim().to_string(), host.into(), if port.is_empty() { "587".into() } else { port.into() }, "tls=required", ), } } other => return Err(format!("{other:?} is not a mail service this panel knows")), }; let secret = if !a.secret.is_empty() { enc(&a.secret) } else { match parts(current) { Some((_, u, p, h, _, _)) if !p.is_empty() && h == host && dec(u) == user => p.to_string(), _ if a.service == "smtp" && user.is_empty() => String::new(), _ => { return Err(match a.service.as_str() { "resend" => "Give the Resend API key (re_…).".into(), _ => "Give the password.".into(), }) } } }; let login = match (user.is_empty(), secret.is_empty()) { (true, _) => String::new(), (false, true) => format!("{}@", enc(&user)), (false, false) => format!("{}:{secret}@", enc(&user)), }; let query = if query.is_empty() { String::new() } else { format!("?{query}") }; Ok(format!("{scheme}://{login}{host}:{port}{query}")) } #[cfg(test)] mod panel_tests { use super::*; fn asked(service: &str) -> Asked { Asked { service: service.into(), ..Default::default() } } #[test] fn services_compose_their_smtp_urls_and_read_back() { let resend = compose(&Asked { secret: "re_test_123".into(), ..asked("resend") }, "").unwrap(); assert_eq!(resend, "smtps://resend:re_test_123@smtp.resend.com:465"); let c = describe(&resend); assert_eq!((c.service.as_str(), c.has_secret), ("resend", true)); // Left empty, the saved key stays. assert_eq!(compose(&asked("resend"), &resend).unwrap(), resend); assert!(compose(&asked("resend"), "").unwrap_err().contains("API key")); let ses = compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), secret: "p@ss/word".into(), ..asked("ses") }, "") .unwrap(); assert_eq!(ses, "smtps://AKIAEXAMPLE:p%40ss%2Fword@email-smtp.eu-west-2.amazonaws.com:465"); assert_eq!((describe(&ses).service.as_str(), describe(&ses).region.as_str()), ("ses", "eu-west-2")); // Another service's key does not carry over. assert!(compose(&Asked { region: "eu-west-2".into(), user: "AKIAEXAMPLE".into(), ..asked("ses") }, &resend).is_err()); let mg = compose(&Asked { region: "eu".into(), user: "postmaster@mg.example.org".into(), secret: "s".into(), ..asked("mailgun") }, "") .unwrap(); assert_eq!(mg, "smtps://postmaster%40mg.example.org:s@smtp.eu.mailgun.org:465"); assert_eq!(describe(&mg).user, "postmaster@mg.example.org"); let plain = compose(&Asked { host: "mail.example.org".into(), user: "hub".into(), secret: "x".into(), ..asked("smtp") }, "").unwrap(); assert_eq!(plain, "smtp://hub:x@mail.example.org:587?tls=required"); assert_eq!(describe(&plain).security, "starttls"); let relay = compose(&Asked { host: "localhost".into(), port: "25".into(), security: "none".into(), ..asked("smtp") }, "").unwrap(); assert_eq!(relay, "smtp://localhost:25?tls=none"); assert_eq!(describe("file:///tmp/mail").service, "url"); assert_eq!(describe("").service, "off"); } } #[cfg(test)] mod tests { use super::*; #[test] fn mail_goes_by_the_from_address_s_name() { assert_eq!(sender_name("OxygenHub ").as_deref(), Some("OxygenHub")); assert_eq!(sender_name("\"Oxygen Hub\" ").as_deref(), Some("Oxygen Hub")); assert_eq!(sender_name("noreply@oxygenhub.io"), None); assert_eq!(sender_name(" "), None); } }