From cc33b6e4fc945f17c632d1244aaaae2a80a1f271 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 9 Oct 2026 21:52:46 +0200 Subject: [PATCH] feat(mail): redesigned transactional emails (banner, bulletproof button, copyable link, preheader, ru/en) with tests --- backend/accounts-service/src/mail/layout.rs | 110 ++++++++++ backend/accounts-service/src/mail/mod.rs | 1 + .../accounts-service/src/mail/templates.rs | 196 ++++++++++++------ 3 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 backend/accounts-service/src/mail/layout.rs diff --git a/backend/accounts-service/src/mail/layout.rs b/backend/accounts-service/src/mail/layout.rs new file mode 100644 index 00000000..136112f4 --- /dev/null +++ b/backend/accounts-service/src/mail/layout.rs @@ -0,0 +1,110 @@ +use super::templates::escape_html; + +/// Everything one transactional email needs. All strings are static copy +/// except `url`, but every field still goes through `escape_html` so a future +/// edit cannot open an HTML-injection hole. +pub struct Card<'a> { + pub lang: &'a str, + pub preheader: &'a str, + /// Small pill above the title ("Security", "Welcome"). + pub badge: &'a str, + pub title: &'a str, + pub paragraphs: &'a [&'a str], + pub button: &'a str, + pub url: &'a str, + pub expiry: &'a str, + pub copy_hint: &'a str, + pub ignore_note: &'a str, + pub footer: &'a str, + pub site_url: &'a str, +} + +const BG: &str = "#070b0c"; +const CARD: &str = "#0d1416"; +const TEXT: &str = "#e7eef0"; +const MUTED: &str = "#8fa3a8"; +const ACCENT: &str = "#be185d"; +const ACCENT_SOFT: &str = "#f472b6"; +const BORDER: &str = "#1f2d31"; +const CODE_BG: &str = "#111a1d"; +const FONT: &str = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"; +const MONO: &str = "SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace"; + +/// Table-based, inline-styled HTML (the only layout every mail client +/// renders): 600 px card, gradient banner, bulletproof button, a separate +/// copyable link block, hidden preheader. The palette is dark on purpose, so +/// Gmail's and Outlook's automatic dark mode has nothing left to invert. +pub fn html(c: &Card) -> String { + let url = escape_html(c.url); + let paragraphs: String = c + .paragraphs + .iter() + .map(|p| { + format!( + "

{}

", + escape_html(p) + ) + }) + .collect(); + format!( + "\ +\ +\ +{title}\ +\ +
{pre} ͏ ͏ ͏
\ +
\ +\ +\ +\ +\ +\ +
\ +\ +\ +
\"LoVisual\"LoVisual
\ +
{badge}
\ +

{title}

\ +
{paragraphs}\ +\ +
{button}
\ +

{expiry}

\ +
\ +

{hint}

\ +
\ +{url}
\ +

{ignore}

\ +

{footer}
{site_host}

\ +
", + lang = escape_html(c.lang), + title = escape_html(c.title), + pre = escape_html(c.preheader), + badge = escape_html(c.badge), + button = escape_html(c.button), + expiry = escape_html(c.expiry), + hint = escape_html(c.copy_hint), + ignore = escape_html(c.ignore_note), + footer = escape_html(c.footer), + site = escape_html(c.site_url), + site_host = escape_html( + c.site_url + .trim_start_matches("https://") + .trim_start_matches("http://") + ), + ) +} + +/// Plain-text twin: same order as the HTML, with the link on its own line so +/// it stays one clean, copyable URL in clients that strip HTML. +pub fn text(c: &Card) -> String { + let mut out = format!("{}\n\n", c.title); + for p in c.paragraphs { + out.push_str(p); + out.push_str("\n\n"); + } + out.push_str(&format!( + "{}:\n{}\n\n{}\n\n{}\n\n--\n{}\n{}\n", + c.button, c.url, c.expiry, c.ignore_note, c.footer, c.site_url + )); + out +} diff --git a/backend/accounts-service/src/mail/mod.rs b/backend/accounts-service/src/mail/mod.rs index 6a0cd828..bfd35928 100644 --- a/backend/accounts-service/src/mail/mod.rs +++ b/backend/accounts-service/src/mail/mod.rs @@ -5,6 +5,7 @@ //! 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; diff --git a/backend/accounts-service/src/mail/templates.rs b/backend/accounts-service/src/mail/templates.rs index cca1793c..608e8d0c 100644 --- a/backend/accounts-service/src/mail/templates.rs +++ b/backend/accounts-service/src/mail/templates.rs @@ -1,4 +1,4 @@ -use super::Lang; +use super::{Lang, layout}; /// Rendered email content: subject plus plain-text and HTML bodies. pub struct EmailContent { @@ -36,54 +36,63 @@ fn link(base_url: &str, path: &str, token: &str) -> String { ) } -fn build(subject: &str, text: String, html: String) -> EmailContent { - EmailContent { - subject: subject.to_owned(), - text, - html, - } +fn site_url(base_url: &str) -> String { + base_url.trim().trim_end_matches('/').to_owned() } -/// Renders the HTML body with every dynamic value escaped: the link origin -/// comes from config, but the config is still data, not markup. -fn html_body(body_template: &str, url: &str) -> String { - body_template.replace("{URL}", &escape_html(url)) +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 => build( + Lang::Ru => render( "Сброс пароля LoVisual", - format!( - "Кто-то запросил сброс пароля LoVisual.\n\ - Если это были вы, открой ссылку (действует 30 минут):\n{url}\n\n\ - Если нет — просто проигнорируйте письмо, пароль не менялся." - ), - html_body( - "

Кто-то запросил сброс пароля LoVisual.

\ -

Если это были вы, нажмите кнопку (действует 30 минут):

\ -

Сбросить пароль

\ -

Если нет — просто проигнорируйте письмо, пароль не менялся.

", - &url, - ), + &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 => build( + Lang::En => render( "LoVisual password reset", - format!( - "Someone requested a password reset for LoVisual.\n\ - If it was you, open the link (valid for 30 minutes):\n{url}\n\n\ - If not, just ignore this email - the password was not changed." - ), - html_body( - "

Someone requested a password reset for LoVisual.

\ -

If it was you, press the button (valid for 30 minutes):

\ -

Reset password

\ -

If not, just ignore this email - the password was not changed.

", - &url, - ), + &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, + }, ), } } @@ -91,36 +100,47 @@ pub fn reset_email(lang: Lang, base_url: &str, token: &str) -> EmailContent { /// 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 => build( + Lang::Ru => render( "Подтверждение почты LoVisual", - format!( - "Добро пожаловать в LoVisual!\n\ - Подтвердите почту по ссылке (действует 24 часа):\n{url}\n\n\ - Без подтверждения нельзя привязать мод к аккаунту." - ), - html_body( - "

Добро пожаловать в LoVisual!

\ -

Подтвердите почту по ссылке (действует 24 часа):

\ -

Подтвердить почту

\ -

Без подтверждения нельзя привязать мод к аккаунту.

", - &url, - ), + &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 => build( + Lang::En => render( "LoVisual email verification", - format!( - "Welcome to LoVisual!\n\ - Verify your email via the link (valid for 24 hours):\n{url}\n\n\ - Device linking stays locked until the address is verified." - ), - html_body( - "

Welcome to LoVisual!

\ -

Verify your email via the link (valid for 24 hours):

\ -

Verify email

\ -

Device linking stays locked until the address is verified.

", - &url, - ), + &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, + }, ), } } @@ -167,4 +187,54 @@ mod tests { assert!(reset.html.contains("/reset-password?token=")); assert!(verify.html.contains("/verify?token=")); } + + #[test] + fn link_is_shown_as_button_and_as_separate_copyable_text() { + let mail = reset_email(Lang::Ru, BASE, TOKEN); + let url = format!("https://visual.loki-code.dev/reset-password?token={TOKEN}"); + assert_eq!( + mail.html.matches(url.as_str()).count(), + 3, + "2x href + 1x visible text" + ); + assert!( + mail.text.lines().any(|l| l == url), + "plain text keeps the URL alone on a line" + ); + } + + #[test] + fn copy_has_no_em_dash_and_ships_a_preheader() { + for lang in [Lang::Ru, Lang::En] { + for mail in [ + reset_email(lang, BASE, TOKEN), + verify_email(lang, BASE, TOKEN), + ] { + assert!(!mail.html.contains('\u{2014}') && !mail.text.contains('\u{2014}')); + assert!(mail.html.contains("display:none;max-height:0")); + } + } + } + + /// `MAIL_PREVIEW_DIR=/tmp/mail cargo test mail_previews` writes the four + /// emails as .html files for eyeballing; a no-op otherwise. + #[test] + fn mail_previews() { + let Some(dir) = std::env::var_os("MAIL_PREVIEW_DIR") else { + return; + }; + std::fs::create_dir_all(&dir).unwrap(); + for (name, mail) in [ + ("reset-ru", reset_email(Lang::Ru, BASE, TOKEN)), + ("reset-en", reset_email(Lang::En, BASE, TOKEN)), + ("verify-ru", verify_email(Lang::Ru, BASE, TOKEN)), + ("verify-en", verify_email(Lang::En, BASE, TOKEN)), + ] { + std::fs::write( + std::path::Path::new(&dir).join(format!("{name}.html")), + mail.html, + ) + .unwrap(); + } + } }