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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
//! Local settings: API keys and sender defaults.
//!
//! Stored in `<data dir>/settings.json`, readable only by the owner. Keys are
//! never returned to the browser (only a masked form) and are only ever sent
//! to their own provider's API. An environment variable, when set, wins over
//! the stored value so the studio can also be configured non-interactively.
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct Stored {
#[serde(default, skip_serializing_if = "Option::is_none")]
resend_api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sendgrid_api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_test_to: Option<String>,
}
/// A partial update from the settings form. `Some("")` clears a field.
#[derive(Debug, Deserialize, Default)]
pub struct Patch {
pub resend_api_key: Option<String>,
pub sendgrid_api_key: Option<String>,
pub default_from: Option<String>,
pub default_reply_to: Option<String>,
pub default_test_to: Option<String>,
}
pub struct Settings {
file: PathBuf,
stored: RwLock<Stored>,
}
/// Where the studio keeps its database and settings: `RESEND_STUDIO_HOME`, else
/// the platform data directory.
pub fn data_dir() -> PathBuf {
if let Some(dir) = env_nonempty("RESEND_STUDIO_HOME") {
return PathBuf::from(dir);
}
let base = env_nonempty("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| env_nonempty("APPDATA").map(PathBuf::from))
.or_else(|| env_nonempty("HOME").map(|h| Path::new(&h).join(".local").join("share")))
.unwrap_or_else(|| PathBuf::from("."));
base.join("adom-resend-studio")
}
pub fn env_nonempty(k: &str) -> Option<String> {
std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
/// `re_AbC...wxyz`: enough to recognize which key it is, not enough to use it.
pub fn mask(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 10 {
return "*".repeat(chars.len());
}
let head: String = chars[..5].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{head}...{tail}")
}
/// Where a value came from, for the settings panel.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Env,
Saved,
None,
}
impl Settings {
pub fn load(dir: &Path) -> Result<Self> {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let file = dir.join("settings.json");
let stored = match std::fs::read_to_string(&file) {
Ok(s) => serde_json::from_str(&s).with_context(|| format!("parsing {}", file.display()))?,
Err(_) => Stored::default(),
};
Ok(Self { file, stored: RwLock::new(stored) })
}
pub fn path(&self) -> &Path {
&self.file
}
fn pick(&self, env: &str, get: impl Fn(&Stored) -> Option<String>) -> (Option<String>, Source) {
if let Some(v) = env_nonempty(env) {
return (Some(v), Source::Env);
}
match get(&self.stored.read().unwrap()).filter(|v| !v.trim().is_empty()) {
Some(v) => (Some(v), Source::Saved),
None => (None, Source::None),
}
}
pub fn resend_key(&self) -> (Option<String>, Source) {
self.pick("RESEND_API_KEY", |s| s.resend_api_key.clone())
}
pub fn sendgrid_key(&self) -> (Option<String>, Source) {
self.pick("SENDGRID_API_KEY", |s| s.sendgrid_api_key.clone())
}
pub fn default_from(&self) -> String {
self.pick("RESEND_STUDIO_FROM", |s| s.default_from.clone()).0.unwrap_or_default()
}
pub fn default_reply_to(&self) -> String {
self.pick("RESEND_STUDIO_REPLY_TO", |s| s.default_reply_to.clone()).0.unwrap_or_default()
}
pub fn default_test_to(&self) -> String {
self.pick("RESEND_STUDIO_TEST_TO", |s| s.default_test_to.clone()).0.unwrap_or_default()
}
pub fn apply(&self, patch: Patch) -> Result<()> {
let mut next = self.stored.read().unwrap().clone();
let set = |field: &mut Option<String>, value: Option<String>| {
if let Some(v) = value {
let v = v.trim().to_string();
*field = if v.is_empty() { None } else { Some(v) };
}
};
set(&mut next.resend_api_key, patch.resend_api_key);
set(&mut next.sendgrid_api_key, patch.sendgrid_api_key);
set(&mut next.default_from, patch.default_from);
set(&mut next.default_reply_to, patch.default_reply_to);
set(&mut next.default_test_to, patch.default_test_to);
write_private(&self.file, &serde_json::to_string_pretty(&next)?)?;
*self.stored.write().unwrap() = next;
Ok(())
}
}
/// Writes via a temp file + rename, created owner-only on unix.
fn write_private(path: &Path, contents: &str) -> Result<()> {
use std::io::Write;
let tmp = path.with_extension("json.tmp");
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn masks_keys() {
assert_eq!(mask("re_abcdefghijklmnop"), "re_ab...mnop");
assert_eq!(mask("short"), "*****");
}
#[test]
fn saves_and_clears() {
let dir = std::env::temp_dir().join(format!("resend-studio-test-{}", std::process::id()));
let s = Settings::load(&dir).unwrap();
s.apply(Patch { default_from: Some("A <[email protected]>".into()), ..Default::default() }).unwrap();
let again = Settings::load(&dir).unwrap();
assert_eq!(again.stored.read().unwrap().default_from.as_deref(), Some("A <[email protected]>"));
again.apply(Patch { default_from: Some(" ".into()), ..Default::default() }).unwrap();
assert!(again.stored.read().unwrap().default_from.is_none());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(again.path()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
let _ = std::fs::remove_dir_all(&dir);
}
}