diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 99161ad..c59aa12 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -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 { diff --git a/panel/server/src/routes/connections.rs b/panel/server/src/routes/connections.rs index cb9784d..8a61d52 100644 --- a/panel/server/src/routes/connections.rs +++ b/panel/server/src/routes/connections.rs @@ -27,7 +27,7 @@ pub struct ConnectionsSettings { pub sender_name: String, } -async fn settings(state: &AppState) -> AppResult { +pub async fn settings(state: &AppState) -> AppResult { store::kv_get(state, "connections_settings").await } @@ -111,6 +111,11 @@ pub async fn public_config(State(state): State) -> AppResult 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,12 +124,12 @@ 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()) - .map_err(|_| AppError::bad_request("invalid email message"))?; + 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::::relay("smtp.resend.com") .map_err(|e| AppError::bad_request(format!("SMTP configuration failed: {e}")))? .credentials(Credentials::new("resend".into(), s.resend_api_key)) diff --git a/panel/server/src/routes/emails.rs b/panel/server/src/routes/emails.rs new file mode 100644 index 0000000..3df975b --- /dev/null +++ b/panel/server/src/routes/emails.rs @@ -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 { + 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>")); + } +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 4390c6c..5d2eadf 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -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 { .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 { .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)) diff --git a/panel/server/tests/emails.rs b/panel/server/tests/emails.rs new file mode 100644 index 0000000..622572c --- /dev/null +++ b/panel/server/tests/emails.rs @@ -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("admin") && 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::() + }; + 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"); +} diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 207b7b8..ad69499 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -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 @@ {:else if route.name === 'branding'} (brand = b)} /> + {:else if route.name === 'emails'} + {:else if route.name === 'discord'} {:else if route.name === 'landing-builder'} diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index ca39ddd..d441314 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ + +
    +
    +
    +

    Emails

    +

    Write reusable emails and send them to everyone, a group, or chosen players. Messages use your launcher colours.{#if data && !data.configured} Set up email under Settings first (sender and Resend key).{/if}

    +
    +
    + + {#if data && t} +
    + + +
    +
    + + +
    +
    Placeholders click to insert +
    {#each data.placeholders as p}{/each}
    + + +
    + + + +
    + +
    +

    Send

    +
    + + +
    +
    + + {#if kind === 'group'}{/if} + {#if kind === 'players'}{/if} +
    +

    {count ? `${count.count} recipient${count.count === 1 ? '' : 's'}${count.sample.length ? ` (${count.sample.join(', ')}${count.count > 5 ? '…' : ''})` : ''}` : '…'}

    + +
    +
    + +
    +

    Preview

    + {#if preview} +
    Subject{preview.subject}
    + + {:else}

    Loading…

    {/if} +
    +
    + +
    +

    Sent emails

    + {#if data.log.length} + + + + {#each data.log as l} + + {/each} + +
    WhenSubjectToSentFailed
    {timeAgo(l.started_at)}{l.subject}{l.audience}{l.sent}/{l.total}{#if !l.finished_at} …{/if}{l.failed}{#if l.last_error} {l.last_error}{/if}
    + {:else}

    Nothing sent yet.

    {/if} +
    + {:else}

    Loading…

    {/if} +
    + + +

    This sends {t?.subject} to {count?.count ?? 0} players. It can't be undone.

    +
    +
    + +