Emails: templates, branded HTML, audiences, opt-out, log; admin UI

This commit is contained in:
Claude committed 2026-10-01 01:49:02 +00:00
1 parent 2cc009d66f
commit cd985a1a7b
8 files changed
+722 -7

No files matched your search

+15
View File
@@ -759,6 +759,21 @@ const MIGRATIONS: &[&str] = &[
VALUES (NEW.user_uuid, 'achievement', NEW.achievement_id, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
END;
"#,
// Emails sent from the panel: players can opt out of non-essential mail, and every send is logged.
r#"
ALTER TABLE users ADD COLUMN email_optout INTEGER NOT NULL DEFAULT 0;
CREATE TABLE email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
audience TEXT NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
sent INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
"#,
];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+11 -6
View File
@@ -27,7 +27,7 @@ pub struct ConnectionsSettings {
pub sender_name: String,
}
async fn settings(state: &AppState) -> AppResult<ConnectionsSettings> {
pub async fn settings(state: &AppState) -> AppResult<ConnectionsSettings> {
store::kv_get(state, "connections_settings").await
}
@@ -111,6 +111,11 @@ pub async fn public_config(State(state): State<AppState>) -> AppResult<Json<Valu
}
async fn send_email(state: &AppState, to: &str, subject: &str, body: &str) -> AppResult<()> {
send_mail(state, to, subject, body, None).await
}
/// Send one message, as plain text and, when given, an HTML version of the same content.
pub async fn send_mail(state: &AppState, to: &str, subject: &str, body: &str, html: Option<&str>) -> AppResult<()> {
let s = settings(state).await?;
if s.resend_api_key.is_empty() || s.sender_email.is_empty() {
return Err(AppError::bad_request("configure Resend SMTP and a sender email in Settings first"));
@@ -119,11 +124,11 @@ async fn send_email(state: &AppState, to: &str, subject: &str, body: &str) -> Ap
if s.sender_name.is_empty() { s.sender_email.parse() } else { format!("{} <{}>", s.sender_name, s.sender_email).parse() }
.map_err(|_| AppError::bad_request("invalid sender email"))?;
let to: Mailbox = to.parse().map_err(|_| AppError::bad_request("invalid recipient email"))?;
let message = Message::builder()
.from(from)
.to(to)
.subject(subject)
.body(body.to_string())
let builder = Message::builder().from(from).to(to).subject(subject);
let message = match html {
Some(h) => builder.multipart(lettre::message::MultiPart::alternative_plain_html(body.to_string(), h.to_string())),
None => builder.body(body.to_string()),
}
.map_err(|_| AppError::bad_request("invalid email message"))?;
let smtp = AsyncSmtpTransport::<Tokio1Executor>::relay("smtp.resend.com")
.map_err(|e| AppError::bad_request(format!("SMTP configuration failed: {e}")))?
+424
View File
@@ -0,0 +1,424 @@
//! 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![
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<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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
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("&amp;", "&");
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(&gt)), ("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 &lt;b&gt;"));
assert!(h.contains("<strong>world</strong>"));
assert!(h.contains("href=\"https://x.example/a?b=1&amp;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 &lt;Server&gt;"));
}
}
+7
View File
@@ -9,6 +9,7 @@ pub mod guild_bank;
pub mod guilds;
pub mod discord;
pub mod discord_studio;
pub mod emails;
pub mod integrations;
pub mod landing;
pub mod worldmap;
@@ -64,6 +65,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/{id}/invites", post(map::send_invite))
.route("/leaderboard", get(servers::global_leaderboard))
.route("/landing", get(landing::public_landing))
.route("/email/unsubscribe", get(emails::unsubscribe))
.route("/launcher/authlib-injector.json", get(account::authlib_index))
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
// Leveling & Rewards
@@ -176,6 +178,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
.route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
.route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
.route("/email", get(emails::get_all))
.route("/email/templates", put(emails::put_templates))
.route("/email/preview", post(emails::preview))
.route("/email/audience", post(emails::audience))
.route("/email/send", post(emails::send))
.route("/discord/studio", get(discord_studio::get_all))
.route("/discord/studio/templates", put(discord_studio::put_templates))
.route("/discord/studio/live", put(discord_studio::put_live))
+59
View File
@@ -0,0 +1,59 @@
mod common;
use common::*;
#[tokio::test]
async fn emails_use_templates_audiences_and_opt_outs() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
for (n, e) in [("Steve", "steve@example.com"), ("Alex", "alex@example.com"), ("Notch", "")] {
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123", "email": e}))).await;
}
let (s, all) = t.call("GET", "/api/admin/email", Some(&admin), None).await;
assert_eq!(s, StatusCode::OK, "{all}");
assert_eq!(all["configured"], false);
assert!(all["templates"].as_array().unwrap().len() >= 3, "starter templates are offered");
// Templates save, with sensible limits.
let bad = json!({"templates": [{"id": "bad id!", "name": "x", "subject": "s", "body": "b"}]});
assert_eq!(t.call("PUT", "/api/admin/email/templates", Some(&admin), Some(bad)).await.0, StatusCode::BAD_REQUEST);
let ok = json!({"templates": [{"id": "news", "name": "News", "subject": "Hi {player}", "body": "Hello **{player}**, level {level}\n\n[button: Go]({panel_url})"}]});
assert_eq!(t.call("PUT", "/api/admin/email/templates", Some(&admin), Some(ok)).await.0, StatusCode::OK);
// Placeholders fill per player, and the HTML is branded and has an unsubscribe link.
let (s, p) = t.call("POST", "/api/admin/email/preview", Some(&admin), Some(json!({"subject": "Hi {player}", "body": "Hello **{player}**\n\n[button: Go]({panel_url})"}))).await;
assert_eq!(s, StatusCode::OK, "{p}");
assert_eq!(p["subject"], "Hi admin");
assert!(p["html"].as_str().unwrap().contains("<strong>admin</strong>") && p["html"].as_str().unwrap().contains("Unsubscribe"));
assert!(p["text"].as_str().unwrap().contains("/api/v1/email/unsubscribe?u="));
// Only active players with an email count.
let count = |a: Value| { let (t, admin) = (&t, admin.clone()); async move { t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": a}))).await.1 } };
assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "Steve and Alex have an address; Notch and admin don't");
assert_eq!(count(json!({"kind": "players", "names": ["steve", "nobody"]})).await["count"], 1);
// Opting out through the link removes a player from marketing mail, but not from account notices.
let steve = t.uuid("Steve").await;
let token = {
let secret: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'email_unsubscribe_secret'").fetch_one(&t.db).await.unwrap();
let secret = secret.trim_matches('"').to_string();
use sha2::{Digest, Sha256};
Sha256::digest(format!("{secret}:{steve}").as_bytes()).iter().take(16).map(|b| format!("{b:02x}")).collect::<String>()
};
let (s, _) = t.fetch(&format!("/api/v1/email/unsubscribe?u={steve}&t=wrong")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "a wrong token changes nothing");
t.fetch(&format!("/api/v1/email/unsubscribe?u={steve}&t={token}")).await;
assert_eq!(count(json!({"kind": "all"})).await["count"], 1);
let (_, important) = t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": {"kind": "all"}, "important": true}))).await;
assert_eq!(important["count"], 2);
// Without SMTP configured, a send is queued but each message fails with a clear reason in the log.
let (s, sent) = t.call("POST", "/api/admin/email/send", Some(&admin), Some(json!({"subject": "Hi", "body": "Hello", "audience": {"kind": "all"}}))).await;
assert_eq!(s, StatusCode::OK, "{sent}");
for _ in 0..40 {
let (_, all) = t.call("GET", "/api/admin/email", Some(&admin), None).await;
if all["log"][0]["failed"] == 1 { assert!(all["log"][0]["last_error"].as_str().unwrap().contains("Resend")); return; }
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
panic!("the send was never logged");
}
+3
View File
@@ -19,6 +19,7 @@
import LevelingAdmin from './pages/LevelingAdmin.svelte';
import Progression from './pages/Progression.svelte';
import DiscordStudio from './pages/DiscordStudio.svelte';
import Emails from './pages/Emails.svelte';
import GuildsAdmin from './pages/GuildsAdmin.svelte';
import { get } from './lib/api';
import { route } from './lib/router.svelte';
@@ -100,6 +101,8 @@
<Users />
{:else if route.name === 'branding'}
<Branding onsaved={(b) => (brand = b)} />
{:else if route.name === 'emails'}
<Emails />
{:else if route.name === 'discord'}
<DiscordStudio />
{:else if route.name === 'landing-builder'}
+2 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield, SlidersHorizontal, MessageSquare } from '@lucide/svelte';
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield, SlidersHorizontal, MessageSquare, Mail } from '@lucide/svelte';
import { route } from '../lib/router.svelte';
import { logout, session } from '../lib/session.svelte';
import Avatar from './Avatar.svelte';
@@ -20,6 +20,7 @@
{ id: 'capes', label: 'Capes', icon: Flag },
{ id: 'branding', label: 'Launcher design', icon: Palette },
{ id: 'discord', label: 'Discord', icon: MessageSquare },
{ id: 'emails', label: 'Emails', icon: Mail },
{ id: 'landing-builder', label: 'Landing page', icon: Globe },
{ id: 'settings', label: 'Settings', icon: Settings },
];
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Mail, Plus, Trash2, Send, Save, Users, Copy } from '@lucide/svelte';
import Toggle from '../components/Toggle.svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, timeAgo } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
type Template = { id: string; name: string; subject: string; body: string };
type Data = { configured: boolean; templates: Template[]; placeholders: string[]; groups: { id: number; name: string }[]; log: any[] };
let data = $state<Data | null>(null);
let templates = $state<Template[]>([]);
let current = $state(0);
let important = $state(false);
let preview = $state<{ subject: string; html: string } | null>(null);
let testTo = $state('');
let kind = $state<'all' | 'group' | 'players'>('all');
let groupId = $state(0);
let names = $state('');
let count = $state<{ count: number; sample: string[] } | null>(null);
let busy = $state('');
let confirmOpen = $state(false);
let timer: ReturnType<typeof setTimeout>;
let body = $state<HTMLTextAreaElement>();
async function load() {
try {
data = await get<Data>('/api/admin/email');
templates = structuredClone(data.templates);
if (!groupId && data.groups[0]) groupId = data.groups[0].id;
} catch (e) { toastError(e); }
}
onMount(() => { load(); const i = setInterval(() => get<Data>('/api/admin/email').then((d) => data && (data.log = d.log)).catch(() => {}), 5000); return () => clearInterval(i); });
const t = $derived(templates[current]);
const audience = $derived({ kind, group_id: groupId, names: names.split(/[\n,]+/).map((n) => n.trim()).filter(Boolean) });
$effect(() => {
const subject = t?.subject, text = t?.body, imp = important;
clearTimeout(timer);
if (!t) return;
timer = setTimeout(async () => {
try { preview = await post('/api/admin/email/preview', { subject, body: text, important: imp }); } catch { preview = null; }
}, 400);
return () => clearTimeout(timer);
});
$effect(() => {
const a = audience, imp = important;
let alive = true;
post<{ count: number; sample: string[] }>('/api/admin/email/audience', { audience: a, important: imp }).then((r) => alive && (count = r)).catch(() => alive && (count = null));
return () => { alive = false; };
});
function add() {
const id = `mail-${Date.now().toString(36)}`;
templates = [...templates, { id, name: 'New email', subject: 'A message from {brand}', body: 'Hi {player},\n\n' }];
current = templates.length - 1;
}
function duplicate() { templates = [...templates, { ...t, id: `${t.id}-copy-${Date.now().toString(36).slice(-3)}`, name: `${t.name} copy` }]; current = templates.length - 1; }
function remove() { templates = templates.filter((_, i) => i !== current); current = Math.max(0, current - 1); }
function insert(p: string) {
const token = `{${p}}`;
const s = body?.selectionStart ?? t.body.length, e = body?.selectionEnd ?? s;
t.body = t.body.slice(0, s) + token + t.body.slice(e);
queueMicrotask(() => { body?.focus(); body?.setSelectionRange(s + token.length, s + token.length); });
}
async function save() {
busy = 'save';
try { await put('/api/admin/email/templates', { templates }); toast('Templates saved'); } catch (e) { toastError(e); } finally { busy = ''; }
}
async function sendTest() {
busy = 'test';
try { await post('/api/admin/email/send', { subject: t.subject, body: t.body, important, test_to: testTo }); toast(`Test sent to ${testTo}`); } catch (e) { toastError(e); } finally { busy = ''; }
}
async function sendAll() {
busy = 'send'; confirmOpen = false;
try { const r = await post<{ queued: number }>('/api/admin/email/send', { subject: t.subject, body: t.body, important, audience }); toast(`Sending to ${r.queued} players…`); await load(); } catch (e) { toastError(e); } finally { busy = ''; }
}
</script>
<div class="page wide">
<header>
<div>
<h1>Emails</h1>
<p>Write reusable emails and send them to everyone, a group, or chosen players. Messages use your launcher colours.{#if data && !data.configured} <strong class="warn">Set up email under Settings first (sender and Resend key).</strong>{/if}</p>
</div>
</header>
{#if data && t}
<div class="layout">
<aside class="card list">
{#each templates as tpl, i}
<button class="item" class:on={i === current} onclick={() => (current = i)}><Mail size={15} /><span><strong>{tpl.name || 'Untitled'}</strong><small>{tpl.subject}</small></span></button>
{/each}
<button class="ghost add" onclick={add}><Plus size={15} /> New email</button>
</aside>
<section class="card form">
<div class="grid">
<label class="field">Name <small>only you see this</small><input bind:value={t.name} maxlength="80" /></label>
<label class="field">Subject<input bind:value={t.subject} maxlength="200" /></label>
</div>
<div class="ph"><span class="lbl">Placeholders <small>click to insert</small></span>
<div>{#each data.placeholders as p}<button type="button" class="chip" onclick={() => insert(p)}>{`{${p}}`}</button>{/each}</div></div>
<label class="field">Message
<textarea rows="12" bind:value={t.body} bind:this={body} maxlength="20000"></textarea>
<small># Heading · **bold** · - list item · [link text](https://…) · [button: Label](https://…)</small>
</label>
<Toggle bind:checked={important} label="Account notice" help="Also reaches players who unsubscribed, and leaves out the unsubscribe link. Use only for things about their account." />
<div class="actions">
<button class="primary" onclick={save} disabled={!!busy}><Save size={15} /> Save templates</button>
<button class="ghost" onclick={duplicate}><Copy size={15} /> Duplicate</button>
<button class="ghost danger" onclick={remove} disabled={templates.length < 2}><Trash2 size={15} /> Delete</button>
</div>
<div class="send">
<h3>Send</h3>
<div class="testrow">
<input bind:value={testTo} type="email" placeholder="you@example.com" />
<button onclick={sendTest} disabled={!testTo || !!busy || !data.configured}><Send size={15} /> Send a test</button>
</div>
<div class="aud">
<label class="field">Send to
<select bind:value={kind}><option value="all">Everyone with an email</option><option value="group">A group</option><option value="players">Chosen players</option></select>
</label>
{#if kind === 'group'}<label class="field">Group<select bind:value={groupId}>{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}</select></label>{/if}
{#if kind === 'players'}<label class="field wide">Player names <small>separated by commas or new lines</small><textarea rows="2" bind:value={names}></textarea></label>{/if}
</div>
<p class="muted small"><Users size={13} /> {count ? `${count.count} recipient${count.count === 1 ? '' : 's'}${count.sample.length ? ` (${count.sample.join(', ')}${count.count > 5 ? '…' : ''})` : ''}` : '…'}</p>
<button class="primary" onclick={() => (confirmOpen = true)} disabled={!!busy || !count?.count || !data.configured}><Send size={15} /> Send to {count?.count ?? 0} players</button>
</div>
</section>
<section class="preview">
<h3>Preview</h3>
{#if preview}
<div class="subject"><span>Subject</span>{preview.subject}</div>
<iframe title="Email preview" sandbox="" srcdoc={preview.html}></iframe>
{:else}<p class="muted">Loading…</p>{/if}
</section>
</div>
<section class="card history">
<h3>Sent emails</h3>
{#if data.log.length}
<table class="table">
<thead><tr><th>When</th><th>Subject</th><th>To</th><th>Sent</th><th>Failed</th></tr></thead>
<tbody>
{#each data.log as l}
<tr><td class="muted small">{timeAgo(l.started_at)}</td><td>{l.subject}</td><td>{l.audience}</td><td>{l.sent}/{l.total}{#if !l.finished_at} …{/if}</td><td class:bad={l.failed}>{l.failed}{#if l.last_error} <small class="muted" title={l.last_error}>{l.last_error}</small>{/if}</td></tr>
{/each}
</tbody>
</table>
{:else}<p class="muted">Nothing sent yet.</p>{/if}
</section>
{:else}<p class="muted">Loading…</p>{/if}
</div>
<Modal bind:open={confirmOpen} title="Send this email?">
<p>This sends <strong>{t?.subject}</strong> to <strong>{count?.count ?? 0}</strong> players. It can't be undone.</p>
<div class="actions end"><button class="ghost" onclick={() => (confirmOpen = false)}>Cancel</button><button class="primary" onclick={sendAll}><Send size={15} /> Send now</button></div>
</Modal>
<style>
.page { display: flex; flex-direction: column; gap: 18px; }
header h1 { margin: 0; }
header p { margin: 4px 0 0; color: var(--muted); }
.warn { color: #fbbf24; margin-left: 6px; }
.layout { display: grid; grid-template-columns: 240px minmax(0, 1.3fr) minmax(0, 1fr); gap: 16px; align-items: start; }
.list { display: flex; flex-direction: column; gap: 6px; padding: 10px; position: sticky; top: 16px; }
.item { display: flex; align-items: flex-start; gap: 9px; text-align: left; padding: 10px; border-radius: 10px; border: 1px solid transparent; background: transparent; }
.item.on { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.item small { display: block; color: var(--muted); font-weight: 400; font-size: 0.74rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
.add { justify-content: flex-start; }
.form { display: flex; flex-direction: column; gap: 14px; }
.grid, .aud { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; font-size: 0.85rem; }
.field.wide { grid-column: 1 / -1; }
.field small, .ph small { color: var(--muted); font-weight: 400; }
.ph { display: flex; flex-direction: column; gap: 6px; }
.ph > div { display: flex; flex-wrap: wrap; gap: 6px; }
.lbl { font-size: 0.82rem; font-weight: 600; }
.chip { padding: 3px 9px; border-radius: 999px; font: 500 0.76rem ui-monospace, monospace; background: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.actions.end { justify-content: flex-end; margin-top: 14px; }
.actions button, .send button, .testrow button { display: inline-flex; align-items: center; gap: 6px; }
.send { display: flex; flex-direction: column; gap: 10px; padding-top: 14px; border-top: 1px solid var(--line); }
.send h3, .preview h3, .history h3 { margin: 0; font-size: 0.95rem; }
.testrow { display: flex; gap: 8px; }
.testrow input { flex: 1; }
.preview { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 8px; }
.subject { padding: 8px 12px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); font-weight: 600; }
.subject span { display: block; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-weight: 500; }
iframe { width: 100%; height: 520px; border: 1px solid var(--line); border-radius: 12px; background: #0f1220; }
.history { display: flex; flex-direction: column; gap: 10px; }
.bad { color: #fb7185; }
@media (max-width: 1300px) { .layout { grid-template-columns: 220px minmax(0, 1fr); } .preview { grid-column: 1 / -1; position: static; } }
@media (max-width: 800px) { .layout { grid-template-columns: 1fr; } .list { position: static; } .grid, .aud { grid-template-columns: 1fr; } }
</style>