//! Outgoing email: the `Mailer` transports, the SMTP/no-op implementations //! and the shared, non-reversible log tag for addresses. //! //! Secrets discipline: no caller ever passes a plaintext token into a log //! line; templates are the only place user-visible mail text exists, and //! their dynamic input is escaped there. pub mod layout; pub mod templates; use std::future::Future; use anyhow::{Context, Result}; use lettre::{ AsyncSmtpTransport, AsyncTransport, Tokio1Executor, message::{Mailbox, Message, MultiPart}, transport::smtp::{ authentication::Credentials, client::{Tls, TlsParameters}, }, }; use sha2::{Digest, Sha256}; /// Language of the built-in email templates (`MAIL_LANG`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Lang { #[default] Ru, En, } impl Lang { pub fn parse(s: &str) -> Option { match s.trim().to_lowercase().as_str() { "ru" => Some(Lang::Ru), "en" => Some(Lang::En), _ => None, } } } /// A fully rendered email ready for any transport. Templates escape dynamic /// content, so a draft is safe to hand to a transport as-is. pub struct EmailDraft { pub to: String, pub subject: String, pub text: String, pub html: String, } /// Transport abstraction. One method keeps fakes trivial. Desugared to an /// explicit `Send` future (rather than AFIT) so the trait stays usable from /// `tokio::spawn`ed background tasks without hidden auto-trait surprises. pub trait Mailer { fn deliver(&self, draft: EmailDraft) -> impl Future> + Send; } /// Real SMTP via lettre. TLS only: 465 speaks implicit TLS, every other port /// uses required STARTTLS (no plaintext downgrade for password mail). rustls /// everywhere — no native-tls in the dependency tree. pub struct SmtpMailer { transport: AsyncSmtpTransport, from: Mailbox, public_base_url: String, } impl SmtpMailer { /// `from` is a full mailbox (`LoVisual ` or a bare /// address); `public_base_url` is the only link origin emails may carry. pub fn new( host: &str, port: u16, user: &str, password: &str, from: &str, public_base_url: &str, ) -> Result { let tls = TlsParameters::builder(host.to_owned()) .build() .context("building SMTP TLS parameters")?; let mut builder = AsyncSmtpTransport::::builder_dangerous(host.to_owned()) .port(port) .tls(if port == 465 { Tls::Wrapper(tls) } else { Tls::Required(tls) }); if !user.trim().is_empty() { builder = builder.credentials(Credentials::new(user.to_owned(), password.to_owned())); } Ok(SmtpMailer { transport: builder.build(), from: from.parse().context("MAIL_FROM is not a valid mailbox")?, public_base_url: public_base_url.trim().trim_end_matches('/').to_owned(), }) } /// Link origin for email buttons, taken from config only — never from /// request headers (host header injection would forge phishing links). pub fn public_base_url(&self) -> &str { &self.public_base_url } } impl Mailer for SmtpMailer { async fn deliver(&self, draft: EmailDraft) -> Result<()> { let mail = Message::builder() .from(self.from.clone()) .to(draft .to .parse() .context("recipient is not a valid mailbox")?) .subject(draft.subject) .multipart(MultiPart::alternative_plain_html(draft.text, draft.html))?; self.transport.send(mail).await?; Ok(()) } } /// Stand-in transport for dev runs that deliberately skip SMTP: reports /// success and logs the fact, so callers need no disabled branch. Only /// non-confidential metadata is logged. pub struct NoopMailer; impl Mailer for NoopMailer { async fn deliver(&self, draft: EmailDraft) -> Result<()> { tracing::info!(subject = %draft.subject, "no-op mailer: delivery suppressed (no SMTP configured)"); Ok(()) } } /// Short non-reversible log tag for an address: enough to correlate log /// lines for the same recipient across requests, useless for rebuilding the /// address or matching it against a candidate list. pub fn address_tag(email: &str) -> String { let digest = Sha256::digest(email.trim().to_lowercase().as_bytes()); digest[..4].iter().map(|b| format!("{b:02x}")).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn lang_parses_both_locales_and_nothing_else() { assert_eq!(Lang::parse("ru"), Some(Lang::Ru)); assert_eq!(Lang::parse("EN"), Some(Lang::En)); assert_eq!(Lang::parse("de"), None); assert_eq!(Lang::parse(""), None); } #[test] fn address_tag_is_short_stable_and_not_the_address() { let a = address_tag("User@Example.com "); let b = address_tag("user@example.com"); assert_eq!(a, b, "tag must normalize case and whitespace"); assert_eq!(a.len(), 8); assert!(!a.contains("user")); } #[test] fn address_tag_differs_per_address() { assert_ne!(address_tag("a@example.com"), address_tag("b@example.com")); } #[tokio::test] async fn noop_mailer_reports_success_without_sending() { NoopMailer .deliver(EmailDraft { to: "a@example.com".into(), subject: "s".into(), text: "t".into(), html: "

t

".into(), }) .await .expect("no-op delivery must succeed"); } }