use super::{Lang, layout}; /// Rendered email content: subject plus plain-text and HTML bodies. pub struct EmailContent { pub subject: String, pub text: String, pub html: String, } /// Escapes the five characters that matter in HTML text and attribute /// values. Today the only dynamic input is the link (a trusted-config base /// URL plus our own base64url token), but escaping stays mandatory so a /// future template edit cannot silently re-open an HTML-injection hole. pub fn escape_html(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(ch), } } out } /// The only link origin is `PUBLIC_BASE_URL` from config; request headers /// are never consulted (host header injection would forge phishing links). fn link(base_url: &str, path: &str, token: &str) -> String { format!( "{}{path}?token={}", base_url.trim().trim_end_matches('/'), token ) } fn site_url(base_url: &str) -> String { base_url.trim().trim_end_matches('/').to_owned() } fn render(subject: &str, card: &layout::Card) -> EmailContent { EmailContent { subject: subject.to_owned(), text: layout::text(card), html: layout::html(card), } } /// Password reset: one button, 30-minute token, generic wording (the mail /// itself must not reveal whether the address even has an account). pub fn reset_email(lang: Lang, base_url: &str, token: &str) -> EmailContent { let url = link(base_url, "/reset-password", token); let site = site_url(base_url); match lang { Lang::Ru => render( "Сброс пароля LoVisual", &layout::Card { lang: "ru", preheader: "Ссылка действует 30 минут. Если это были не вы, ничего делать не нужно.", badge: "Безопасность", title: "Сброс пароля", paragraphs: &[ "Мы получили запрос на сброс пароля для вашего аккаунта LoVisual.", "Нажмите кнопку ниже, чтобы придумать новый пароль.", ], button: "Сбросить пароль", url: &url, expiry: "Ссылка действует 30 минут и срабатывает один раз.", copy_hint: "Кнопка не работает? Скопируйте ссылку и вставьте её в адресную строку браузера:", ignore_note: "Если вы не запрашивали сброс, просто проигнорируйте письмо: пароль останется прежним.", footer: "Это автоматическое письмо от LoVisual, отвечать на него не нужно.", site_url: &site, }, ), Lang::En => render( "LoVisual password reset", &layout::Card { lang: "en", preheader: "The link is valid for 30 minutes. If it was not you, no action is needed.", badge: "Security", title: "Reset your password", paragraphs: &[ "We received a request to reset the password of your LoVisual account.", "Press the button below to choose a new password.", ], button: "Reset password", url: &url, expiry: "The link is valid for 30 minutes and works only once.", copy_hint: "Button not working? Copy the link and paste it into your browser's address bar:", ignore_note: "If you did not request a reset, just ignore this email: your password stays the same.", footer: "This is an automated email from LoVisual, no need to reply.", site_url: &site, }, ), } } /// Address verification: one button, 24-hour token. pub fn verify_email(lang: Lang, base_url: &str, token: &str) -> EmailContent { let url = link(base_url, "/verify", token); let site = site_url(base_url); match lang { Lang::Ru => render( "Подтверждение почты LoVisual", &layout::Card { lang: "ru", preheader: "Подтвердите почту, чтобы привязать мод к аккаунту. Ссылка действует 24 часа.", badge: "Добро пожаловать", title: "Подтвердите почту", paragraphs: &[ "Спасибо за регистрацию в LoVisual!", "Подтвердите адрес почты, чтобы привязать мод к аккаунту и публиковать свои конфиги.", ], button: "Подтвердить почту", url: &url, expiry: "Ссылка действует 24 часа и срабатывает один раз.", copy_hint: "Кнопка не работает? Скопируйте ссылку и вставьте её в адресную строку браузера:", ignore_note: "Если вы не регистрировались в LoVisual, просто проигнорируйте письмо.", footer: "Это автоматическое письмо от LoVisual, отвечать на него не нужно.", site_url: &site, }, ), Lang::En => render( "LoVisual email verification", &layout::Card { lang: "en", preheader: "Verify your email to link the mod to your account. The link is valid for 24 hours.", badge: "Welcome", title: "Verify your email", paragraphs: &[ "Thanks for signing up for LoVisual!", "Verify your email address to link the mod to your account and publish your configs.", ], button: "Verify email", url: &url, expiry: "The link is valid for 24 hours and works only once.", copy_hint: "Button not working? Copy the link and paste it into your browser's address bar:", ignore_note: "If you did not sign up for LoVisual, just ignore this email.", footer: "This is an automated email from LoVisual, no need to reply.", site_url: &site, }, ), } } #[cfg(test)] mod tests { use super::*; const BASE: &str = "https://visual.loki-code.dev/"; const TOKEN: &str = "lvev_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; #[test] fn links_are_built_from_config_base_without_double_slash() { let mail = verify_email(Lang::Ru, BASE, TOKEN); assert!( mail.html .contains("https://visual.loki-code.dev/verify?token=lvev_") ); assert!(!mail.html.contains("dev//verify")); } #[test] fn dynamic_content_is_html_escaped() { // A hostile base URL must not be able to break out of the attribute. let evil = "https://x.example\">"; let mail = verify_email(Lang::En, evil, TOKEN); assert!(!mail.html.contains("