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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Mutex;
/// One template variable. `key`, `type`, and `fallback` are what Resend
/// stores; `sample` and `description` are local only (preview data and docs).
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VariableSpec {
pub key: String,
#[serde(rename = "type", default = "default_type")]
pub var_type: String,
#[serde(default)]
pub fallback: Option<Value>,
#[serde(default)]
pub sample: Value,
#[serde(default)]
pub description: Option<String>,
}
fn default_type() -> String {
"string".into()
}
/// A local working copy of a template. Edits live here until they are
/// uploaded to Resend (which only touches Resend's draft) and, separately,
/// published.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Draft {
/// Stable local identity: the alias for anything created here, or the
/// Resend id for a remote template that had no alias when first opened.
pub key: String,
pub resend_id: Option<String>,
pub name: String,
pub alias: Option<String>,
#[serde(default)]
pub subject: String,
pub from: Option<String>,
pub reply_to: Option<String>,
#[serde(default)]
pub html: String,
#[serde(default)]
pub variables: Vec<VariableSpec>,
/// Where it came from, e.g. "sendgrid:d-b6c7...".
pub source: Option<String>,
/// Free-form notes for whoever picks this up next (migration decisions).
pub notes: Option<String>,
pub uploaded_at: Option<String>,
/// The exact payload last uploaded, so the UI can tell whether there are
/// local edits Resend hasn't seen.
pub uploaded_snapshot: Option<String>,
pub updated_at: Option<String>,
}
impl Draft {
/// The body Resend's create/update template endpoints take.
pub fn resend_payload(&self) -> Value {
let variables: Vec<Value> = self
.variables
.iter()
.filter(|v| !v.key.is_empty())
.map(|v| {
let mut o = json!({"key": v.key, "type": v.var_type});
if let Some(fb) = v.fallback.as_ref().filter(|f| !f.is_null()) {
o["fallback_value"] = if v.var_type == "number" {
fb.as_str().and_then(|s| s.trim().parse::<f64>().ok()).map(|n| json!(n)).unwrap_or(fb.clone())
} else {
fb.clone()
};
}
o
})
.collect();
let mut p = json!({
"name": self.name,
"html": self.html,
"subject": self.subject,
"variables": variables,
});
if let Some(a) = self.alias.as_ref().filter(|a| !a.is_empty()) {
p["alias"] = json!(a);
}
if let Some(f) = self.from.as_ref().filter(|f| !f.is_empty()) {
p["from"] = json!(f);
}
if let Some(r) = self.reply_to.as_ref().filter(|r| !r.is_empty()) {
p["reply_to"] = json!(r);
}
p
}
pub fn is_dirty(&self) -> bool {
match &self.uploaded_snapshot {
Some(s) => *s != self.resend_payload().to_string(),
None => true,
}
}
}
pub struct Store {
conn: Mutex<Connection>,
}
const COLS: &str = "key, resend_id, name, alias, subject, from_addr, reply_to, html, variables_json, source, notes, uploaded_at, uploaded_snapshot, updated_at";
fn row_to_draft(r: &rusqlite::Row) -> rusqlite::Result<Draft> {
let vars: String = r.get(8)?;
Ok(Draft {
key: r.get(0)?,
resend_id: r.get(1)?,
name: r.get(2)?,
alias: r.get(3)?,
subject: r.get(4)?,
from: r.get(5)?,
reply_to: r.get(6)?,
html: r.get(7)?,
variables: serde_json::from_str(&vars).unwrap_or_default(),
source: r.get(9)?,
notes: r.get(10)?,
uploaded_at: r.get(11)?,
uploaded_snapshot: r.get(12)?,
updated_at: r.get(13)?,
})
}
impl Store {
pub fn open(path: &str) -> Result<Self> {
let conn = Connection::open(path).with_context(|| format!("opening sqlite db at {path}"))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS drafts (
key TEXT PRIMARY KEY,
resend_id TEXT,
name TEXT NOT NULL,
alias TEXT,
subject TEXT NOT NULL DEFAULT '',
from_addr TEXT,
reply_to TEXT,
html TEXT NOT NULL DEFAULT '',
variables_json TEXT NOT NULL DEFAULT '[]',
source TEXT,
notes TEXT,
uploaded_at TEXT,
uploaded_snapshot TEXT,
updated_at TEXT NOT NULL
);",
)?;
Ok(Self { conn: Mutex::new(conn) })
}
pub fn all(&self) -> Result<Vec<Draft>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!("SELECT {COLS} FROM drafts ORDER BY name"))?;
let rows = stmt.query_map([], row_to_draft)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn get(&self, key: &str) -> Result<Option<Draft>> {
let conn = self.conn.lock().unwrap();
let d = conn
.query_row(&format!("SELECT {COLS} FROM drafts WHERE key = ?1"), params![key], row_to_draft)
.optional()?;
Ok(d)
}
/// Finds the draft for a remote template: by linked id first, then by alias.
pub fn find_for_remote(&self, id: &str, alias: Option<&str>) -> Result<Option<Draft>> {
let all = self.all()?;
if let Some(d) = all.iter().find(|d| d.resend_id.as_deref() == Some(id) || d.key == id) {
return Ok(Some(d.clone()));
}
if let Some(a) = alias.filter(|a| !a.is_empty()) {
if let Some(d) = all.iter().find(|d| d.resend_id.is_none() && (d.alias.as_deref() == Some(a) || d.key == a)) {
return Ok(Some(d.clone()));
}
}
Ok(None)
}
pub fn save(&self, d: &Draft) -> Result<()> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
&format!(
"INSERT INTO drafts ({COLS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(key) DO UPDATE SET
resend_id = excluded.resend_id, name = excluded.name, alias = excluded.alias,
subject = excluded.subject, from_addr = excluded.from_addr, reply_to = excluded.reply_to,
html = excluded.html, variables_json = excluded.variables_json, source = excluded.source,
notes = excluded.notes, uploaded_at = excluded.uploaded_at,
uploaded_snapshot = excluded.uploaded_snapshot, updated_at = excluded.updated_at"
),
params![
d.key,
d.resend_id,
d.name,
d.alias,
d.subject,
d.from,
d.reply_to,
d.html,
serde_json::to_string(&d.variables)?,
d.source,
d.notes,
d.uploaded_at,
d.uploaded_snapshot,
now
],
)?;
Ok(())
}
pub fn delete(&self, key: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute("DELETE FROM drafts WHERE key = ?1", params![key])? > 0)
}
}