LoVisual/backend/accounts-service/src/mail/mod.rs
loki5512344 cc33b6e4fc
Some checks failed
CI / backend (cargo test + clippy) (push) Failing after 1s
CI / frontend (lint + test + build) (push) Failing after 1m25s
CI / mod (gradle test + checkFolderLimit) (push) Successful in 5m7s
feat(mail): redesigned transactional emails (banner, bulletproof button, copyable link, preheader, ru/en) with tests
2026-10-09 21:52:46 +02:00

178 lines
5.7 KiB
Rust

//! 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<Lang> {
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<Output = Result<()>> + 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<Tokio1Executor>,
from: Mailbox,
public_base_url: String,
}
impl SmtpMailer {
/// `from` is a full mailbox (`LoVisual <noreply@loki-code.dev>` 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<Self> {
let tls = TlsParameters::builder(host.to_owned())
.build()
.context("building SMTP TLS parameters")?;
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::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: "<p>t</p>".into(),
})
.await
.expect("no-op delivery must succeed");
}
}