425 lines
19 KiB
Rust
425 lines
19 KiB
Rust
//! 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<EmailTemplate> {
|
|
vec\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<Vec<EmailTemplate>> {
|
|
let stored: Vec<EmailTemplate> = 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<String> {
|
|
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!(
|
|
"<a href=\"{u}\" style=\"display:inline-block;padding:12px 24px;border-radius:10px;background:{accent};color:#ffffff;font-weight:700;text-decoration:none\">{}</a>",
|
|
text.trim()
|
|
)),
|
|
(Some(u), None) => out.push_str(&format!("<a href=\"{u}\" style=\"color:{accent}\">{label}</a>")),
|
|
_ => 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 { "</strong>" } else { "<strong>" });
|
|
bold = !bold;
|
|
}
|
|
out.push_str(part);
|
|
}
|
|
if bold {
|
|
out.push_str("</strong>");
|
|
}
|
|
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("<ul style=\"margin:0 0 16px;padding-left:22px\">");
|
|
list = true;
|
|
}
|
|
content.push_str(&format!("<li style=\"margin:4px 0\">{}</li>", inline(item, accent)));
|
|
continue;
|
|
}
|
|
if list {
|
|
content.push_str("</ul>");
|
|
list = false;
|
|
}
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
if let Some(h) = line.strip_prefix("# ") {
|
|
content.push_str(&format!("<h1 style=\"margin:0 0 14px;font-size:24px;line-height:1.25\">{}</h1>", inline(h, accent)));
|
|
} else if let Some(h) = line.strip_prefix("## ") {
|
|
content.push_str(&format!("<h2 style=\"margin:18px 0 10px;font-size:18px\">{}</h2>", inline(h, accent)));
|
|
} else {
|
|
content.push_str(&format!("<p style=\"margin:0 0 16px;line-height:1.6\">{}</p>", inline(&line, accent)));
|
|
}
|
|
}
|
|
if list {
|
|
content.push_str("</ul>");
|
|
}
|
|
format!(
|
|
"<!doctype html><html><body style=\"margin:0;padding:0;background:#0f1220\"><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#0f1220;padding:28px 12px\"><tr><td align=\"center\">\
|
|
<table role=\"presentation\" width=\"560\" cellpadding=\"0\" cellspacing=\"0\" style=\"max-width:560px;width:100%;background:#171a2b;border-radius:16px;overflow:hidden;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#e9ecff\">\
|
|
<tr><td style=\"height:6px;background:{accent}\"></td></tr>\
|
|
<tr><td style=\"padding:26px 30px 6px;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:{accent}\">{brand}</td></tr>\
|
|
<tr><td style=\"padding:6px 30px 26px;font-size:15px\">{content}</td></tr>\
|
|
<tr><td style=\"padding:18px 30px 24px;border-top:1px solid #262a40;font-size:12px;color:#8d97b8\">{footer}</td></tr>\
|
|
</table></td></tr></table></body></html>",
|
|
brand = esc(brand),
|
|
)
|
|
}
|
|
|
|
fn unsub_secret_key() -> &'static str {
|
|
"email_unsubscribe_secret"
|
|
}
|
|
|
|
async fn unsub_secret(state: &AppState) -> AppResult<String> {
|
|
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<String> {
|
|
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<Vars> {
|
|
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<String>)> = 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<Rendered> {
|
|
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}. <a href=\"{}\" style=\"color:#8d97b8\">Unsubscribe</a>", 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<String>,
|
|
}
|
|
|
|
async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResult<Vec<Person>> {
|
|
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<AppState>) -> AppResult<Json<Value>> {
|
|
let conn = connections::settings(&state).await?;
|
|
let log: Vec<(i64, String, String, i64, i64, i64, Option<String>, String, Option<String>)> =
|
|
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::<Vec<_>>(),
|
|
"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::<Vec<_>>(),
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct TemplatesBody {
|
|
templates: Vec<EmailTemplate>,
|
|
}
|
|
|
|
pub async fn put_templates(_: AdminUser, State(state): State<AppState>, Json(b): Json<TemplatesBody>) -> AppResult<Json<Value>> {
|
|
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<AppState>, Json(b): Json<PreviewBody>) -> AppResult<Json<Value>> {
|
|
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<AppState>, Json(b): Json<AudienceBody>) -> AppResult<Json<Value>> {
|
|
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::<Vec<_>>(), "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<AppState>, Json(b): Json<SendBody>) -> AppResult<Json<Value>> {
|
|
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::<String>()).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::<String>()).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<AppState>, Query(q): Query<UnsubQuery>) -> Html<String> {
|
|
let page = |msg: &str| Html(format!("<!doctype html><meta name=viewport content='width=device-width'><body style=\"font-family:system-ui;background:#0f1220;color:#e9ecff;display:grid;place-items:center;min-height:100vh;margin:0\"><div style=\"max-width:420px;padding:32px;text-align:center\"><h2>{}</h2></div></body>", 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 <b>\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 <Server>", "#112233", "foot");
|
|
assert!(h.contains("<h1") && h.contains("Hi <b>"));
|
|
assert!(h.contains("<strong>world</strong>"));
|
|
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("<li style=\"margin:4px 0\">one</li>"));
|
|
assert!(!h.contains("javascript:"), "only web links survive");
|
|
assert!(h.contains("My <Server>"));
|
|
}
|
|
}
|