//! Emails the admin sends to players: reusable templates with placeholders, a branded HTML layout generated from plain text, //! audiences (everyone, a group, chosen players), opt-out links, and a log of what went out. use crate::auth::AdminUser; use crate::embeds::{fill, plain, Vars}; use crate::error::{AppError, AppResult}; use crate::routes::connections; use crate::state::AppState; use crate::store; use axum::extract::{Query, State}; use axum::response::Html; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::time::Duration; const MAX_RECIPIENTS: usize = 2000; #[derive(Clone, Serialize, Deserialize, Default, PartialEq, Debug)] #[serde(default)] pub struct EmailTemplate { pub id: String, pub name: String, pub subject: String, pub body: String, } fn defaults() -> Vec { vec![ EmailTemplate { id: "welcome".into(), name: "Welcome".into(), subject: "Welcome to {brand}, {player}!".into(), body: "# Welcome, {player}!\n\nYour account on **{brand}** is ready. Download the launcher, sign in and join the server.\n\n[button: Open {brand}]({panel_url})\n\nSee you in game!".into(), }, EmailTemplate { id: "news".into(), name: "News & updates".into(), subject: "News from {brand}".into(), body: "Hi {player},\n\nHere is what is new on {brand}:\n\n- First thing\n- Second thing\n\n[button: Read more]({panel_url})".into(), }, EmailTemplate { id: "event".into(), name: "Event announcement".into(), subject: "{brand} event: don't miss it".into(), body: "# A new event is starting\n\nHi {player}, you are level **{level}**{rank_title_suffix}. Come and join in!\n\n[button: Join now]({panel_url})".into(), }, ] } pub async fn templates(state: &AppState) -> AppResult> { let stored: Vec = store::kv_get(state, "email_templates").await?; Ok(if stored.is_empty() { defaults() } else { stored }) } pub const PLACEHOLDERS: [&str; 11] = ["player", "uuid", "email", "level", "rank_title", "rank_title_suffix", "guild", "guild_tag", "brand", "panel_url", "unsubscribe_url"]; // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } fn safe_url(u: &str) -> Option { let u = u.trim(); (u.starts_with("https://") || u.starts_with("http://")).then(|| esc(u)) } /// `**bold**`, `*italic*`, `[text](url)` and `[button: text](url)` inside one already-escaped line. fn inline(line: &str, accent: &str) -> String { let mut out = String::new(); let mut rest = line.to_string(); // links and buttons first, from the raw (escaped) text while let Some(open) = rest.find('[') { let Some(mid) = rest[open..].find("](").map(|i| open + i) else { break }; let Some(close) = rest[mid..].find(')').map(|i| mid + i) else { break }; let label = &rest[open + 1..mid]; let url = rest[mid + 2..close].replace("&", "&"); out.push_str(&emphasis(&rest[..open])); match (safe_url(&url), label.strip_prefix("button:")) { (Some(u), Some(text)) => out.push_str(&format!( "{}", text.trim() )), (Some(u), None) => out.push_str(&format!("{label}")), _ => out.push_str(label), } rest = rest[close + 1..].to_string(); } out.push_str(&emphasis(&rest)); out } fn emphasis(s: &str) -> String { let mut out = String::new(); let mut bold = false; for (i, part) in s.split("**").enumerate() { if i > 0 { out.push_str(if bold { "" } else { "" }); bold = !bold; } out.push_str(part); } if bold { out.push_str(""); } out } /// Plain text to the branded HTML layout. Everything the admin or a player typed is escaped first. pub fn html(body: &str, brand: &str, accent: &str, footer: &str) -> String { let accent = if accent.len() == 7 && accent.starts_with('#') && accent[1..].chars().all(|c| c.is_ascii_hexdigit()) { accent } else { "#8b6cff" }; let mut content = String::new(); let mut list = false; for raw in body.lines() { let line = esc(raw.trim_end()); let bullet = line.trim_start().strip_prefix("- ").map(str::to_string); if let Some(item) = &bullet { if !list { content.push_str("
    "); list = true; } content.push_str(&format!("
  • {}
  • ", inline(item, accent))); continue; } if list { content.push_str("
"); list = false; } if line.trim().is_empty() { continue; } if let Some(h) = line.strip_prefix("# ") { content.push_str(&format!("

{}

", inline(h, accent))); } else if let Some(h) = line.strip_prefix("## ") { content.push_str(&format!("

{}

", inline(h, accent))); } else { content.push_str(&format!("

{}

