feat(mail): redesigned transactional emails (banner, bulletproof button, copyable link, preheader, ru/en) with tests
This commit is contained in:
parent
c57f851a8b
commit
cc33b6e4fc
3 changed files with 244 additions and 63 deletions
110
backend/accounts-service/src/mail/layout.rs
Normal file
110
backend/accounts-service/src/mail/layout.rs
Normal file
|
|
@ -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!(
|
||||||
|
"<p style=\"margin:0 0 16px;font-size:16px;line-height:26px;color:{TEXT};\">{}</p>",
|
||||||
|
escape_html(p)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"<!DOCTYPE html><html lang=\"{lang}\"><head><meta charset=\"utf-8\">\
|
||||||
|
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
||||||
|
<meta name=\"color-scheme\" content=\"dark light\"><meta name=\"supported-color-schemes\" content=\"dark light\">\
|
||||||
|
<title>{title}</title></head>\
|
||||||
|
<body style=\"margin:0;padding:0;background:{BG};\" bgcolor=\"{BG}\">\
|
||||||
|
<div style=\"display:none;max-height:0;overflow:hidden;opacity:0;color:{BG};\">{pre} ͏ ͏ ͏</div>\
|
||||||
|
<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"{BG}\" style=\"background:{BG};\"><tr><td align=\"center\" style=\"padding:32px 12px;\">\
|
||||||
|
<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:100%;max-width:600px;background:{CARD};border:1px solid {BORDER};border-radius:18px;overflow:hidden;font-family:{FONT};\" bgcolor=\"{CARD}\">\
|
||||||
|
<tr><td bgcolor=\"{ACCENT}\" style=\"background:{ACCENT};background-image:linear-gradient(135deg,{ACCENT} 0%,#7c1d6f 55%,#312e81 100%);padding:28px 32px 30px;\">\
|
||||||
|
<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr>\
|
||||||
|
<td style=\"padding-right:12px;\"><img src=\"{site}/favicon.png\" width=\"40\" height=\"40\" alt=\"LoVisual\" style=\"display:block;border:0;border-radius:10px;\"></td>\
|
||||||
|
<td style=\"font-size:22px;font-weight:800;letter-spacing:0.5px;color:#ffffff;\">LoVisual</td></tr></table>\
|
||||||
|
<div style=\"margin:26px 0 12px;\"><span style=\"display:inline-block;padding:4px 12px;border-radius:999px;background:rgba(255,255,255,0.18);font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:#ffffff;\">{badge}</span></div>\
|
||||||
|
<h1 style=\"margin:0;font-size:28px;line-height:34px;font-weight:800;color:#ffffff;\">{title}</h1>\
|
||||||
|
</td></tr>\
|
||||||
|
<tr><td style=\"padding:32px 32px 8px;\">{paragraphs}\
|
||||||
|
<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"margin:8px 0 18px;\"><tr>\
|
||||||
|
<td bgcolor=\"{ACCENT}\" style=\"border-radius:12px;background:{ACCENT};\"><a href=\"{url}\" target=\"_blank\" style=\"display:inline-block;padding:15px 36px;font-size:16px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:12px;\">{button}</a></td></tr></table>\
|
||||||
|
<p style=\"margin:0 0 24px;font-size:14px;line-height:22px;color:{MUTED};\">{expiry}</p>\
|
||||||
|
</td></tr>\
|
||||||
|
<tr><td style=\"padding:0 32px 8px;\">\
|
||||||
|
<p style=\"margin:0 0 8px;font-size:13px;line-height:20px;color:{MUTED};\">{hint}</p>\
|
||||||
|
<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr><td bgcolor=\"{CODE_BG}\" style=\"background:{CODE_BG};border:1px solid {BORDER};border-radius:10px;padding:12px 14px;font-family:{MONO};font-size:13px;line-height:20px;word-break:break-all;\">\
|
||||||
|
<a href=\"{url}\" target=\"_blank\" style=\"color:{ACCENT_SOFT};text-decoration:none;\">{url}</a></td></tr></table>\
|
||||||
|
</td></tr>\
|
||||||
|
<tr><td style=\"padding:24px 32px 32px;\"><p style=\"margin:0;font-size:13px;line-height:21px;color:{MUTED};\">{ignore}</p></td></tr>\
|
||||||
|
<tr><td bgcolor=\"#0a1011\" style=\"background:#0a1011;border-top:1px solid {BORDER};padding:20px 32px;\">\
|
||||||
|
<p style=\"margin:0;font-size:12px;line-height:19px;color:{MUTED};\">{footer}<br><a href=\"{site}\" target=\"_blank\" style=\"color:{ACCENT_SOFT};text-decoration:none;\">{site_host}</a></p>\
|
||||||
|
</td></tr></table></td></tr></table></body></html>",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! line; templates are the only place user-visible mail text exists, and
|
//! line; templates are the only place user-visible mail text exists, and
|
||||||
//! their dynamic input is escaped there.
|
//! their dynamic input is escaped there.
|
||||||
|
|
||||||
|
pub mod layout;
|
||||||
pub mod templates;
|
pub mod templates;
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::Lang;
|
use super::{Lang, layout};
|
||||||
|
|
||||||
/// Rendered email content: subject plus plain-text and HTML bodies.
|
/// Rendered email content: subject plus plain-text and HTML bodies.
|
||||||
pub struct EmailContent {
|
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 {
|
fn site_url(base_url: &str) -> String {
|
||||||
EmailContent {
|
base_url.trim().trim_end_matches('/').to_owned()
|
||||||
subject: subject.to_owned(),
|
|
||||||
text,
|
|
||||||
html,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the HTML body with every dynamic value escaped: the link origin
|
fn render(subject: &str, card: &layout::Card) -> EmailContent {
|
||||||
/// comes from config, but the config is still data, not markup.
|
EmailContent {
|
||||||
fn html_body(body_template: &str, url: &str) -> String {
|
subject: subject.to_owned(),
|
||||||
body_template.replace("{URL}", &escape_html(url))
|
text: layout::text(card),
|
||||||
|
html: layout::html(card),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Password reset: one button, 30-minute token, generic wording (the mail
|
/// Password reset: one button, 30-minute token, generic wording (the mail
|
||||||
/// itself must not reveal whether the address even has an account).
|
/// itself must not reveal whether the address even has an account).
|
||||||
pub fn reset_email(lang: Lang, base_url: &str, token: &str) -> EmailContent {
|
pub fn reset_email(lang: Lang, base_url: &str, token: &str) -> EmailContent {
|
||||||
let url = link(base_url, "/reset-password", token);
|
let url = link(base_url, "/reset-password", token);
|
||||||
|
let site = site_url(base_url);
|
||||||
match lang {
|
match lang {
|
||||||
Lang::Ru => build(
|
Lang::Ru => render(
|
||||||
"Сброс пароля LoVisual",
|
"Сброс пароля LoVisual",
|
||||||
format!(
|
&layout::Card {
|
||||||
"Кто-то запросил сброс пароля LoVisual.\n\
|
lang: "ru",
|
||||||
Если это были вы, открой ссылку (действует 30 минут):\n{url}\n\n\
|
preheader: "Ссылка действует 30 минут. Если это были не вы, ничего делать не нужно.",
|
||||||
Если нет — просто проигнорируйте письмо, пароль не менялся."
|
badge: "Безопасность",
|
||||||
),
|
title: "Сброс пароля",
|
||||||
html_body(
|
paragraphs: &[
|
||||||
"<p>Кто-то запросил сброс пароля LoVisual.</p>\
|
"Мы получили запрос на сброс пароля для вашего аккаунта LoVisual.",
|
||||||
<p>Если это были вы, нажмите кнопку (действует 30 минут):</p>\
|
"Нажмите кнопку ниже, чтобы придумать новый пароль.",
|
||||||
<p><a href=\"{URL}\">Сбросить пароль</a></p>\
|
],
|
||||||
<p>Если нет — просто проигнорируйте письмо, пароль не менялся.</p>",
|
button: "Сбросить пароль",
|
||||||
&url,
|
url: &url,
|
||||||
),
|
expiry: "Ссылка действует 30 минут и срабатывает один раз.",
|
||||||
|
copy_hint: "Кнопка не работает? Скопируйте ссылку и вставьте её в адресную строку браузера:",
|
||||||
|
ignore_note: "Если вы не запрашивали сброс, просто проигнорируйте письмо: пароль останется прежним.",
|
||||||
|
footer: "Это автоматическое письмо от LoVisual, отвечать на него не нужно.",
|
||||||
|
site_url: &site,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Lang::En => build(
|
Lang::En => render(
|
||||||
"LoVisual password reset",
|
"LoVisual password reset",
|
||||||
format!(
|
&layout::Card {
|
||||||
"Someone requested a password reset for LoVisual.\n\
|
lang: "en",
|
||||||
If it was you, open the link (valid for 30 minutes):\n{url}\n\n\
|
preheader: "The link is valid for 30 minutes. If it was not you, no action is needed.",
|
||||||
If not, just ignore this email - the password was not changed."
|
badge: "Security",
|
||||||
),
|
title: "Reset your password",
|
||||||
html_body(
|
paragraphs: &[
|
||||||
"<p>Someone requested a password reset for LoVisual.</p>\
|
"We received a request to reset the password of your LoVisual account.",
|
||||||
<p>If it was you, press the button (valid for 30 minutes):</p>\
|
"Press the button below to choose a new password.",
|
||||||
<p><a href=\"{URL}\">Reset password</a></p>\
|
],
|
||||||
<p>If not, just ignore this email - the password was not changed.</p>",
|
button: "Reset password",
|
||||||
&url,
|
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.
|
/// Address verification: one button, 24-hour token.
|
||||||
pub fn verify_email(lang: Lang, base_url: &str, token: &str) -> EmailContent {
|
pub fn verify_email(lang: Lang, base_url: &str, token: &str) -> EmailContent {
|
||||||
let url = link(base_url, "/verify", token);
|
let url = link(base_url, "/verify", token);
|
||||||
|
let site = site_url(base_url);
|
||||||
match lang {
|
match lang {
|
||||||
Lang::Ru => build(
|
Lang::Ru => render(
|
||||||
"Подтверждение почты LoVisual",
|
"Подтверждение почты LoVisual",
|
||||||
format!(
|
&layout::Card {
|
||||||
"Добро пожаловать в LoVisual!\n\
|
lang: "ru",
|
||||||
Подтвердите почту по ссылке (действует 24 часа):\n{url}\n\n\
|
preheader: "Подтвердите почту, чтобы привязать мод к аккаунту. Ссылка действует 24 часа.",
|
||||||
Без подтверждения нельзя привязать мод к аккаунту."
|
badge: "Добро пожаловать",
|
||||||
),
|
title: "Подтвердите почту",
|
||||||
html_body(
|
paragraphs: &[
|
||||||
"<p>Добро пожаловать в LoVisual!</p>\
|
"Спасибо за регистрацию в LoVisual!",
|
||||||
<p>Подтвердите почту по ссылке (действует 24 часа):</p>\
|
"Подтвердите адрес почты, чтобы привязать мод к аккаунту и публиковать свои конфиги.",
|
||||||
<p><a href=\"{URL}\">Подтвердить почту</a></p>\
|
],
|
||||||
<p>Без подтверждения нельзя привязать мод к аккаунту.</p>",
|
button: "Подтвердить почту",
|
||||||
&url,
|
url: &url,
|
||||||
),
|
expiry: "Ссылка действует 24 часа и срабатывает один раз.",
|
||||||
|
copy_hint: "Кнопка не работает? Скопируйте ссылку и вставьте её в адресную строку браузера:",
|
||||||
|
ignore_note: "Если вы не регистрировались в LoVisual, просто проигнорируйте письмо.",
|
||||||
|
footer: "Это автоматическое письмо от LoVisual, отвечать на него не нужно.",
|
||||||
|
site_url: &site,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Lang::En => build(
|
Lang::En => render(
|
||||||
"LoVisual email verification",
|
"LoVisual email verification",
|
||||||
format!(
|
&layout::Card {
|
||||||
"Welcome to LoVisual!\n\
|
lang: "en",
|
||||||
Verify your email via the link (valid for 24 hours):\n{url}\n\n\
|
preheader: "Verify your email to link the mod to your account. The link is valid for 24 hours.",
|
||||||
Device linking stays locked until the address is verified."
|
badge: "Welcome",
|
||||||
),
|
title: "Verify your email",
|
||||||
html_body(
|
paragraphs: &[
|
||||||
"<p>Welcome to LoVisual!</p>\
|
"Thanks for signing up for LoVisual!",
|
||||||
<p>Verify your email via the link (valid for 24 hours):</p>\
|
"Verify your email address to link the mod to your account and publish your configs.",
|
||||||
<p><a href=\"{URL}\">Verify email</a></p>\
|
],
|
||||||
<p>Device linking stays locked until the address is verified.</p>",
|
button: "Verify email",
|
||||||
&url,
|
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!(reset.html.contains("/reset-password?token="));
|
||||||
assert!(verify.html.contains("/verify?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue