main
Colby Knox Publish 0.1.0 fb6e12f 11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
use std::collections::{BTreeMap, VecDeque};
use std::sync::{Arc, Mutex, RwLock};

use serde::Deserialize;
use serde_json::{Value, json};
use tiny_http::{Header, Method, Response, Server};

use crate::convert::{self, Mode};
use crate::output;
use crate::resend::ResendClient;
use crate::sendgrid::{self, ActiveTemplate, SendGridClient};
use crate::settings::{self, Settings, env_nonempty};
use crate::store::{Draft, Store, VariableSpec};

const UI_HTML: &str = include_str!("../ui/index.html");
const UI_JS: &str = include_str!("../ui/app.js");
const UI_CSS: &str = include_str!("../ui/style.css");
const ICON_SVG: &str = include_str!("../docs/icon.svg");

const STARTER_HTML: &str = r#"<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
    <tr><td align="center">
      <table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;padding:32px;">
        <tr><td style="font-size:16px;line-height:1.6;color:#1f2328;">
          <p>Hi {{{name}}},</p>
          <p>Write the email here.</p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>
"#;

struct ConsoleLog {
    messages: VecDeque<Value>,
}

impl ConsoleLog {
    fn push(&mut self, entry: Value) {
        if self.messages.len() >= 500 {
            self.messages.pop_front();
        }
        self.messages.push_back(entry);
    }
}

pub struct AppState {
    pub dev_mode: bool,
    port: u16,
    settings: Settings,
    resend: RwLock<Option<Arc<ResendClient>>>,
    /// "full" | "sending" | "invalid" | "unknown" | "checking" | "none", plus the error if any.
    resend_access: RwLock<(String, Option<String>)>,
    sendgrid: RwLock<Option<Arc<SendGridClient>>>,
    sendgrid_studio_db: Option<String>,
    store: Store,
    allowed_hosts: Vec<String>,
    console: Mutex<ConsoleLog>,
    sendgrid_cache: Mutex<Option<Vec<ActiveTemplate>>>,
}

impl AppState {
    pub fn new(dev_mode: bool, port: u16, db_path: &str, settings: Settings) -> anyhow::Result<Self> {
        let mut allowed_hosts = vec!["127.0.0.1".to_string(), "localhost".to_string(), "[::1]".to_string()];
        // Reached through a dev-container port proxy (code-server / Hydrogen):
        // the browser's Host is the proxy's.
        if let Some(proxy) = env_nonempty("VSCODE_PROXY_URI") {
            if let Some(host) = proxy.split("://").nth(1).and_then(|r| r.split(['/', ':']).next()) {
                allowed_hosts.push(host.to_lowercase());
            }
        }
        if let Some(extra) = env_nonempty("RESEND_STUDIO_ALLOWED_HOSTS") {
            allowed_hosts.extend(extra.split(',').map(|h| h.trim().to_lowercase()).filter(|h| !h.is_empty()));
        }
        let state = Self {
            dev_mode,
            port,
            settings,
            resend: RwLock::new(None),
            resend_access: RwLock::new(("none".into(), None)),
            sendgrid: RwLock::new(None),
            sendgrid_studio_db: env_nonempty("SENDGRID_STUDIO_DB"),
            store: Store::open(db_path)?,
            allowed_hosts,
            console: Mutex::new(ConsoleLog { messages: VecDeque::with_capacity(500) }),
            sendgrid_cache: Mutex::new(None),
        };
        state.rebuild_clients();
        Ok(state)
    }

    fn resend(&self) -> Option<Arc<ResendClient>> {
        self.resend.read().unwrap().clone()
    }

    fn sendgrid(&self) -> Option<Arc<SendGridClient>> {
        self.sendgrid.read().unwrap().clone()
    }

    /// Re-reads the keys after a settings change and re-checks what the
    /// Resend key may do.
    fn rebuild_clients(&self) {
        let rs = self.settings.resend_key().0.map(|k| Arc::new(ResendClient::new(k)));
        let access = match &rs {
            Some(c) => {
                let (a, e) = c.check_access();
                (a.to_string(), e)
            }
            None => ("none".to_string(), None),
        };
        *self.resend.write().unwrap() = rs;
        *self.resend_access.write().unwrap() = access;
        *self.sendgrid.write().unwrap() = self.settings.sendgrid_key().0.map(|k| Arc::new(SendGridClient::new(k)));
        *self.sendgrid_cache.lock().unwrap() = None;
    }

    fn host_allowed(&self, host: &str) -> bool {
        let host = host.trim().to_lowercase();
        let name = if host.starts_with('[') {
            host.split(']').next().map(|h| format!("{h}]")).unwrap_or(host.clone())
        } else {
            host.split(':').next().unwrap_or("").to_string()
        };
        self.allowed_hosts.iter().any(|h| *h == name)
    }
}

