Emails: templates, branded HTML, audiences, opt-out, log; admin UI
This commit is contained in:
8 files changed
+722
-7
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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}")))?
|
||||
|
||||
@@ -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\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>"));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user