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("