/// Best-effort port-forward hint to Hydrogen Desktop's watcher so the Port
/// Mappings dialog shows a friendly name. A no-op outside an HD container.
fn port_hint(port: u16, action: &str, name: Option<&str>, reason: Option<&str>) {
    let mut payload = json!({
        "port": port,
        "action": action,
        "expose": "internal",
        "source": "adom-app:adom-resend-studio",
    });
    if let Some(n) = name {
        payload["name"] = json!(n);
    }
    if let Some(r) = reason {
        payload["reason"] = json!(r);
    }
    let _ = ureq::post("http://host.docker.internal:47084/port-forward")
        .timeout(std::time::Duration::from_millis(300))
        .send_json(payload);
}

pub fn run(port: u16, state: Arc<AppState>) -> anyhow::Result<()> {
    let server = Server::http(("127.0.0.1", port)).map_err(|e| anyhow::anyhow!("failed to bind port {port}: {e}"))?;
    port_hint(port, "register", Some("Resend Studio"), Some("main HTTP server + webview UI"));
    output::ok(format!("adom-resend-studio listening on http://127.0.0.1:{port}/"));

    for request in server.incoming_requests() {
        let state = state.clone();
        std::thread::spawn(move || {
            if let Err(e) = handle(request, &state) {
                output::err(format!("request handling failed: {e}"));
            }
        });
    }
    Ok(())
}

fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let Some(b) = std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) {
                out.push(b);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

type Reply = (u16, &'static str, String);

fn handle(mut request: tiny_http::Request, state: &AppState) -> anyhow::Result<()> {
    let method = request.method().clone();
    let url = request.url().to_string();
    let (path, query) = url.split_once('?').unwrap_or((url.as_str(), ""));
    let segments: Vec<String> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).map(percent_decode).collect();
    let segs: Vec<&str> = segments.iter().map(|s| s.as_str()).collect();

    let header = |name: &str| {
        request
            .headers()
            .iter()
            .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))
            .map(|h| h.value.as_str().to_string())
    };
    // The server holds API keys that can send mail as the user, so only its
    // own page may talk to it. A Host check stops DNS rebinding; requiring a
    // JSON content type on writes stops cross-site form posts (a cross-origin
    // JSON request needs a CORS preflight, which this server never grants).
    let host = header("Host").unwrap_or_default();
    let refusal = if !state.host_allowed(&host) {
        output::hint(format!("refused a request for Host {host:?}; add it to RESEND_STUDIO_ALLOWED_HOSTS if it is yours"));
        Some("unknown Host; set RESEND_STUDIO_ALLOWED_HOSTS to reach the studio through another name")
    } else if !matches!(method, Method::Get | Method::Head)
        && !header("Content-Type").map(|c| c.to_lowercase().starts_with("application/json")).unwrap_or(false)
    {
        Some("write requests must be Content-Type: application/json")
    } else {
        None
    };

    let mut body = String::new();
    if !matches!(method, Method::Get | Method::Head) {
        request.as_reader().read_to_string(&mut body).ok();
    }

    let (status, content_type, payload) = match refusal {
        Some(msg) => err_json(403, msg),
        None => route(&method, &segs, query, &body, state),
    };

    let header = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
        .map_err(|_| anyhow::anyhow!("invalid content-type header"))?;
    let no_store = Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..]).unwrap();
    request.respond(Response::from_string(payload).with_status_code(status).with_header(header).with_header(no_store))?;
    Ok(())
}

fn route(method: &Method, segments: &[&str], query: &str, body: &str, state: &AppState) -> Reply {
    match (method, segments) {
        (Method::Get, []) => (200, "text/html; charset=utf-8", UI_HTML.to_string()),
        (Method::Get, ["app.js"]) => (200, "application/javascript", UI_JS.to_string()),
        (Method::Get, ["style.css"]) => (200, "text/css", UI_CSS.to_string()),
        (Method::Get, ["favicon.svg"]) => (200, "image/svg+xml", ICON_SVG.to_string()),

        (Method::Get, ["health"]) => ok(json!({"ok": true, "service": "adom-resend-studio", "version": env!("CARGO_PKG_VERSION")})),

        (Method::Get, ["state"]) => {
            let (access, access_error) = state.resend_access.read().unwrap().clone();
            ok(json!({
                "resend_configured": state.resend().is_some(),
                "resend_access": access,
                "resend_access_error": access_error,
                "sendgrid_configured": state.sendgrid().is_some(),
                "dev_mode": state.dev_mode,
                "version": env!("CARGO_PKG_VERSION"),
                "default_from": state.settings.default_from(),
                "default_reply_to": state.settings.default_reply_to(),
                "default_test_to": state.settings.default_test_to(),
            }))
        }

        (Method::Get, ["api", "settings"]) => api_get_settings(state),
        (Method::Post, ["api", "settings"]) => api_save_settings(state, body),

        (Method::Get, ["console"]) => {
            let log = state.console.lock().unwrap();
            ok(json!({"messages": log.messages.iter().collect::<Vec<_>>()}))
        }
        (Method::Post, ["console"]) => {
            if let Ok(entry) = serde_json::from_str::<Value>(body) {
                state.console.lock().unwrap().push(entry);
            }
            ok(json!({"ok": true}))
        }
        (Method::Delete, ["console"]) => {
            state.console.lock().unwrap().messages.clear();
            ok(json!({"ok": true}))
        }

        (Method::Get, ["api", "templates"]) => api_list(state),
        (Method::Post, ["api", "templates"]) => api_create(state, body),
        (Method::Get, ["api", "templates", key]) => api_get(state, key),
        (Method::Post, ["api", "templates", key, "draft"]) => api_save(state, key, body),
        (Method::Delete, ["api", "templates", key, "draft"]) => api_discard(state, key),
        (Method::Post, ["api", "templates", key, "upload"]) => api_upload(state, key, body),
        (Method::Post, ["api", "templates", key, "publish"]) => api_publish(state, key, body),
        (Method::Post, ["api", "templates", key, "send-test"]) => api_send_test(state, key, body),

        (Method::Post, ["api", "lint"]) => api_lint(body),
        (Method::Post, ["api", "convert"]) => api_convert(body),
        (Method::Get, ["api", "sendgrid", "templates"]) => api_sendgrid_list(state, query.contains("refresh=1")),
        (Method::Post, ["api", "import", "sendgrid"]) => api_import(state, body),

        (Method::Post, ["shutdown"]) => {
            let port = state.port;
            std::thread::spawn(move || {
                std::thread::sleep(std::time::Duration::from_millis(200));
                port_hint(port, "unregister", None, None);
                std::process::exit(0);
            });
            ok(json!({"ok": true}))
        }

        _ => err_json(404, "not found"),
    }
}

fn ok(v: Value) -> Reply {
    (200, "application/json", v.to_string())
}

fn err_json(status: u16, msg: impl std::fmt::Display) -> Reply {
    (status, "application/json", json!({"error": msg.to_string()}).to_string())
}

fn err_with(status: u16, msg: impl std::fmt::Display, extra: Value) -> Reply {
    let mut v = json!({"error": msg.to_string()});
    if let (Some(o), Value::Object(e)) = (v.as_object_mut(), extra) {
        o.extend(e);
    }
    (status, "application/json", v.to_string())
}

const NOT_CONFIGURED: &str = "Resend is not connected yet: add your Resend API key in Settings";

fn api_get_settings(state: &AppState) -> Reply {
    let (rk, rsrc) = state.settings.resend_key();
    let (sk, ssrc) = state.settings.sendgrid_key();
    let (access, access_error) = state.resend_access.read().unwrap().clone();
    ok(json!({
        "file": state.settings.path().display().to_string(),
        "resend": {"source": rsrc, "masked": rk.as_deref().map(settings::mask), "access": access, "error": access_error},
        "sendgrid": {"source": ssrc, "masked": sk.as_deref().map(settings::mask)},
        "default_from": state.settings.default_from(),
        "default_reply_to": state.settings.default_reply_to(),
        "default_test_to": state.settings.default_test_to(),
    }))
}

fn api_save_settings(state: &AppState, body: &str) -> Reply {
    let patch: settings::Patch = match serde_json::from_str(body) {
        Ok(p) => p,
        Err(e) => return err_json(400, e),
    };
    if let Some(k) = patch.resend_api_key.as_deref().map(str::trim).filter(|k| !k.is_empty()) {
        if !k.starts_with("re_") {
            return err_json(400, "that doesn't look like a Resend API key (they start with re_)");
        }
    }
    if let Some(k) = patch.sendgrid_api_key.as_deref().map(str::trim).filter(|k| !k.is_empty()) {
        if !k.starts_with("SG.") {
            return err_json(400, "that doesn't look like a SendGrid API key (they start with SG.)");
        }
    }
    if let Err(e) = state.settings.apply(patch) {
        return err_json(500, e);
    }
    state.rebuild_clients();
    api_get_settings(state)
}

fn str_field(v: &Value, k: &str) -> Option<String> {
    v.get(k).and_then(|x| x.as_str()).map(String::from).filter(|s| !s.is_empty())
}

/// Builds an editable draft from a remote Resend template, marked clean
/// (its snapshot equals what Resend already has).
fn draft_from_remote(r: &Value) -> Draft {
    let id = str_field(r, "id").unwrap_or_default();
    let alias = str_field(r, "alias");
    let variables = r
        .get("variables")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .map(|v| {
                    let fb = v.get("fallback_value").cloned().filter(|f| !f.is_null());
                    VariableSpec {
                        key: str_field(v, "key").unwrap_or_default(),
                        var_type: str_field(v, "type").unwrap_or_else(|| "string".into()),
                        sample: fb.clone().unwrap_or(Value::String(String::new())),
                        fallback: fb,
                        description: None,
                    }
                })
                .collect()
        })
        .unwrap_or_default();
    let reply_to = match r.get("reply_to") {
        Some(Value::String(s)) => Some(s.clone()),
        Some(Value::Array(a)) => Some(a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")).filter(|s| !s.is_empty()),
        _ => None,
    };
    let mut d = Draft {
        key: alias.clone().unwrap_or_else(|| id.clone()),
        resend_id: Some(id),
        name: str_field(r, "name").unwrap_or_default(),
        alias,
        subject: str_field(r, "subject").unwrap_or_default(),
        from: str_field(r, "from"),
        reply_to,
        html: str_field(r, "html").unwrap_or_default(),
        variables,
        source: None,
        notes: None,
        uploaded_at: None,
        uploaded_snapshot: None,
        updated_at: None,
    };
    d.uploaded_snapshot = Some(d.resend_payload().to_string());
    d
}

fn draft_summary(d: &Draft) -> Value {
    json!({
        "dirty": d.is_dirty(),
        "uploaded": d.uploaded_at.is_some() || d.resend_id.is_some(),
        "uploaded_at": d.uploaded_at,
        "updated_at": d.updated_at,
        "source": d.source,
    })
}

fn api_list(state: &AppState) -> Reply {
    let drafts = match state.store.all() {
        Ok(d) => d,
        Err(e) => return err_json(500, e),
    };
    let (remote, remote_error) = match state.resend() {
        Some(rs) => match rs.list_templates() {
            Ok(r) => (r, None),
            Err(e) => (vec![], Some(e.to_string())),
        },
        None => (vec![], Some(NOT_CONFIGURED.to_string())),
    };
    let mut used = std::collections::HashSet::new();
    let mut items = Vec::new();
    for r in &remote {
        let id = str_field(r, "id").unwrap_or_default();
        let alias = str_field(r, "alias");
        let d = drafts
            .iter()
            .find(|d| d.resend_id.as_deref() == Some(&id) || d.key == id)
            .or_else(|| alias.as_ref().and_then(|a| drafts.iter().find(|d| d.resend_id.is_none() && (d.alias.as_ref() == Some(a) || &d.key == a))));
        if let Some(d) = d {
            used.insert(d.key.clone());
        }
        items.push(json!({
            "key": d.map(|d| d.key.clone()).or(alias.clone()).unwrap_or(id.clone()),
            "name": d.map(|d| d.name.clone()).or(str_field(r, "name")).unwrap_or_default(),
            "alias": d.and_then(|d| d.alias.clone()).or(alias),
            "remote": r,
            "draft": d.map(draft_summary),
        }));
    }
    for d in drafts.iter().filter(|d| !used.contains(&d.key)) {
        items.push(json!({
            "key": d.key,
            "name": d.name,
            "alias": d.alias,
            "remote": Value::Null,
            "draft": draft_summary(d),
        }));
    }
    items.sort_by(|a, b| {
        let n = |v: &Value| v.get("name").and_then(|x| x.as_str()).unwrap_or("").to_lowercase();
        n(a).cmp(&n(b))
    });
    ok(json!({"templates": items, "remote_error": remote_error}))
}

fn api_get(state: &AppState, key: &str) -> Reply {
    let mut draft = match state.store.get(key) {
        Ok(d) => d,
        Err(e) => return err_json(500, e),
    };
    let mut remote: Option<Value> = None;
    let mut remote_error: Option<String> = None;
    if let Some(rs) = state.resend() {
        let lookup = draft
            .as_ref()
            .map(|d| d.resend_id.clone().or(d.alias.clone()).unwrap_or(d.key.clone()))
            .unwrap_or(key.to_string());
        match rs.get_template(&lookup) {
            Ok(r) => remote = r,
            Err(e) => remote_error = Some(e.to_string()),
        }
        if draft.is_none() {
            if let Some(r) = &remote {
                let id = str_field(r, "id").unwrap_or_default();
                draft = state.store.find_for_remote(&id, str_field(r, "alias").as_deref()).ok().flatten();
            }
        }
    } else {
        remote_error = Some(NOT_CONFIGURED.into());
    }
    let editable = match (&draft, &remote) {
        (Some(d), _) => d.clone(),
        (None, Some(r)) => draft_from_remote(r),
        (None, None) => return err_with(404, format!("no local draft or Resend template named {key}"), json!({"remote_error": remote_error})),
    };
    let lint = convert::lint(&editable.html, &editable.subject, &editable.variables, editable.alias.as_deref());
    ok(json!({
        "key": editable.key,
        "has_draft": draft.is_some(),
        "dirty": editable.is_dirty(),
        "editable": editable,
        "remote": remote,
        "remote_error": remote_error,
        "lint": lint,
    }))
}

#[derive(Deserialize)]
struct EditableInput {
    name: String,
    #[serde(default)]
    alias: Option<String>,
    #[serde(default)]
    subject: String,
    #[serde(default)]
    from: Option<String>,
    #[serde(default)]
    reply_to: Option<String>,
    #[serde(default)]
    html: String,
    #[serde(default)]
    variables: Vec<VariableSpec>,
    #[serde(default)]
    notes: Option<String>,
    /// Set when saving a remote template that has no local draft yet.
    #[serde(default)]
    resend_id: Option<String>,
}

fn blank_to_none(s: Option<String>) -> Option<String> {
    s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty())
}

fn api_save(state: &AppState, key: &str, body: &str) -> Reply {
    let input: EditableInput = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return err_json(400, e),
    };
    if input.name.trim().is_empty() {
        return err_json(400, "name is required");
    }
    let existing = match state.store.get(key) {
        Ok(d) => d,
        Err(e) => return err_json(500, e),
    };
    let mut d = existing.unwrap_or_else(|| Draft {
        key: key.to_string(),
        resend_id: blank_to_none(input.resend_id.clone()),
        name: String::new(),
        alias: None,
        subject: String::new(),
        from: None,
        reply_to: None,
        html: String::new(),
        variables: vec![],
        source: None,
        notes: None,
        uploaded_at: None,
        uploaded_snapshot: None,
        updated_at: None,
    });
    let first_save_of_remote = d.updated_at.is_none() && d.resend_id.is_some();
    d.name = input.name.trim().to_string();
    d.alias = blank_to_none(input.alias);
    d.subject = input.subject;
    d.from = blank_to_none(input.from);
    d.reply_to = blank_to_none(input.reply_to);
    d.html = input.html;
    d.variables = input.variables;
    d.notes = blank_to_none(input.notes);
    if first_save_of_remote && d.uploaded_snapshot.is_none() {
        // Opening a remote template and saving it unchanged shouldn't read as
        // "local edits": compare against what Resend holds right now.
        if let Some(rs) = state.resend() {
            if let Ok(Some(r)) = rs.get_template(d.resend_id.as_deref().unwrap()) {
                d.uploaded_snapshot = draft_from_remote(&r).uploaded_snapshot;
            }
        }
    }
    if let Err(e) = state.store.save(&d) {
        return err_json(500, e);
    }
    let lint = convert::lint(&d.html, &d.subject, &d.variables, d.alias.as_deref());
    ok(json!({"ok": true, "key": d.key, "dirty": d.is_dirty(), "lint": lint}))
}

fn api_create(state: &AppState, body: &str) -> Reply {
    let v: Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return err_json(400, e),
    };
    let Some(name) = str_field(&v, "name") else { return err_json(400, "name is required") };
    let alias = str_field(&v, "alias").unwrap_or_else(|| slugify(&name));
    if alias.is_empty() {
        return err_json(400, "alias is required");
    }
    match state.store.get(&alias) {
        Ok(Some(_)) => return err_json(409, format!("a local draft named {alias} already exists")),
        Ok(None) => {}
        Err(e) => return err_json(500, e),
    }
    let html = str_field(&v, "html").unwrap_or_else(|| STARTER_HTML.to_string());
    let subject = str_field(&v, "subject").unwrap_or_default();
    let variables = convert::used_variables(&format!("{subject}\n{html}"))
        .into_iter()
        .map(|k| VariableSpec { key: k, var_type: "string".into(), fallback: None, sample: Value::String(String::new()), description: None })
        .collect();
    let d = Draft {
        key: alias.clone(),
        resend_id: None,
        name,
        alias: Some(alias.clone()),
        subject,
        from: Some(state.settings.default_from()).filter(|s| !s.is_empty()),
        reply_to: Some(state.settings.default_reply_to()).filter(|s| !s.is_empty()),
        html,
        variables,
        source: None,
        notes: None,
        uploaded_at: None,
        uploaded_snapshot: None,
        updated_at: None,
    };
    match state.store.save(&d) {
        Ok(()) => ok(json!({"ok": true, "key": alias})),
        Err(e) => err_json(500, e),
    }
}

fn api_discard(state: &AppState, key: &str) -> Reply {
    match state.store.delete(key) {
        Ok(true) => ok(json!({"ok": true})),
        Ok(false) => err_json(404, format!("no local draft named {key}")),
        Err(e) => err_json(500, e),
    }
}

fn api_upload(state: &AppState, key: &str, body: &str) -> Reply {
    let Some(rs) = state.resend() else { return err_json(501, NOT_CONFIGURED) };
    let force = serde_json::from_str::<Value>(body).ok().and_then(|v| v.get("force").and_then(|f| f.as_bool())).unwrap_or(false);
    let mut d = match state.store.get(key) {
        Ok(Some(d)) => d,
        Ok(None) => return err_json(404, format!("no local draft named {key}; save it first")),
        Err(e) => return err_json(500, e),
    };
    let errors: Vec<_> = convert::lint(&d.html, &d.subject, &d.variables, d.alias.as_deref())
        .into_iter()
        .filter(|i| i.level == "error")
        .collect();
    if !errors.is_empty() && !force {
        return err_with(422, "fix the lint errors first (or pass force: true)", json!({"lint": errors}));
    }
    let payload = d.resend_payload();
    let mut created = false;
    let mut linked_existing = false;
    let result = (|| -> anyhow::Result<String> {
        if let Some(id) = &d.resend_id {
            rs.update_template(id, &payload)?;
            return Ok(id.clone());
        }
        if let Some(alias) = &d.alias {
            if let Some(existing) = rs.get_template(alias)? {
                let id = str_field(&existing, "id").unwrap_or_default();
                rs.update_template(&id, &payload)?;
                linked_existing = true;
                return Ok(id);
            }
        }
        created = true;
        rs.create_template(&payload)
    })();
    match result {
        Ok(id) => {
            d.resend_id = Some(id.clone());
            d.uploaded_at = Some(chrono::Utc::now().to_rfc3339());
            d.uploaded_snapshot = Some(payload.to_string());
            if let Err(e) = state.store.save(&d) {
                return err_json(500, format!("uploaded to Resend as {id} but saving the link locally failed: {e}"));
            }
            ok(json!({"ok": true, "resend_id": id, "created": created, "linked_existing": linked_existing, "published": false}))
        }
        Err(e) => err_json(502, e),
    }
}

fn api_publish(state: &AppState, key: &str, body: &str) -> Reply {
    let Some(rs) = state.resend() else { return err_json(501, NOT_CONFIGURED) };
    let confirm = serde_json::from_str::<Value>(body).ok().and_then(|v| str_field(&v, "confirm"));
    let d = match state.store.get(key) {
        Ok(Some(d)) => d,
        Ok(None) => return err_json(404, format!("no local draft named {key}")),
        Err(e) => return err_json(500, e),
    };
    let Some(id) = d.resend_id.clone() else {
        return err_json(409, "this template is not on Resend yet; upload it first");
    };
    if d.is_dirty() {
        return err_json(409, "there are local edits Resend hasn't seen; upload first so you publish exactly what you previewed");
    }
    let expected = d.alias.clone().unwrap_or(d.key.clone());
    if confirm.as_deref() != Some(expected.as_str()) {
        return err_with(
            428,
            format!("publishing changes live email; confirm by sending {{\"confirm\": \"{expected}\"}}"),
            json!({"confirm_with": expected}),
        );
    }
    match rs.publish_template(&id) {
        Ok(()) => ok(json!({"ok": true, "resend_id": id, "published": true})),
        Err(e) => err_json(502, e),
    }
}