", inline(&line, accent))); } } if list { content.push_str(""); } format!( "
\ \ \ \ \ \
{brand}
{content}
{footer}
", brand = esc(brand), ) } fn unsub_secret_key() -> &'static str { "email_unsubscribe_secret" } async fn unsub_secret(state: &AppState) -> AppResult { let mut s: String = store::kv_get(state, unsub_secret_key()).await?; if s.is_empty() { s = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); store::kv_set(state, unsub_secret_key(), &s).await?; } Ok(s) } async fn unsub_token(state: &AppState, uuid: &str) -> AppResult { let secret = unsub_secret(state).await?; let digest = Sha256::digest(format!("{secret}:{uuid}").as_bytes()); Ok(digest.iter().take(16).map(|b| format!("{b:02x}")).collect()) } struct Person { uuid: String, username: String, email: String, } async fn vars_for(state: &AppState, p: &Person) -> AppResult { let brand = store::branding(state).await?.name; let panel = state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string(); let mut v = Vars::new(); let level: Option<(i64, Option)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?; let (lvl, title) = level.unwrap_or((1, None)); let title = plain(&title.unwrap_or_default()); let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?; let (gn, gt) = guild.unwrap_or_default(); for (k, val) in [ ("player", plain(&p.username)), ("uuid", p.uuid.clone()), ("email", p.email.clone()), ("level", lvl.to_string()), ("rank_title_suffix", if title.is_empty() { String::new() } else { format!(" ({title})") }), ("rank_title", title), ("guild", plain(&gn)), ("guild_tag", plain(>)), ("brand", brand), ("panel_url", panel.clone()), ("unsubscribe_url", format!("{panel}/api/v1/email/unsubscribe?u={}&t={}", p.uuid, unsub_token(state, &p.uuid).await?)), ] { v.insert(k.into(), val); } Ok(v) } struct Rendered { subject: String, text: String, html: String, } async fn build(state: &AppState, subject: &str, body: &str, important: bool, p: &Person) -> AppResult { let vars = vars_for(state, p).await?; let subject = fill(subject, &vars).replace(['\r', '\n'], " "); let text = fill(body, &vars); let brand = vars["brand"].clone(); let accent = store::branding(state).await?.colors.accent; let footer_text = if important { format!("This message is about your {brand} account.") } else { format!("You are receiving this because you have an account on {brand}. Unsubscribe", esc(&vars["unsubscribe_url"])) }; let mut plain_text = text.clone(); if !important { plain_text.push_str(&format!("\n\n--\nUnsubscribe: {}", vars["unsubscribe_url"])); } Ok(Rendered { subject, text: plain_text, html: html(&text, &brand, &accent, &footer_text) }) } // --------------------------------------------------------------------------- // Audiences // --------------------------------------------------------------------------- #[derive(Deserialize, Clone, Default)] #[serde(default)] pub struct Audience { /// `all`, `group` or `players` kind: String, group_id: i64, /// Player names, for `players` names: Vec, } async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResult> { let optout = if important { "" } else { " AND u.email_optout = 0" }; let base = format!("SELECT u.uuid, u.username, u.email FROM users u WHERE u.status = 'active' AND u.email IS NOT NULL AND u.email <> ''{optout}"); let rows: Vec<(String, String, String)> = match a.kind.as_str() { "all" => sqlx::query_as(&format!("{base} ORDER BY u.id")).fetch_all(&state.db).await?, "group" => sqlx::query_as(&format!("{base} AND u.id IN (SELECT user_id FROM user_groups WHERE group_id = ?) ORDER BY u.id")).bind(a.group_id).fetch_all(&state.db).await?, "players" => { let mut out = Vec::new(); for n in a.names.iter().take(500) { let r: Option<(String, String, String)> = sqlx::query_as(&format!("{base} AND u.username = ? COLLATE NOCASE")).bind(n.trim()).fetch_optional(&state.db).await?; out.extend(r); } out } _ => return Err(AppError::bad_request("choose who should get the email")), }; Ok(rows.into_iter().take(MAX_RECIPIENTS).map(|(uuid, username, email)| Person { uuid, username, email }).collect()) } // --------------------------------------------------------------------------- // Admin endpoints // --------------------------------------------------------------------------- pub async fn get_all(_: AdminUser, State(state): State) -> AppResult> { let conn = connections::settings(&state).await?; let log: Vec<(i64, String, String, i64, i64, i64, Option, String, Option)> = sqlx::query_as("SELECT id, subject, audience, total, sent, failed, last_error, started_at, finished_at FROM email_log ORDER BY id DESC LIMIT 20").fetch_all(&state.db).await?; let groups: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM groups ORDER BY name").fetch_all(&state.db).await?; Ok(Json(json!({ "configured": !conn.resend_api_key.is_empty() && !conn.sender_email.is_empty(), "templates": templates(&state).await?, "placeholders": PLACEHOLDERS, "groups": groups.into_iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), "log": log.into_iter().map(|(id, subject, audience, total, sent, failed, error, started, finished)| json!({ "id": id, "subject": subject, "audience": audience, "total": total, "sent": sent, "failed": failed, "last_error": error, "started_at": started, "finished_at": finished })).collect::>(), }))) } #[derive(Deserialize)] pub struct TemplatesBody { templates: Vec, } pub async fn put_templates(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { if b.templates.len() > 50 { return Err(AppError::bad_request("at most 50 email templates")); } let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for mut t in b.templates { t.id = t.id.trim().to_string(); if t.id.is_empty() || !t.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || !seen.insert(t.id.clone()) { return Err(AppError::bad_request("each template needs its own short id (letters, numbers, - and _)")); } if t.subject.chars().count() > 200 || t.body.chars().count() > 20_000 || t.name.chars().count() > 80 { return Err(AppError::bad_request("a template is too long")); } out.push(t); } store::kv_set(&state, "email_templates", &out).await?; Ok(Json(json!({ "ok": true }))) } #[derive(Deserialize)] pub struct PreviewBody { subject: String, body: String, #[serde(default)] important: bool, } async fn sample_person(state: &AppState, admin: &crate::auth::UserRow) -> Person { let _ = state; Person { uuid: admin.uuid.clone(), username: admin.username.clone(), email: admin.email.clone().unwrap_or_default() } } pub async fn preview(admin: AdminUser, State(state): State, Json(b): Json) -> AppResult> { let who = sample_person(&state, &admin.0).await; let r = build(&state, &b.subject, &b.body, b.important, &who).await?; Ok(Json(json!({ "subject": r.subject, "text": r.text, "html": r.html }))) } #[derive(Deserialize)] pub struct AudienceBody { audience: Audience, #[serde(default)] important: bool, } pub async fn audience(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { let list = recipients(&state, &b.audience, b.important).await?; Ok(Json(json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::>(), "limit": MAX_RECIPIENTS }))) } #[derive(Deserialize)] pub struct SendBody { subject: String, body: String, #[serde(default)] important: bool, #[serde(default)] audience: Audience, /// Send one test copy to this address instead of to players. #[serde(default)] test_to: String, } pub async fn send(admin: AdminUser, State(state): State, Json(b): Json) -> AppResult> { if b.subject.trim().is_empty() || b.body.trim().is_empty() { return Err(AppError::bad_request("write a subject and a message first")); } if !b.test_to.trim().is_empty() { let me = sample_person(&state, &admin.0).await; let r = build(&state, &format!("[Test] {}", b.subject), &b.body, b.important, &me).await?; connections::send_mail(&state, b.test_to.trim(), &r.subject, &r.text, Some(&r.html)).await?; return Ok(Json(json!({ "ok": true, "test": true }))); } let people = recipients(&state, &b.audience, b.important).await?; if people.is_empty() { return Err(AppError::bad_request("nobody to send to: no matching players have an email address (or they opted out)")); } let label = match b.audience.kind.as_str() { "all" => "Everyone".to_string(), "group" => sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE id = ?").bind(b.audience.group_id).fetch_optional(&state.db).await?.map(|n| format!("Group: {n}")).unwrap_or_else(|| "A group".into()), _ => format!("{} chosen players", people.len()), }; let log_id: i64 = sqlx::query_scalar("INSERT INTO email_log (subject, audience, total, started_at) VALUES (?, ?, ?, ?) RETURNING id") .bind(b.subject.chars().take(200).collect::()).bind(label).bind(people.len() as i64).bind(crate::db::now()).fetch_one(&state.db).await?; let total = people.len(); let bg = state.clone(); tokio::spawn(async move { for p in people { let outcome = match build(&bg, &b.subject, &b.body, b.important, &p).await { Ok(r) => connections::send_mail(&bg, &p.email, &r.subject, &r.text, Some(&r.html)).await, Err(e) => Err(e), }; match outcome { Ok(()) => { let _ = sqlx::query("UPDATE email_log SET sent = sent + 1 WHERE id = ?").bind(log_id).execute(&bg.db).await; } Err(e) => { let _ = sqlx::query("UPDATE email_log SET failed = failed + 1, last_error = ? WHERE id = ?").bind(e.message.chars().take(200).collect::()).bind(log_id).execute(&bg.db).await; } } tokio::time::sleep(Duration::from_millis(150)).await; } let _ = sqlx::query("UPDATE email_log SET finished_at = ? WHERE id = ?").bind(crate::db::now()).bind(log_id).execute(&bg.db).await; }); Ok(Json(json!({ "ok": true, "queued": total, "log_id": log_id }))) } // --------------------------------------------------------------------------- // Public: unsubscribe // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct UnsubQuery { u: String, t: String, } pub async fn unsubscribe(State(state): State, Query(q): Query) -> Html { let page = |msg: &str| Html(format!("

{}

", esc(msg))); let ok = matches!(unsub_token(&state, &q.u).await, Ok(t) if t == q.t); if !ok { return page("That link isn't valid."); } let _ = sqlx::query("UPDATE users SET email_optout = 1 WHERE uuid = ?").bind(&q.u).execute(&state.db).await; page("You are unsubscribed. You will still get emails about your account.") } #[cfg(test)] mod tests { use super::*; #[test] fn text_becomes_safe_branded_html() { let h = html("# Hi \n\nHello **world** [site](https://x.example/a?b=1&c=2) [button: Go](https://x.example)\n\n- one\n- two\n\n[bad](javascript:alert(1))", "My ", "#112233", "foot"); assert!(h.contains("world")); assert!(h.contains("href=\"https://x.example/a?b=1&c=2\"")); assert!(h.contains("background:#112233;color:#ffffff"), "buttons use the brand colour"); assert!(h.contains("
  • one
  • ")); assert!(!h.contains("javascript:"), "only web links survive"); assert!(h.contains("My <Server>")); } }