app
Resend Studio
Public Made by Adomby adom
A local editor for Resend email templates: paste your API key, then edit, preview, lint, test-send, and publish templates without Resend's dashboard. Also imports SendGrid dynamic templates and converts their Handlebars to Resend's variable syntax.
main
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()))
}