fn api_send_test(state: &AppState, key: &str, body: &str) -> Reply {
    let Some(rs) = state.resend() else { return err_json(501, NOT_CONFIGURED) };
    let v: Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return err_json(400, e),
    };
    let Some(to) = str_field(&v, "to").filter(|t| t.contains('@')) else {
        return err_json(400, "to must be an email address");
    };
    let mode = str_field(&v, "mode").unwrap_or_else(|| "draft".into());
    let d = match state.store.get(key) {
        Ok(Some(d)) => d,
        Ok(None) => match rs.get_template(key) {
            Ok(Some(r)) => draft_from_remote(&r),
            Ok(None) => return err_json(404, format!("no draft or Resend template named {key}")),
            Err(e) => return err_json(502, e),
        },
        Err(e) => return err_json(500, e),
    };
    let mut values = serde_json::Map::new();
    for spec in &d.variables {
        if !spec.sample.is_null() && spec.sample.as_str() != Some("") {
            values.insert(spec.key.clone(), spec.sample.clone());
        }
    }
    if let Some(Value::Object(o)) = v.get("values") {
        for (k, val) in o {
            values.insert(k.clone(), val.clone());
        }
    }
    let from = d.from.clone().unwrap_or_else(|| state.settings.default_from());
    if from.is_empty() {
        return err_json(400, "no sender: set From on the template, or a default From in Settings (it must be on a domain verified in Resend)");
    }
    let reply_to = d.reply_to.clone().unwrap_or_else(|| state.settings.default_reply_to());

    let mut payload = if mode == "published" {
        let Some(id) = &d.resend_id else { return err_json(409, "not on Resend yet, so there is no published version to send") };
        let declared: Vec<&str> = d.variables.iter().map(|s| s.key.as_str()).collect();
        let vars: serde_json::Map<String, Value> = values.into_iter().filter(|(k, _)| declared.contains(&k.as_str())).collect();
        json!({
            "from": from,
            "to": [to],
            "template": {"id": id, "variables": vars},
        })
    } else {
        let html = convert::substitute(&d.html, &values, &d.variables);
        let subject = convert::substitute(&d.subject, &values, &d.variables);
        let missing = convert::used_variables(&format!("{subject}\n{html}"));
        if !missing.is_empty() {
            return err_with(
                422,
                format!("no sample or fallback for: {} (Resend would reject this send too)", missing.join(", ")),
                json!({"missing": missing}),
            );
        }
        json!({
            "from": from,
            "to": [to],
            "subject": format!("[TEST] {subject}"),
            "html": html,
        })
    };
    if !reply_to.trim().is_empty() {
        payload["reply_to"] = json!(reply_to);
    }
    match rs.send_email(&payload) {
        Ok(email_id) => ok(json!({"ok": true, "email_id": email_id, "mode": mode})),
        Err(e) => err_json(502, e),
    }
}

fn api_lint(body: &str) -> Reply {
    #[derive(Deserialize)]
    struct LintInput {
        #[serde(default)]
        html: String,
        #[serde(default)]
        subject: String,
        #[serde(default)]
        variables: Vec<VariableSpec>,
        #[serde(default)]
        alias: Option<String>,
    }
    match serde_json::from_str::<LintInput>(body) {
        Ok(i) => ok(json!({"lint": convert::lint(&i.html, &i.subject, &i.variables, i.alias.as_deref())})),
        Err(e) => err_json(400, e),
    }
}

fn api_convert(body: &str) -> Reply {
    #[derive(Deserialize)]
    struct ConvertInput {
        html: String,
        #[serde(default)]
        subject: String,
        #[serde(default)]
        modes: BTreeMap<String, Mode>,
    }
    let i: ConvertInput = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return err_json(400, e),
    };
    let analysis = convert::analyze(&i.html, &i.subject);
    match convert::convert(&i.html, &i.subject, &i.modes) {
        Ok(variants) => ok(json!({"analysis": analysis, "variants": variants})),
        Err(e) => err_with(422, e, json!({"analysis": analysis})),
    }
}

fn slugify(s: &str) -> String {
    let mut out = String::new();
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
        } else if !out.ends_with('-') {
            out.push('-');
        }
    }
    out.trim_matches('-').to_string()
}

fn sendgrid_templates(state: &AppState, refresh: bool) -> anyhow::Result<Vec<ActiveTemplate>> {
    let Some(sg) = state.sendgrid() else {
        anyhow::bail!("SendGrid import needs a SendGrid API key in Settings (read-only use; nothing is written to SendGrid)");
    };
    let mut cache = state.sendgrid_cache.lock().unwrap();
    if refresh || cache.is_none() {
        *cache = Some(sg.list_active()?);
    }
    Ok(cache.clone().unwrap_or_default())
}

fn api_sendgrid_list(state: &AppState, refresh: bool) -> Reply {
    let templates = match sendgrid_templates(state, refresh) {
        Ok(t) => t,
        Err(e) => return err_json(501, e),
    };
    let drafts = state.store.all().unwrap_or_default();
    let items: Vec<Value> = templates
        .iter()
        .map(|t| {
            let analysis = convert::analyze(&t.html, &t.subject);
            let imported: Vec<&str> = drafts
                .iter()
                .filter(|d| d.source.as_deref() == Some(&format!("sendgrid:{}", t.id)))
                .map(|d| d.key.as_str())
                .collect();
            json!({
                "id": t.id,
                "name": t.name,
                "subject": t.subject,
                "html": t.html,
                "suggested_alias": slugify(&t.name),
                "analysis": analysis,
                "imported_as": imported,
            })
        })
        .collect();
    ok(json!({"templates": items}))
}

