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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
use std::sync::Mutex;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
const API_BASE: &str = "https://api.resend.com";
/// Resend's rate limit is per team and shared with every other service that
/// sends through the same account. Space our calls out so a template-list
/// refresh can never starve a real send.
const MIN_GAP: Duration = Duration::from_millis(550);
pub struct ResendClient {
api_key: String,
last_call: Mutex<Option<Instant>>,
}
impl ResendClient {
pub fn new(api_key: String) -> Self {
Self { api_key, last_call: Mutex::new(None) }
}
fn throttle(&self) {
let mut last = self.last_call.lock().unwrap();
if let Some(t) = *last {
let since = t.elapsed();
if since < MIN_GAP {
std::thread::sleep(MIN_GAP - since);
}
}
*last = Some(Instant::now());
}
fn req(&self, method: &str, path: &str) -> ureq::Request {
self.throttle();
ureq::request(method, &format!("{API_BASE}{path}"))
.set("Authorization", &format!("Bearer {}", self.api_key))
.set("User-Agent", concat!("adom-resend-studio/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(20))
}
/// What this key may do. Resend's "Sending access" keys can only send
/// email; managing templates needs a "Full access" key.
pub fn check_access(&self) -> (&'static str, Option<String>) {
match self.req("GET", "/templates?limit=1").call() {
Ok(_) => ("full", None),
Err(ureq::Error::Status(401, resp)) => {
let body = resp.into_string().unwrap_or_default();
if body.contains("restricted") {
("sending", None)
} else {
("invalid", Some(map_body(401, &body)))
}
}
Err(ureq::Error::Status(403, resp)) => ("invalid", Some(map_body(403, &resp.into_string().unwrap_or_default()))),
Err(e) => ("unknown", Some(map_ureq_err(e).to_string())),
}
}
/// Every template in the account, following the cursor until has_more is false.
pub fn list_templates(&self) -> Result<Vec<Value>> {
let mut out = Vec::new();
let mut after: Option<String> = None;
loop {
let path = match &after {
Some(a) => format!("/templates?limit=100&after={a}"),
None => "/templates?limit=100".to_string(),
};
let body: Value = self
.req("GET", &path)
.call()
.map_err(map_ureq_err)?
.into_json()
.context("parsing template list")?;
let page = body.get("data").and_then(|d| d.as_array()).cloned().unwrap_or_default();
let last_id = page.last().and_then(|t| t.get("id")).and_then(|v| v.as_str()).map(String::from);
out.extend(page);
let has_more = body.get("has_more").and_then(|v| v.as_bool()).unwrap_or(false);
match (has_more, last_id) {
(true, Some(id)) => after = Some(id),
_ => break,
}
}
Ok(out)
}
/// Looks a template up by id or alias. Ok(None) on 404.
pub fn get_template(&self, id_or_alias: &str) -> Result<Option<Value>> {
match self.req("GET", &format!("/templates/{id_or_alias}")).call() {
Ok(resp) => Ok(Some(resp.into_json().context("parsing template")?)),
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(e) => Err(map_ureq_err(e)),
}
}
/// Creates a template. New templates start as drafts on Resend's side.
pub fn create_template(&self, payload: &Value) -> Result<String> {
let body: Value = self
.req("POST", "/templates")
.send_json(payload.clone())
.map_err(map_ureq_err)?
.into_json()
.context("parsing create-template response")?;
body.get("id")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow!("Resend response missing template id: {body}"))
}
/// Updates a template's working draft. The published version, which is
/// what real sends use, is untouched until `publish_template`.
pub fn update_template(&self, id: &str, payload: &Value) -> Result<()> {
self.req("PATCH", &format!("/templates/{id}"))
.send_json(payload.clone())
.map_err(map_ureq_err)?;
Ok(())
}
pub fn publish_template(&self, id: &str) -> Result<()> {
self.req("POST", &format!("/templates/{id}/publish"))
.send_json(json!({}))
.map_err(map_ureq_err)?;
Ok(())
}
/// Sends one email. Returns Resend's email id.
pub fn send_email(&self, payload: &Value) -> Result<String> {
let body: Value = self
.req("POST", "/emails")
.send_json(payload.clone())
.map_err(map_ureq_err)?
.into_json()
.context("parsing send response")?;
Ok(body.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string())
}
}
fn map_body(code: u16, body: &str) -> String {
let msg = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or_else(|| body.to_string());
format!("Resend API returned HTTP {code}: {msg}")
}
fn map_ureq_err(err: ureq::Error) -> anyhow::Error {
match err {
ureq::Error::Status(code, resp) => anyhow!(map_body(code, &resp.into_string().unwrap_or_default())),
ureq::Error::Transport(t) => anyhow!("Resend API request failed: {t}"),
}
}