fn api_import(state: &AppState, body: &str) -> Reply {
    #[derive(Deserialize)]
    struct ImportInput {
        sendgrid_id: String,
        #[serde(default)]
        alias: Option<String>,
        #[serde(default)]
        name: Option<String>,
        #[serde(default)]
        modes: BTreeMap<String, Mode>,
        #[serde(default)]
        fallbacks: BTreeMap<String, Value>,
        #[serde(default)]
        samples: BTreeMap<String, Value>,
        #[serde(default)]
        overwrite: bool,
    }
    let i: ImportInput = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(e) => return err_json(400, e),
    };
    let templates = match sendgrid_templates(state, false) {
        Ok(t) => t,
        Err(e) => return err_json(501, e),
    };
    let Some(src) = templates.iter().find(|t| t.id == i.sendgrid_id) else {
        return err_json(404, format!("SendGrid template {} has no active version", i.sendgrid_id));
    };
    let analysis = convert::analyze(&src.html, &src.subject);
    let variants = match convert::convert(&src.html, &src.subject, &i.modes) {
        Ok(v) => v,
        Err(e) => return err_json(422, e),
    };
    let base_alias = blank_to_none(i.alias).unwrap_or_else(|| slugify(&src.name));
    let base_name = blank_to_none(i.name).unwrap_or_else(|| src.name.clone());

    let keys: Vec<String> = variants.iter().map(|v| format!("{base_alias}{}", v.alias_suffix)).collect();
    if !i.overwrite {
        let conflicts: Vec<&String> = keys.iter().filter(|k| state.store.get(k).ok().flatten().is_some()).collect();
        if !conflicts.is_empty() {
            return err_with(409, "local drafts already exist; pass overwrite: true to replace them", json!({"conflicts": conflicts}));
        }
    }

    let old_samples = state
        .sendgrid_studio_db
        .as_deref()
        .map(sendgrid::studio_samples)
        .and_then(|m| m.get(&src.id).cloned())
        .unwrap_or_default();

    let decisions: Vec<String> = analysis
        .conditionals
        .iter()
        .map(|c| {
            let m = i.modes.get(&c.var).copied().unwrap_or(c.suggested);
            format!("{}={}", c.var, match m { Mode::Split => "split", Mode::Keep => "keep", Mode::Drop => "drop" })
        })
        .collect();
    let mut notes = format!("Converted from SendGrid \"{}\" ({}).", src.name, src.id);
    if !decisions.is_empty() {
        notes.push_str(&format!(" Conditionals: {}.", decisions.join(", ")));
    }
    if !analysis.unsupported.is_empty() {
        notes.push_str(&format!(" Needs a manual rewrite: {}.", analysis.unsupported.join(" ")));
    }

    let mut created = Vec::new();
    for (v, key) in variants.iter().zip(keys.iter()) {
        let variables = v
            .variables
            .iter()
            .map(|k| {
                let keep_hint = analysis
                    .conditionals
                    .iter()
                    .find(|c| &c.var == k && i.modes.get(k).copied().unwrap_or(c.suggested) == Mode::Keep)
                    .and_then(|c| c.fallback_hint.clone())
                    .map(Value::String);
                let fallback = i.fallbacks.get(k).cloned().or(keep_hint);
                let sample = i
                    .samples
                    .get(k)
                    .cloned()
                    .or_else(|| old_samples.get(k).cloned())
                    .unwrap_or(Value::String(String::new()));
                VariableSpec { key: k.clone(), var_type: "string".into(), fallback, sample, description: None }
            })
            .collect();
        let existing = state.store.get(key).ok().flatten();
        let d = Draft {
            key: key.clone(),
            resend_id: existing.as_ref().and_then(|e| e.resend_id.clone()),
            name: format!("{base_name}{}", v.name_suffix),
            alias: Some(key.clone()),
            subject: v.subject.clone(),
            from: Some(state.settings.default_from()).filter(|s| !s.is_empty()),
            reply_to: Some(state.settings.default_reply_to()).filter(|s| !s.is_empty()),
            html: v.html.clone(),
            variables,
            source: Some(format!("sendgrid:{}", src.id)),
            notes: Some(notes.clone()),
            uploaded_at: existing.as_ref().and_then(|e| e.uploaded_at.clone()),
            uploaded_snapshot: existing.as_ref().and_then(|e| e.uploaded_snapshot.clone()),
            updated_at: None,
        };
        if let Err(e) = state.store.save(&d) {
            return err_json(500, e);
        }
        created.push(key.clone());
    }
    ok(json!({"ok": true, "created": created, "notes": notes}))
}

pub fn icon_data_url() -> String {
    use base64::Engine;
    format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(ICON_SVG.as_bytes()))
}