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.
← Commit history
Publish 0.1.0
25 files changed
+5047
.gitignore+4Cargo.lock+1253Cargo.toml+20SKILL.md+85VERSION+1docs/hero.pngdocs/icon.svg+4docs/screenshots/editor.pngdocs/screenshots/lint.pngdocs/screenshots/settings.pnginstall.sh+20package.json+56src/cli.rs+30src/convert.rs+534src/main.rs+93src/output.rs+60src/resend.rs+152src/sendgrid.rs+101src/server.rs+995src/settings.rs+191src/store.rs+217ui/app.js+786ui/index.html+168ui/style.css+269uninstall.sh+8.gitignoreadded+4@@ -0,0 +1,4 @@+/target+*.db+*.db-journal+.env
Cargo.lockadded+1253@@ -0,0 +1,1253 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-resend-studio"+version = "0.1.0"+dependencies = [+ "anyhow",+ "base64",+ "chrono",+ "clap",+ "regex",+ "rusqlite",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]++[[package]]+name = "ahash"+version = "0.8.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"+dependencies = [+ "cfg-if",+ "once_cell",+ "version_check",+ "zerocopy",+]++[[package]]+name = "aho-corasick"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"+dependencies = [+ "memchr",+]++[[package]]+name = "android_system_properties"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"+dependencies = [+ "libc",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "anyhow"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "autocfg"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "bitflags"+version = "2.13.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"++[[package]]+name = "cc"+version = "1.2.65"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chrono"+version = "0.4.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"+dependencies = [+ "iana-time-zone",+ "js-sys",+ "num-traits",+ "serde",+ "wasm-bindgen",+ "windows-link",+]++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "core-foundation-sys"+version = "0.8.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "displaydoc"+version = "0.2.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "equivalent"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"++[[package]]+name = "fallible-iterator"+version = "0.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"++[[package]]+name = "fallible-streaming-iterator"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "hashbrown"+version = "0.14.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"+dependencies = [+ "ahash",+]++[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"++[[package]]+name = "hashlink"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"+dependencies = [+ "hashbrown 0.14.5",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "iana-time-zone"+version = "0.1.65"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"+dependencies = [+ "android_system_properties",+ "core-foundation-sys",+ "iana-time-zone-haiku",+ "js-sys",+ "log",+ "wasm-bindgen",+ "windows-core",+]++[[package]]+name = "iana-time-zone-haiku"+version = "0.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"+dependencies = [+ "cc",+]++[[package]]+name = "icu_collections"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"++[[package]]+name = "icu_properties"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"+dependencies = [+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"++[[package]]+name = "icu_provider"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "indexmap"+version = "2.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"+dependencies = [+ "equivalent",+ "hashbrown 0.17.1",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]++[[package]]+name = "libc"+version = "0.2.186"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"++[[package]]+name = "libsqlite3-sys"+version = "0.28.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"+dependencies = [+ "cc",+ "pkg-config",+ "vcpkg",+]++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[package]]+name = "log"+version = "0.4.33"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"++[[package]]+name = "memchr"+version = "2.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "pkg-config"+version = "0.3.33"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.46"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "regex"+version = "1.13.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-automata",+ "regex-syntax",+]++[[package]]+name = "regex-automata"+version = "0.4.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-syntax",+]++[[package]]+name = "regex-syntax"+version = "0.8.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rusqlite"+version = "0.31.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"+dependencies = [+ "bitflags",+ "fallible-iterator",+ "fallible-streaming-iterator",+ "hashlink",+ "libsqlite3-sys",+ "smallvec",+]++[[package]]+name = "rustls"+version = "0.23.41"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.15.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.13"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.150"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"+dependencies = [+ "indexmap",+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "smallvec"+version = "1.15.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.118"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "vcpkg"+version = "0.2.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "wasm-bindgen"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.8",+]++[[package]]+name = "webpki-roots"+version = "1.0.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-core"+version = "0.62.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"+dependencies = [+ "windows-implement",+ "windows-interface",+ "windows-link",+ "windows-result",+ "windows-strings",+]++[[package]]+name = "windows-implement"+version = "0.60.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "windows-interface"+version = "0.59.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-result"+version = "0.4.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-strings"+version = "0.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"++[[package]]+name = "yoke"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zerocopy"+version = "0.8.52"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"+dependencies = [+ "zerocopy-derive",+]++[[package]]+name = "zerocopy-derive"+version = "0.8.52"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zerofrom"+version = "0.1.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"++[[package]]+name = "zerotrie"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+20@@ -0,0 +1,20 @@+[package]+name = "adom-resend-studio"+version = "0.1.0"+edition = "2024"++[[bin]]+name = "adom-resend-studio"+path = "src/main.rs"++[dependencies]+anyhow = "1.0.100"+base64 = "0.22.1"+chrono = { version = "0.4.41", features = ["serde"] }+clap = { version = "4.5", features = ["derive"] }+regex = "1.11"+rusqlite = { version = "0.31", features = ["bundled"] }+serde = { version = "1.0.219", features = ["derive"] }+serde_json = { version = "1.0.149", features = ["preserve_order"] }+tiny_http = "0.12"+ureq = { version = "2.10", features = ["json"] }
SKILL.mdadded+85@@ -0,0 +1,85 @@+---+name: adom-resend-studio+description: Local server + Hydrogen webview for authoring Resend email templates (edit, live preview, lint, test send, upload, publish), plus a read-only SendGrid importer that converts Handlebars templates to Resend's {{{VAR}}} syntax. Use when editing a Resend template, migrating SendGrid templates to Resend, sending a test email, or publishing a template. Triggers - resend template, resend studio, email template editor, migrate sendgrid to resend, publish resend template, test email.+---++# adom-resend-studio++Edit Resend templates without Resend's dashboard. Three layers, on purpose:++1. **Save** writes a local draft (SQLite). Resend is untouched.+2. **Upload** sends the draft to Resend. It creates the template, or updates+ Resend's DRAFT of it. Live email does not change.+3. **Publish** makes Resend's draft the version real sends use. Needs a typed+ confirmation (the alias). Never publish unless the user said to.++## Run++```bash+adom-resend-studio serve --port 8894 --open-tab # --open-tab: Hydrogen tab; otherwise open http://127.0.0.1:8894+adom-resend-studio health --port 8894+```++## Configure++The user pastes their own Resend API key in the studio's Settings (gear icon).+Never ask them to paste a key into chat; point them at Settings. Keys live in+`~/.local/share/adom-resend-studio/settings.json` (owner-only) and are never+returned by the API, only masked. A Resend key is either Full access (templates+work) or Sending access (test sends only); `GET /state` reports `resend_access`+as `full`, `sending`, `invalid`, `unknown`, or `none`.++Env vars win over Settings: `RESEND_API_KEY`, `SENDGRID_API_KEY`,+`RESEND_STUDIO_FROM`, `RESEND_STUDIO_REPLY_TO`, `RESEND_STUDIO_TEST_TO`,+`RESEND_STUDIO_HOME` (data folder), `RESEND_STUDIO_DB`,+`RESEND_STUDIO_ALLOWED_HOSTS` (extra hostnames allowed to reach the server),+`SENDGRID_STUDIO_DB` (carry sample data over from adom-sendgrid-studio).++Every write request must be `Content-Type: application/json`, and the Host must+be 127.0.0.1/localhost (or an allowed host); anything else gets a 403.++## Resend template rules (the lint enforces these)++- Variables are `{{{KEY}}}` only. No `{{#if}}`, loops, or helpers.+- Every variable used must be declared; up to 50; type `string` or `number`.+- No fallback means required: Resend rejects a send that leaves it out.+- Reserved keys: FIRST_NAME, LAST_NAME, EMAIL, UNSUBSCRIBE_URL, RESEND_UNSUBSCRIBE_URL, contact, this.+- Only PUBLISHED templates can be sent by id/alias. The studio's "this draft"+ test mode renders locally and sends plain HTML, so drafts can be tested first.+- Resend does NOT HTML-escape variables, and puts the same values into the+ subject. Senders should escape user-supplied values and pass their own+ `subject` built from the raw values.+- An empty string satisfies a required variable; a missing one is a 422.+ Extra, undeclared variables are ignored.+- `GET /templates/{id}` returns the PUBLISHED content, not Resend's draft.++## SendGrid import++Each `{{#if x}}` block gets a mode: `split` (two templates, `<alias>-with-x` and+`<alias>-no-x`; the sender picks), `keep` (always render; a fallback covers a+missing value), or `drop` (never render; `{{else}}` content stays).+`{{#if x}}{{x}}{{else}}TEXT{{/if}}` is detected and suggested as keep with+fallback TEXT.++## HTTP API (for agents)++| Method | Path | Body / notes |+|--------|------|-------------|+| GET/POST | `/api/settings` | masked keys + defaults; POST `{resend_api_key?, sendgrid_api_key?, default_from?, default_reply_to?, default_test_to?}` (`""` clears) |+| GET | `/state` | connection + key access summary |+| GET | `/api/templates` | merged list: Resend templates + local drafts |+| POST | `/api/templates` | `{name, alias}` new local draft |+| GET | `/api/templates/{key}` | `{editable, remote, lint, dirty, has_draft}` |+| POST | `/api/templates/{key}/draft` | `{name, alias, subject, from, reply_to, html, variables, notes}` save locally |+| DELETE | `/api/templates/{key}/draft` | discard the local draft |+| POST | `/api/templates/{key}/upload` | `{force?}`; refuses on lint errors unless force |+| POST | `/api/templates/{key}/publish` | `{confirm: "<alias>"}`; refuses if local edits aren't uploaded |+| POST | `/api/templates/{key}/send-test` | `{to, mode: "draft"\|"published", values?}` |+| POST | `/api/lint` | `{html, subject, variables, alias}` |+| POST | `/api/convert` | `{html, subject, modes}` preview a conversion |+| GET | `/api/sendgrid/templates` | SendGrid active versions + detected conditionals |+| POST | `/api/import/sendgrid` | `{sendgrid_id, alias?, modes?, fallbacks?, samples?, overwrite?}` |+| GET | `/console` | UI console log |++A variable is `{key, type, fallback, sample, description}`; `sample` and+`description` stay local.
VERSIONadded+1@@ -0,0 +1 @@+0.1.0
docs/hero.pngadded⋯ 1 unchanged line ⋯
docs/icon.svgadded+4@@ -0,0 +1,4 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">+ <path fill="#00b8b0" d="M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5v-11Zm2.2-.5 6.8 5.3L18.8 6H5.2ZM19 7.7l-6.39 4.98a1 1 0 0 1-1.22 0L5 7.7v9.8c0 .28.22.5.5.5h13a.5.5 0 0 0 .5-.5V7.7Z"/>+ <path fill="#e6edf3" d="M15.5 14h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>+</svg>
docs/screenshots/editor.pngadded⋯ 1 unchanged line ⋯
docs/screenshots/lint.pngadded⋯ 1 unchanged line ⋯
docs/screenshots/settings.pngadded⋯ 1 unchanged line ⋯
install.shadded+20@@ -0,0 +1,20 @@+#!/bin/sh+set -e++BIN_DIR="$HOME/.local/bin"+SKILL_DIR="$HOME/.claude/skills/adom-resend-studio"++command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo is required to build adom-resend-studio." >&2; exit 1; }++echo "Building adom-resend-studio (release)..."+cargo build --release --manifest-path "$PWD/Cargo.toml"++mkdir -p "$BIN_DIR"+# Symlinked so a later pull + rebuild in this checkout is picked up.+ln -sfn "$PWD/target/release/adom-resend-studio" "$BIN_DIR/adom-resend-studio"++mkdir -p "$SKILL_DIR"+cp "$PWD/SKILL.md" "$SKILL_DIR/SKILL.md"++echo "Run: adom-resend-studio serve (then add your Resend API key in Settings)"+echo "installed"
package.jsonadded+56@@ -0,0 +1,56 @@+{+ "slug": "adom-resend-studio",+ "title": "Resend Studio",+ "type": "app",+ "version": "0.1.0",+ "description": "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.",+ "tags": [+ "cli",+ "resend",+ "email",+ "templates",+ "sendgrid"+ ],+ "dependencies": {},+ "discovery_triggers": [+ "resend template",+ "resend email template",+ "resend studio",+ "email template editor",+ "migrate sendgrid to resend",+ "publish resend template",+ "send test email",+ "preview email template"+ ],+ "discovery_pitch": "Edit Resend email templates locally with a live preview and lint, test-send drafts, and publish on purpose. Bring your own Resend API key; imports SendGrid templates too.",+ "sample_prompts": [+ {+ "label": "Install",+ "prompt": "install the adom-resend-studio app"+ },+ {+ "label": "Open it",+ "prompt": "open resend studio so I can edit my email templates"+ },+ {+ "label": "Migrate",+ "prompt": "import my SendGrid templates into Resend Studio"+ }+ ],+ "metadata": {+ "activate": {+ "kind": "bridge",+ "skill": "adom-resend-studio",+ "requires": [],+ "verbPrefixes": []+ }+ },+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "hero": {+ "type": "image",+ "path": "docs/hero.png"+ }+}
src/cli.rsadded+30@@ -0,0 +1,30 @@+use clap::{Parser, Subcommand};++#[derive(Parser)]+#[command(name = "adom-resend-studio", version, about = "Author, preview, and publish Resend email templates")]+pub struct Cli {+ #[command(subcommand)]+ pub command: Command,+}++#[derive(Subcommand)]+pub enum Command {+ /// Start the local server + webview UI.+ Serve {+ #[arg(long, default_value_t = 8894)]+ port: u16,+ /// Enables dev-only behavior. Never set this in production.+ #[arg(long)]+ dev: bool,+ /// Also open the app in a Hydrogen webview tab via adom-cli.+ #[arg(long)]+ open_tab: bool,+ },+ /// Check whether a running instance is reachable.+ Health {+ #[arg(long, default_value_t = 8894)]+ port: u16,+ },+ /// One-time local setup check.+ Install,+}
src/convert.rsadded+534@@ -0,0 +1,534 @@+//! SendGrid (Handlebars) -> Resend template conversion, plus the lint that+//! checks a template against what Resend actually supports.+//!+//! Resend templates only substitute variables: `{{{KEY}}}`. There are no+//! conditionals, loops, or helpers. So every `{{#if x}}` block in a SendGrid+//! template has to be decided ahead of time, one of three ways:+//!+//! - `split`: emit two templates, one with the block and one without, e.g.+//! invitation-with-notes and invitation-no-notes; the sender picks.+//! - `keep`: always render the block; a missing value is covered by the+//! variable's fallback instead.+//! - `drop`: never render the block (its `{{else}}` branch, if any, stays).++use std::collections::{BTreeMap, HashMap};++use regex::Regex;+use serde::{Deserialize, Serialize};+use serde_json::Value;++use crate::store::VariableSpec;++pub const RESERVED_KEYS: &[&str] = &[+ "FIRST_NAME",+ "LAST_NAME",+ "EMAIL",+ "UNSUBSCRIBE_URL",+ "RESEND_UNSUBSCRIBE_URL",+ "contact",+ "this",+];+pub const MAX_VARIABLES: usize = 50;+const MAX_SPLIT_VARS: usize = 4;++#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "lowercase")]+pub enum Mode {+ Split,+ Keep,+ Drop,+}++#[derive(Debug)]+enum Node {+ Text(String),+ Var(String),+ If { var: String, negate: bool, then: Vec<Node>, els: Vec<Node> },+}++/// A conditional found in the source, with the mode we'd suggest for it.+#[derive(Debug, Serialize, Clone)]+pub struct Conditional {+ pub var: String,+ pub suggested: Mode,+ /// When the block is `{{#if x}}{{x}}{{else}}TEXT{{/if}}`, TEXT is the+ /// natural Resend fallback for `x`.+ pub fallback_hint: Option<String>,+}++#[derive(Debug, Serialize, Clone)]+pub struct Variant {+ /// Alias suffix, e.g. "-with-notes". Empty when nothing was split.+ pub alias_suffix: String,+ /// Name suffix, e.g. " (with notes)".+ pub name_suffix: String,+ pub html: String,+ pub subject: String,+ /// Variable keys the converted HTML + subject reference, in first-use order.+ pub variables: Vec<String>,+}++#[derive(Debug, Serialize, Clone)]+pub struct Analysis {+ pub conditionals: Vec<Conditional>,+ /// Handlebars we can't express in Resend at all (each/with/helpers).+ /// Left in the output verbatim and flagged so a human rewrites them.+ pub unsupported: Vec<String>,+}++struct Parsed {+ nodes: Vec<Node>,+ unsupported: Vec<String>,+}++fn is_path(s: &str) -> bool {+ !s.is_empty()+ && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')+ && !s.starts_with('.')+}++fn parse(src: &str) -> Parsed {+ // Each frame: (var, negate, then, els, in_else). The root frame has var "".+ struct Frame {+ var: String,+ negate: bool,+ then: Vec<Node>,+ els: Vec<Node>,+ in_else: bool,+ }+ let mut stack = vec![Frame { var: String::new(), negate: false, then: vec![], els: vec![], in_else: false }];+ let mut unsupported = Vec::new();+ // Non-if blocks (each/with/...) we pass through verbatim; track their+ // names so their `{{/name}}` isn't mistaken for the end of an if.+ let mut opaque_blocks: Vec<String> = Vec::new();++ fn push(stack: &mut [Frame], node: Node) {+ let top = stack.last_mut().unwrap();+ if top.in_else { top.els.push(node) } else { top.then.push(node) }+ }++ let mut rest = src;+ while let Some(start) = rest.find("{{") {+ if start > 0 {+ push(&mut stack, Node::Text(rest[..start].to_string()));+ }+ let after = &rest[start..];+ let triple = after.starts_with("{{{");+ let close = if triple { "}}}" } else { "}}" };+ let open_len = if triple { 3 } else { 2 };+ let Some(end_rel) = after[open_len..].find(close) else {+ push(&mut stack, Node::Text(after.to_string()));+ rest = "";+ break;+ };+ let raw_tag = &after[..open_len + end_rel + close.len()];+ let inner = after[open_len..open_len + end_rel].trim().trim_matches('~').trim();+ rest = &after[raw_tag.len()..];++ if triple {+ if is_path(inner) {+ push(&mut stack, Node::Var(inner.to_string()));+ } else {+ unsupported.push(raw_tag.to_string());+ push(&mut stack, Node::Text(raw_tag.to_string()));+ }+ continue;+ }+ if inner.starts_with('!') {+ continue; // Handlebars comment+ }+ if let Some(block) = inner.strip_prefix('#') {+ let mut parts = block.split_whitespace();+ let kind = parts.next().unwrap_or("");+ let arg = parts.next().unwrap_or("");+ if (kind == "if" || kind == "unless") && is_path(arg) && parts.next().is_none() {+ stack.push(Frame { var: arg.to_string(), negate: kind == "unless", then: vec![], els: vec![], in_else: false });+ } else {+ unsupported.push(raw_tag.to_string());+ opaque_blocks.push(kind.to_string());+ push(&mut stack, Node::Text(raw_tag.to_string()));+ }+ continue;+ }+ if let Some(kind) = inner.strip_prefix('/') {+ let kind = kind.trim();+ if (kind == "if" || kind == "unless") && stack.len() > 1 {+ let f = stack.pop().unwrap();+ push(&mut stack, Node::If { var: f.var, negate: f.negate, then: f.then, els: f.els });+ } else {+ if opaque_blocks.last().map(|b| b == kind).unwrap_or(false) {+ opaque_blocks.pop();+ }+ push(&mut stack, Node::Text(raw_tag.to_string()));+ }+ continue;+ }+ if inner == "else" || inner == "^" {+ if stack.len() > 1 && opaque_blocks.is_empty() {+ stack.last_mut().unwrap().in_else = true;+ } else {+ push(&mut stack, Node::Text(raw_tag.to_string()));+ }+ continue;+ }+ if is_path(inner) {+ push(&mut stack, Node::Var(inner.to_string()));+ } else {+ unsupported.push(raw_tag.to_string());+ push(&mut stack, Node::Text(raw_tag.to_string()));+ }+ }+ if !rest.is_empty() {+ push(&mut stack, Node::Text(rest.to_string()));+ }+ // Unclosed ifs: fold them back in so no content is lost.+ while stack.len() > 1 {+ let f = stack.pop().unwrap();+ unsupported.push(format!("unclosed {{{{#if {}}}}}", f.var));+ push(&mut stack, Node::If { var: f.var, negate: f.negate, then: f.then, els: f.els });+ }+ let root = stack.pop().unwrap();+ Parsed { nodes: root.then, unsupported }+}++fn collect_conditionals(nodes: &[Node], out: &mut Vec<Conditional>) {+ for n in nodes {+ if let Node::If { var, negate, then, els } = n {+ let hint = match (then.as_slice(), els.as_slice()) {+ ([Node::Var(v)], [Node::Text(t)]) if v == var && !negate && !t.contains('<') => Some(t.clone()),+ _ => None,+ };+ if let Some(existing) = out.iter_mut().find(|c| &c.var == var) {+ if existing.fallback_hint.is_none() {+ existing.fallback_hint = hint.clone();+ }+ if hint.is_none() {+ existing.suggested = Mode::Split;+ }+ } else {+ let suggested = if hint.is_some() { Mode::Keep } else { Mode::Split };+ out.push(Conditional { var: var.clone(), suggested, fallback_hint: hint });+ }+ collect_conditionals(then, out);+ collect_conditionals(els, out);+ }+ }+}++fn render(nodes: &[Node], truthy: &HashMap<String, bool>, out: &mut String) {+ for n in nodes {+ match n {+ Node::Text(t) => out.push_str(t),+ Node::Var(v) => {+ out.push_str("{{{");+ out.push_str(v);+ out.push_str("}}}");+ }+ Node::If { var, negate, then, els } => {+ let on = truthy.get(var).copied().unwrap_or(true) != *negate;+ render(if on { then } else { els }, truthy, out);+ }+ }+ }+}++pub fn analyze(html: &str, subject: &str) -> Analysis {+ let mut conditionals = Vec::new();+ let mut unsupported = Vec::new();+ for src in [subject, html] {+ let p = parse(src);+ collect_conditionals(&p.nodes, &mut conditionals);+ unsupported.extend(p.unsupported);+ }+ Analysis { conditionals, unsupported }+}++/// Converts one SendGrid template into one or more Resend-ready variants.+/// Conditionals not named in `modes` use their suggested mode.+pub fn convert(html: &str, subject: &str, modes: &BTreeMap<String, Mode>) -> anyhow::Result<Vec<Variant>> {+ let analysis = analyze(html, subject);+ let mode_of = |c: &Conditional| modes.get(&c.var).copied().unwrap_or(c.suggested);+ let split: Vec<String> = analysis+ .conditionals+ .iter()+ .filter(|c| mode_of(c) == Mode::Split)+ .map(|c| c.var.clone())+ .collect();+ if split.len() > MAX_SPLIT_VARS {+ anyhow::bail!(+ "splitting on {} variables would make {} templates; keep or drop some of: {}",+ split.len(),+ 1usize << split.len(),+ split.join(", ")+ );+ }+ let mut base: HashMap<String, bool> = HashMap::new();+ for c in &analysis.conditionals {+ match mode_of(c) {+ Mode::Keep => {+ base.insert(c.var.clone(), true);+ }+ Mode::Drop => {+ base.insert(c.var.clone(), false);+ }+ Mode::Split => {}+ }+ }+ let html_nodes = parse(html).nodes;+ let subject_nodes = parse(subject).nodes;+ let mut variants = Vec::new();+ for mask in 0..(1usize << split.len()) {+ let mut truthy = base.clone();+ let mut alias_suffix = String::new();+ let mut labels = Vec::new();+ for (i, var) in split.iter().enumerate() {+ let on = mask & (1 << i) == 0;+ truthy.insert(var.clone(), on);+ let words = var.replace('_', " ");+ alias_suffix.push_str(&format!("-{}-{}", if on { "with" } else { "no" }, var.replace('_', "-")));+ labels.push(format!("{} {}", if on { "with" } else { "no" }, words));+ }+ let mut out_html = String::new();+ render(&html_nodes, &truthy, &mut out_html);+ let mut out_subject = String::new();+ render(&subject_nodes, &truthy, &mut out_subject);+ let variables = used_variables(&format!("{out_subject}\n{out_html}"));+ variants.push(Variant {+ alias_suffix,+ name_suffix: if labels.is_empty() { String::new() } else { format!(" ({})", labels.join(", ")) },+ html: out_html,+ subject: out_subject,+ variables,+ });+ }+ Ok(variants)+}++fn triple_re() -> Regex {+ Regex::new(r"\{\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}\}").unwrap()+}++/// Variable keys referenced as `{{{KEY}}}`, in first-use order.+pub fn used_variables(text: &str) -> Vec<String> {+ let mut seen = Vec::new();+ for cap in triple_re().captures_iter(text) {+ let k = cap[1].to_string();+ if !seen.contains(&k) {+ seen.push(k);+ }+ }+ seen+}++#[derive(Debug, Serialize, Clone)]+pub struct LintIssue {+ /// "error" blocks a working send, "warn" is probably wrong, "info" is advice.+ pub level: &'static str,+ pub message: String,+}++pub fn lint(html: &str, subject: &str, variables: &[VariableSpec], alias: Option<&str>) -> Vec<LintIssue> {+ let mut issues = Vec::new();+ let mut add = |level: &'static str, message: String| issues.push(LintIssue { level, message });++ let both = format!("{subject}\n{html}");+ let stripped = triple_re().replace_all(&both, "");+ let mut leftovers: Vec<String> = Vec::new();+ let tag_re = Regex::new(r"\{\{[^}]*\}\}").unwrap();+ for m in tag_re.find_iter(&stripped) {+ let t = m.as_str().to_string();+ if !leftovers.contains(&t) {+ leftovers.push(t);+ }+ }+ let blocks: Vec<&String> = leftovers+ .iter()+ .filter(|t| {+ let i = t.trim_start_matches('{').trim();+ i.starts_with('#') || i.starts_with('/') || i.starts_with('^') || i == "else}}" || i.starts_with("else")+ })+ .collect();+ if !blocks.is_empty() {+ add(+ "error",+ format!(+ "Resend has no conditionals or loops; these Handlebars blocks would be sent as literal text: {}",+ blocks.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ")+ ),+ );+ }+ let doubles: Vec<&String> = leftovers.iter().filter(|t| !blocks.contains(t)).collect();+ if !doubles.is_empty() {+ add(+ "error",+ format!(+ "Resend only substitutes triple-brace variables like {{{{{{KEY}}}}}}; these would go out as literal text: {}",+ doubles.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ")+ ),+ );+ }++ let used = used_variables(&both);+ let declared: Vec<&str> = variables.iter().map(|v| v.key.as_str()).filter(|k| !k.is_empty()).collect();+ for k in &used {+ if !declared.contains(&k.as_str()) {+ add("error", format!("{{{{{{{k}}}}}}} is used but not declared in Variables; Resend rejects sends that reference undeclared variables"));+ }+ }+ for k in &declared {+ if !used.iter().any(|u| u == k) {+ add("warn", format!("{k} is declared but never used in the subject or HTML"));+ }+ }+ let mut dupes = Vec::new();+ for (i, k) in declared.iter().enumerate() {+ if declared[..i].contains(k) && !dupes.contains(k) {+ dupes.push(*k);+ add("error", format!("{k} is declared more than once"));+ }+ }+ for v in variables {+ if v.key.is_empty() {+ add("warn", "a variable row has no key".into());+ continue;+ }+ if RESERVED_KEYS.contains(&v.key.as_str()) {+ add("error", format!("{} is a reserved name in Resend; pick another key", v.key));+ }+ if !v.key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {+ add("warn", format!("{} has characters other than letters, digits, and underscores", v.key));+ }+ if v.var_type == "number" {+ if let Some(fb) = &v.fallback {+ let ok = fb.is_number() || fb.as_str().map(|s| s.trim().parse::<f64>().is_ok()).unwrap_or(false);+ if !ok {+ add("error", format!("{} is a number but its fallback is not", v.key));+ }+ }+ }+ if v.var_type != "string" && v.var_type != "number" {+ add("error", format!("{} has type {}; Resend only supports string and number", v.key, v.var_type));+ }+ }+ if declared.len() > MAX_VARIABLES {+ add("error", format!("{} variables declared; Resend allows at most {MAX_VARIABLES}", declared.len()));+ }+ if subject.trim().is_empty() {+ add("warn", "no subject; every send would have to supply one".into());+ }+ match alias {+ Some(a) if !a.trim().is_empty() => {+ if !a.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {+ add("warn", format!("alias {a} should be lowercase kebab-case"));+ }+ }+ _ => add("warn", "no alias; senders usually reference a template by alias, so this one can only be sent by its id".into()),+ }+ let required: Vec<&str> = variables+ .iter()+ .filter(|v| !v.key.is_empty() && v.fallback.as_ref().map(|f| f.is_null()).unwrap_or(true))+ .map(|v| v.key.as_str())+ .collect();+ if !required.is_empty() {+ add("info", format!("required at send time (no fallback): {}", required.join(", ")));+ }+ issues+}++/// Substitutes `{{{KEY}}}` with values, falling back to each variable's+/// fallback. Unresolved keys are left in place. Mirrors Resend's own+/// substitution so a test send matches what a real one would look like.+pub fn substitute(text: &str, values: &serde_json::Map<String, Value>, variables: &[VariableSpec]) -> String {+ triple_re()+ .replace_all(text, |cap: ®ex::Captures| {+ let k = &cap[1];+ let v = values+ .get(k)+ .filter(|v| !is_blank(v))+ .or_else(|| variables.iter().find(|s| s.key == k).and_then(|s| s.fallback.as_ref()).filter(|v| !is_blank(v)));+ match v {+ Some(Value::String(s)) => s.clone(),+ Some(other) => other.to_string(),+ None => cap[0].to_string(),+ }+ })+ .into_owned()+}++fn is_blank(v: &Value) -> bool {+ v.is_null() || v.as_str().map(|s| s.is_empty()).unwrap_or(false)+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn converts_vars_to_triple_braces() {+ let v = convert("<p>Hi {{name}}, {{{link}}}</p>", "Hello {{name}}", &BTreeMap::new()).unwrap();+ assert_eq!(v.len(), 1);+ assert_eq!(v[0].html, "<p>Hi {{{name}}}, {{{link}}}</p>");+ assert_eq!(v[0].subject, "Hello {{{name}}}");+ assert_eq!(v[0].variables, vec!["name", "link"]);+ }++ #[test]+ fn splits_on_if() {+ let src = "<p>a</p>{{#if notes}}<p>{{notes}}</p>{{/if}}<p>b</p>";+ let v = convert(src, "s", &BTreeMap::new()).unwrap();+ assert_eq!(v.len(), 2);+ assert_eq!(v[0].alias_suffix, "-with-notes");+ assert_eq!(v[0].html, "<p>a</p><p>{{{notes}}}</p><p>b</p>");+ assert_eq!(v[1].alias_suffix, "-no-notes");+ assert_eq!(v[1].html, "<p>a</p><p>b</p>");+ assert!(v[1].variables.is_empty());+ }++ #[test]+ fn else_text_becomes_fallback_hint() {+ let a = analyze("Hi {{#if name}}{{name}}{{else}}there{{/if}},", "");+ assert_eq!(a.conditionals[0].suggested, Mode::Keep);+ assert_eq!(a.conditionals[0].fallback_hint.as_deref(), Some("there"));+ let v = convert("Hi {{#if name}}{{name}}{{else}}there{{/if}},", "", &BTreeMap::new()).unwrap();+ assert_eq!(v[0].html, "Hi {{{name}}},");+ }++ #[test]+ fn drop_keeps_else_branch() {+ let mut m = BTreeMap::new();+ m.insert("x".to_string(), Mode::Drop);+ let v = convert("{{#if x}}yes{{else}}no{{/if}}", "", &m).unwrap();+ assert_eq!(v[0].html, "no");+ }++ #[test]+ fn nested_and_unsupported() {+ let src = "{{#if a}}A{{#if b}}B{{/if}}{{/if}}{{#each items}}{{this}}{{/each}}";+ let a = analyze(src, "");+ assert_eq!(a.conditionals.len(), 2);+ assert!(a.unsupported.iter().any(|u| u.contains("#each")));+ let mut m = BTreeMap::new();+ m.insert("a".to_string(), Mode::Keep);+ m.insert("b".to_string(), Mode::Drop);+ let v = convert(src, "", &m).unwrap();+ assert_eq!(v[0].html, "A{{#each items}}{{{this}}}{{/each}}");+ }++ #[test]+ fn lint_flags_leftovers_and_undeclared() {+ let vars = vec![VariableSpec { key: "name".into(), var_type: "string".into(), fallback: None, sample: Value::Null, description: None }];+ let issues = lint("{{#if x}}{{y}}{{/if}} {{{name}}} {{{link}}}", "s", &vars, Some("a"));+ let msgs: Vec<String> = issues.iter().map(|i| i.message.clone()).collect();+ assert!(msgs.iter().any(|m| m.contains("no conditionals")));+ assert!(msgs.iter().any(|m| m.contains("{{y}}")));+ assert!(msgs.iter().any(|m| m.contains("{{{link}}} is used but not declared")));+ }++ #[test]+ fn substitute_uses_fallback() {+ let vars = vec![VariableSpec { key: "role".into(), var_type: "string".into(), fallback: Some(Value::String("member".into())), sample: Value::Null, description: None }];+ let mut vals = serde_json::Map::new();+ vals.insert("name".into(), Value::String("Ada".into()));+ assert_eq!(substitute("{{{name}}} as {{{role}}} {{{x}}}", &vals, &vars), "Ada as member {{{x}}}");+ }+}
src/main.rsadded+93@@ -0,0 +1,93 @@+mod cli;+mod convert;+mod output;+mod resend;+mod sendgrid;+mod server;+mod settings;+mod store;++use std::sync::Arc;++use clap::Parser;+use cli::{Cli, Command};++fn main() {+ let cli = Cli::parse();+ match cli.command {+ Command::Serve { port, dev, open_tab } => {+ if let Err(e) = serve(port, dev, open_tab) {+ output::err(e);+ std::process::exit(1);+ }+ }+ Command::Health { port } => health(port),+ Command::Install => {+ output::ok(format!("adom-resend-studio v{} installed", env!("CARGO_PKG_VERSION")));+ }+ }+}++fn serve(port: u16, dev: bool, open_tab: bool) -> anyhow::Result<()> {+ let dir = settings::data_dir();+ let settings = settings::Settings::load(&dir)?;+ if settings.resend_key().0.is_none() {+ output::hint("No Resend API key yet: open the studio and add one in Settings (or set RESEND_API_KEY).");+ }+ let db_path = std::env::var("RESEND_STUDIO_DB")+ .unwrap_or_else(|_| dir.join("resend-studio.db").display().to_string());+ let state = Arc::new(server::AppState::new(dev, port, &db_path, settings)?);+ if open_tab {+ open_hydrogen_tab(port);+ }+ server::run(port, state)+}++fn health(port: u16) {+ match ureq::get(&format!("http://127.0.0.1:{port}/health")).call() {+ Ok(resp) => {+ let body: serde_json::Value = resp.into_json().unwrap_or_default();+ output::ok(format!(+ "adom-resend-studio is reachable on port {port} (version {}).",+ body.get("version").and_then(|v| v.as_str()).unwrap_or("unknown")+ ));+ }+ Err(e) => {+ output::err(format!("Cannot reach adom-resend-studio on port {port}: {e}"));+ output::hint(format!("Start it with `adom-resend-studio serve --port {port} &`"));+ std::process::exit(1);+ }+ }+}++/// Opens the app in a NEW Hydrogen pane (split off the first panel) rather+/// than as a tab inside whatever is focused, which is usually the editor.+fn open_hydrogen_tab(port: u16) {+ let proxy_url = match std::env::var("VSCODE_PROXY_URI") {+ Ok(template) => template.replace("{{port}}", &port.to_string()),+ Err(_) => format!("http://127.0.0.1:{port}/"),+ };+ let icon = server::icon_data_url();+ let restart = format!("adom-resend-studio serve --port {port}");+ let status = std::process::Command::new("adom-cli")+ .args([+ "hydrogen",+ "webview",+ "open-or-refresh",+ "--name",+ "Resend Studio",+ "--url",+ &proxy_url,+ "--display-icon",+ &icon,+ "--reason",+ "Resend email template editor",+ "--restart",+ &restart,+ ])+ .status();+ match status {+ Ok(s) if s.success() => output::ok("opened Resend Studio in a Hydrogen webview tab"),+ _ => output::hint(format!("couldn't open the webview tab automatically; open {proxy_url} manually.")),+ }+}
src/output.rsadded+60@@ -0,0 +1,60 @@+use std::io::IsTerminal;+use std::sync::OnceLock;++const GREEN: &str = "\x1b[32m";+const RED: &str = "\x1b[31m";+const DIM: &str = "\x1b[2m";+const BOLD: &str = "\x1b[1m";+const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();++fn stdout_is_tty() -> bool {+ *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal())+}+fn stderr_is_tty() -> bool {+ *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal())+}++fn strip_ansi(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let mut chars = s.chars().peekable();+ while let Some(c) = chars.next() {+ if c == '\x1b' && chars.peek() == Some(&'[') {+ chars.next();+ for c2 in chars.by_ref() {+ if c2.is_ascii_alphabetic() {+ break;+ }+ }+ continue;+ }+ out.push(c);+ }+ out+}++pub fn ok(msg: impl std::fmt::Display) {+ if stdout_is_tty() {+ println!("{GREEN}{BOLD}OK:{RESET} {msg}");+ } else {+ println!("OK: {}", strip_ansi(&msg.to_string()));+ }+}++pub fn err(msg: impl std::fmt::Display) {+ if stderr_is_tty() {+ eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");+ } else {+ eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));+ }+}++pub fn hint(msg: impl std::fmt::Display) {+ if stderr_is_tty() {+ eprintln!("{DIM}Hint:{RESET} {msg}");+ } else {+ eprintln!("Hint: {}", strip_ansi(&msg.to_string()));+ }+}
src/resend.rsadded+152@@ -0,0 +1,152 @@+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}"),+ }+}
src/sendgrid.rsadded+101@@ -0,0 +1,101 @@+//! Read-only SendGrid access, used only to import templates during the move+//! to Resend. Nothing here writes to SendGrid.++use anyhow::{Context, Result, anyhow};+use serde_json::Value;++const API_BASE: &str = "https://api.sendgrid.com/v3";++pub struct SendGridClient {+ api_key: String,+}++/// The active version of one SendGrid dynamic template.+#[derive(Debug, serde::Serialize, Clone)]+pub struct ActiveTemplate {+ pub id: String,+ pub name: String,+ pub subject: String,+ pub html: String,+}++impl SendGridClient {+ pub fn new(api_key: String) -> Self {+ Self { api_key }+ }++ fn get(&self, path: &str) -> Result<Value> {+ ureq::get(&format!("{API_BASE}{path}"))+ .set("Authorization", &format!("Bearer {}", self.api_key))+ .timeout(std::time::Duration::from_secs(20))+ .call()+ .map_err(|e| match e {+ ureq::Error::Status(code, resp) => {+ anyhow!("SendGrid API returned HTTP {code}: {}", resp.into_string().unwrap_or_default())+ }+ ureq::Error::Transport(t) => anyhow!("SendGrid API request failed: {t}"),+ })?+ .into_json()+ .context("parsing SendGrid response")+ }++ /// Every dynamic template with its active version's subject and HTML.+ pub fn list_active(&self) -> Result<Vec<ActiveTemplate>> {+ let list = self.get("/templates?generations=dynamic&page_size=200")?;+ let ids: Vec<String> = list+ .get("result")+ .and_then(|r| r.as_array())+ .map(|a| a.iter().filter_map(|t| t.get("id")?.as_str().map(String::from)).collect())+ .unwrap_or_default();+ let mut out = Vec::new();+ for id in ids {+ if let Some(t) = self.get_active(&id)? {+ out.push(t);+ }+ }+ Ok(out)+ }++ pub fn get_active(&self, id: &str) -> Result<Option<ActiveTemplate>> {+ let t = self.get(&format!("/templates/{id}"))?;+ let name = t.get("name").and_then(|v| v.as_str()).unwrap_or(id).to_string();+ let versions = t.get("versions").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ let Some(v) = versions.iter().find(|v| v.get("active").and_then(|a| a.as_i64()) == Some(1)) else {+ return Ok(None);+ };+ Ok(Some(ActiveTemplate {+ id: id.to_string(),+ name,+ subject: v.get("subject").and_then(|s| s.as_str()).unwrap_or("").to_string(),+ html: v.get("html_content").and_then(|s| s.as_str()).unwrap_or("").to_string(),+ }))+ }+}++/// Sample values from a SendGrid Studio database, keyed by SendGrid template+/// id then variable name, so imported drafts keep the preview data that was+/// already written for them.+pub fn studio_samples(db_path: &str) -> std::collections::HashMap<String, serde_json::Map<String, Value>> {+ let mut out = std::collections::HashMap::new();+ let Ok(conn) = rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) else {+ return out;+ };+ let Ok(mut stmt) = conn.prepare("SELECT template_id, variables_json FROM manifests") else {+ return out;+ };+ let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)));+ if let Ok(rows) = rows {+ for (id, vars) in rows.flatten() {+ let mut map = serde_json::Map::new();+ if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&vars) {+ for v in arr {+ if let (Some(name), Some(sample)) = (v.get("name").and_then(|n| n.as_str()), v.get("sample")) {+ map.insert(name.to_string(), sample.clone());+ }+ }+ }+ out.insert(id, map);+ }+ }+ out+}
src/server.rsadded+995@@ -0,0 +1,995 @@+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()))+}
src/settings.rsadded+191@@ -0,0 +1,191 @@+//! 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);+ }+}
src/store.rsadded+217@@ -0,0 +1,217 @@+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)+ }+}
ui/app.jsadded+786@@ -0,0 +1,786 @@+// Forward console to the server so an AI (or whoever debugs this later) can+// read UI-side logs at GET /console without DevTools access to the webview.+(function () {+ const orig = { log: console.log, warn: console.warn, error: console.error };+ function forward(level, args) {+ orig[level].apply(console, args);+ try {+ fetch('console', {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({+ level, ts: Date.now(),+ message: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' '),+ }),+ }).catch(() => {});+ } catch {}+ }+ console.log = (...a) => forward('log', a);+ console.warn = (...a) => forward('warn', a);+ console.error = (...a) => forward('error', a);+ window.addEventListener('error', e => forward('error', [e.message, e.filename + ':' + e.lineno]));+ window.addEventListener('unhandledrejection', e => forward('error', ['unhandledrejection', String(e.reason)]));+})();++// Header hover-popover (600ms show, 220ms hide grace).+(function () {+ document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+ const target = document.getElementById(trigger.dataset.tooltipTarget);+ if (!target) return;+ let showTimer = null, hideTimer = null;+ const show = () => { clearTimeout(hideTimer); if (target.classList.contains('show')) return; clearTimeout(showTimer); showTimer = setTimeout(() => target.classList.add('show'), 600); };+ const queueHide = () => { clearTimeout(showTimer); clearTimeout(hideTimer); hideTimer = setTimeout(() => target.classList.remove('show'), 220); };+ trigger.addEventListener('mouseenter', show);+ trigger.addEventListener('mouseleave', queueHide);+ target.addEventListener('mouseenter', show);+ target.addEventListener('mouseleave', queueHide);+ });+})();++// Global tooltip: one body-level fixed div positioned at the cursor, so no+// pane's overflow can clip it.+(function () {+ const tip = document.createElement('div');+ tip.id = 'global-tooltip';+ document.body.appendChild(tip);+ let cx = 0, cy = 0, timer = null, current = null;+ document.addEventListener('mousemove', e => { cx = e.clientX; cy = e.clientY; }, true);+ function place() {+ const w = tip.offsetWidth, h = tip.offsetHeight, o = 14, vw = innerWidth, vh = innerHeight;+ const c = [{ x: cx + o, y: cy + o }, { x: cx - w - o, y: cy + o }, { x: cx + o, y: cy - h - o }, { x: cx - w - o, y: cy - h - o }]+ .find(p => p.x >= 4 && p.y >= 4 && p.x + w <= vw - 4 && p.y + h <= vh - 4)+ || { x: Math.min(Math.max(cx + o, 4), vw - w - 4), y: Math.min(Math.max(cy + o, 4), vh - h - 4) };+ tip.style.setProperty('--tip-x', c.x + 'px');+ tip.style.setProperty('--tip-y', c.y + 'px');+ }+ function hide() { clearTimeout(timer); tip.classList.remove('shown', 'visible'); current = null; }+ document.addEventListener('mouseover', e => {+ const t = e.target.closest && e.target.closest('[data-tooltip]');+ if (t === current) return;+ hide();+ current = t || null;+ if (t) timer = setTimeout(() => {+ tip.textContent = t.getAttribute('data-tooltip');+ tip.classList.add('visible');+ place();+ requestAnimationFrame(() => tip.classList.add('shown'));+ }, 600);+ });+ document.addEventListener('mouseout', e => {+ const t = e.target.closest && e.target.closest('[data-tooltip]');+ if (!t || (e.relatedTarget && t.contains(e.relatedTarget))) return;+ hide();+ });+ document.addEventListener('mousedown', hide, true);+})();++// ---------- helpers ----------+const $ = id => document.getElementById(id);+function escapeHtml(s) {+ return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));+}+async function api(path, opts = {}) {+ const init = { method: opts.method || 'GET', headers: {} };+ // Every write is JSON: the server refuses anything else (cross-site form protection).+ if (init.method !== 'GET') { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(opts.body ?? {}); }+ try {+ const res = await fetch(path, init);+ let body = {};+ try { body = await res.json(); } catch {}+ return { ok: res.ok, status: res.status, body };+ } catch (e) {+ console.error('network error', path, e);+ return { ok: false, status: 0, body: { error: 'Network error: is the studio server still running?' } };+ }+}+function toast(msg, level = 'ok', ms = 4200) {+ const el = document.createElement('div');+ el.className = 'toast ' + level;+ el.textContent = msg;+ $('toasts').appendChild(el);+ setTimeout(() => el.remove(), level === 'error' ? ms * 2 : ms);+}+const VAR_RE = /\{\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}\}/g;+const RESERVED = ['FIRST_NAME', 'LAST_NAME', 'EMAIL', 'UNSUBSCRIBE_URL', 'RESEND_UNSUBSCRIBE_URL', 'contact', 'this'];+function usedVars(text) {+ const out = [];+ for (const m of String(text).matchAll(VAR_RE)) if (!out.includes(m[1])) out.push(m[1]);+ return out;+}+function blank(v) { return v === null || v === undefined || v === ''; }+function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }+function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }++// ---------- modal ----------+let modalOnClose = null;+function openModal({ title, body, buttons = [], narrow = false, onClose = null }) {+ $('modalTitle').textContent = title;+ const b = $('modalBody');+ b.innerHTML = '';+ if (typeof body === 'string') b.innerHTML = body; else if (body) b.appendChild(body);+ const f = $('modalFoot');+ f.innerHTML = '';+ for (const btn of buttons) {+ const el = document.createElement('button');+ el.className = 'btn ' + (btn.cls || '');+ el.textContent = btn.label;+ if (btn.id) el.id = btn.id;+ if (btn.disabled) el.disabled = true;+ el.addEventListener('click', async () => { const keep = btn.onClick ? await btn.onClick(el) : false; if (!keep) closeModal(); });+ f.appendChild(el);+ }+ $('modal').classList.toggle('narrow', narrow);+ $('modal').style.width = '';+ modalOnClose = onClose;+ $('modalBackdrop').hidden = false;+}+function closeModal() {+ $('modalBackdrop').hidden = true;+ const cb = modalOnClose; modalOnClose = null;+ if (cb) cb();+}+$('modalClose').addEventListener('click', closeModal);+$('modalBackdrop').addEventListener('mousedown', e => { if (e.target === $('modalBackdrop')) closeModal(); });+document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('modalBackdrop').hidden) closeModal(); });+function confirmModal(title, text, okLabel = 'OK', danger = false) {+ return new Promise(resolve => {+ let answered = false;+ openModal({+ title, narrow: true, body: `<p>${escapeHtml(text)}</p>`,+ onClose: () => { if (!answered) resolve(false); },+ buttons: [+ { label: 'Cancel', onClick: () => { answered = true; resolve(false); } },+ { label: okLabel, cls: danger ? 'btn-danger' : 'btn-accent', onClick: () => { answered = true; resolve(true); } },+ ],+ });+ });+}++// ---------- app state ----------+let appState = { resend_configured: false, sendgrid_configured: false };+let templates = [];+let current = null; // { key, editable, remote, hasDraft, dirty }+let vars = []; // working copy of current.editable.variables+let unsaved = false; // edits in the form not yet saved locally+let lintTimer = null;++async function pollState() {+ const r = await api('state');+ if (!r.ok) {+ $('resendStatus').textContent = 'server down';+ $('resendStatus').className = 'status-pill bad';+ return;+ }+ const s = r.body;+ const changed = s.resend_configured !== appState.resend_configured || s.sendgrid_configured !== appState.sendgrid_configured || s.resend_access !== appState.resend_access;+ appState = s;+ $('popVersion').textContent = 'v' + s.version;+ const acc = s.resend_access;+ const [label, cls] = !s.resend_configured ? ['connect Resend', 'bad']+ : acc === 'full' ? ['resend connected', 'ok']+ : acc === 'sending' ? ['resend: send-only key', 'warn']+ : acc === 'invalid' ? ['resend: key rejected', 'bad']+ : ['resend: unreachable', 'warn'];+ $('resendStatus').textContent = label;+ $('resendStatus').className = 'status-pill as-btn ' + cls;+ $('sendgridStatus').textContent = s.sendgrid_configured ? 'sendgrid import ready' : 'sendgrid import off';+ $('sendgridStatus').className = 'status-pill ' + (s.sendgrid_configured ? 'ok' : 'off');+ if (changed) { loadList(); refreshButtons(); }+}++// ---------- list ----------+async function loadList() {+ const r = await api('api/templates');+ if (!r.ok) { $('templateList').innerHTML = `<div class="empty-state">${escapeHtml(r.body.error || 'failed to load')}</div>`; return; }+ templates = r.body.templates || [];+ remoteError = r.body.remote_error || null;+ const re = $('remoteError');+ re.hidden = !r.body.remote_error;+ re.innerHTML = !r.body.remote_error ? ''+ : !appState.resend_configured ? 'Connect your Resend account to list, upload, and publish templates. Local drafts and SendGrid import work without it. <button class="link-btn" data-open-settings>Open Settings</button>'+ : appState.resend_access === 'sending' ? 'This Resend key has <b>Sending access</b> only, so templates can\'t be listed, uploaded, or published. Create a <b>Full access</b> key in Resend and paste it in Settings. <button class="link-btn" data-open-settings>Open Settings</button>'+ : 'Resend list unavailable: ' + escapeHtml(r.body.remote_error) + '. Local drafts still work.';+ renderList();+}++let remoteError = null;+function itemState(t) {+ if (!t.remote && t.draft && t.draft.uploaded && remoteError) return { dot: '', label: 'on Resend, status unavailable (Resend list failed)' };+ if (!t.remote) return { dot: 'local', label: 'local only' };+ return t.remote.status === 'published' ? { dot: 'live', label: 'published' } : { dot: 'draft', label: 'on Resend, unpublished' };+}++function renderList() {+ const el = $('templateList');+ const q = ($('filterInput').value || '').toLowerCase().trim();+ const shown = templates.filter(t => !q || (t.name || '').toLowerCase().includes(q) || (t.alias || '').toLowerCase().includes(q));+ if (!shown.length) {+ el.innerHTML = `<div class="empty-state">${templates.length ? 'Nothing matches.' : 'No templates yet. Import from SendGrid or start a new one.'}</div>`;+ return;+ }+ el.innerHTML = '';+ for (const t of shown) {+ const st = itemState(t);+ const badges = [];+ if (t.draft && t.draft.dirty && t.remote) badges.push('<span class="badge warn" data-tooltip="The local draft has edits Resend has not seen. Upload to send them to Resend\'s draft.">local edits</span>');+ if (t.draft && !t.remote && !(t.draft.uploaded && remoteError)) badges.push('<span class="badge" data-tooltip="Exists only in this studio. Upload to create it on Resend.">not uploaded</span>');+ if (t.draft && (t.draft.source || '').startsWith('sendgrid:')) badges.push(`<span class="badge info" data-tooltip="Imported from SendGrid template ${escapeHtml(t.draft.source.slice(9))}">from SendGrid</span>`);+ const item = document.createElement('div');+ item.className = 'template-item' + (current && current.key === t.key ? ' active' : '');+ item.innerHTML = `+ <div class="row1"><i class="dot ${st.dot}" data-tooltip="${escapeHtml(st.label)}"></i><span class="name">${escapeHtml(t.name || t.key)}</span></div>+ <div class="alias">${escapeHtml(t.alias || t.key)}</div>+ ${badges.length ? `<div class="badges">${badges.join('')}</div>` : ''}`;+ item.addEventListener('click', () => selectTemplate(t.key));+ el.appendChild(item);+ }+}+$('filterInput').addEventListener('input', renderList);+$('refreshBtn').addEventListener('click', () => { loadList(); if (current && !unsaved) selectTemplate(current.key, true); });++// ---------- editor ----------+async function selectTemplate(key, force = false) {+ if (!force && unsaved && current && current.key !== key) {+ const ok = await confirmModal('Unsaved changes', `You have unsaved edits to ${current.editable.name}. Leave without saving?`, 'Discard edits', true);+ if (!ok) return;+ }+ const r = await api('api/templates/' + encodeURIComponent(key));+ if (!r.ok) { toast(r.body.error || 'failed to load template', 'error'); return; }+ current = { key: r.body.key, editable: r.body.editable, remote: r.body.remote, hasDraft: r.body.has_draft, dirty: r.body.dirty, remoteError: r.body.remote_error };+ vars = JSON.parse(JSON.stringify(current.editable.variables || []));+ unsaved = false;+ lsSet('resend-studio:last', current.key);+ mountEditor();+ fillForm(current.editable);+ renderList();+ renderLint(r.body.lint || []);+ renderStatus();+ renderRemote();+}++function mountEditor() {+ const area = $('editorArea');+ area.innerHTML = '';+ area.appendChild($('editorTemplate').content.cloneNode(true));+ for (const id of ['nameInput', 'aliasInput', 'subjectInput', 'fromInput', 'replyToInput', 'htmlInput', 'notesInput']) {+ $(id).addEventListener('input', onEdit);+ }+ $('htmlInput').addEventListener('keydown', e => {+ if (e.key === 'Tab') { e.preventDefault(); document.execCommand('insertText', false, ' '); }+ });+ $('saveBtn').addEventListener('click', () => save());+ $('uploadBtn').addEventListener('click', upload);+ $('publishBtn').addEventListener('click', publish);+ $('sendTestBtn').addEventListener('click', sendTest);+ $('addVarBtn').addEventListener('click', () => { vars.push({ key: '', type: 'string', fallback: null, sample: '' }); renderVars(); onEdit(); });+ $('syncVarsBtn').addEventListener('click', syncVars);+ $('moreBtn').addEventListener('click', e => { e.stopPropagation(); $('moreMenu').hidden = !$('moreMenu').hidden; });+ $('moreMenu').addEventListener('click', e => { const act = e.target.dataset.act; $('moreMenu').hidden = true; if (act) moreAction(act); });+ $('widthSeg').addEventListener('click', e => {+ const w = e.target.dataset.w; if (!w) return;+ for (const b of $('widthSeg').querySelectorAll('button')) b.classList.toggle('on', b.dataset.w === w);+ $('previewWrap').classList.toggle('mobile', w === 'mobile');+ });+ $('testEmailInput').value = lsGet('resend-studio:test-to') || appState.default_test_to || '';+ if (!$('fromInput').placeholder) $('fromInput').placeholder = appState.default_from || 'Acme <[email protected]>';+}+document.addEventListener('click', () => { const m = $('moreMenu'); if (m) m.hidden = true; });++function fillForm(e) {+ $('nameInput').value = e.name || '';+ $('aliasInput').value = e.alias || '';+ $('subjectInput').value = e.subject || '';+ $('fromInput').value = e.from || '';+ $('replyToInput').value = e.reply_to || '';+ $('htmlInput').value = e.html || '';+ $('notesInput').value = e.notes || '';+ $('subjectName').textContent = e.name || e.key;+ $('subjectMeta').textContent = e.alias || '';+ renderVars();+ updatePreview();+}++function collect() {+ return {+ name: $('nameInput').value,+ alias: $('aliasInput').value,+ subject: $('subjectInput').value,+ from: $('fromInput').value,+ reply_to: $('replyToInput').value,+ html: $('htmlInput').value,+ notes: $('notesInput').value,+ variables: vars.map(v => ({ key: v.key.trim(), type: v.type || 'string', fallback: blank(v.fallback) ? null : v.fallback, sample: v.sample ?? '', description: v.description || null })),+ resend_id: current.editable.resend_id || null,+ };+}++function onEdit() {+ if (!current) return;+ unsaved = true;+ updatePreview();+ renderVarMarks();+ renderStatus();+ clearTimeout(lintTimer);+ lintTimer = setTimeout(runLint, 250);+}++async function runLint() {+ if (!current) return;+ const c = collect();+ const r = await api('api/lint', { method: 'POST', body: { html: c.html, subject: c.subject, variables: c.variables, alias: c.alias } });+ if (r.ok) renderLint(r.body.lint || []);+}++function renderLint(items) {+ const el = $('lintList');+ if (!el) return;+ const errs = items.filter(i => i.level === 'error').length, warns = items.filter(i => i.level === 'warn').length;+ $('lintCount').textContent = errs || warns ? `${errs} error${errs === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'}` : '';+ if (!items.length) { el.innerHTML = '<div class="lint-ok">Ready for Resend.</div>'; return; }+ el.innerHTML = items.map(i => `<div class="lint ${i.level}"><span class="lv">${i.level}</span><span>${escapeHtml(i.message)}</span></div>`).join('');+ if (!errs && !warns) el.insertAdjacentHTML('afterbegin', '<div class="lint-ok">Ready for Resend.</div>');+}++// ---------- variables ----------+function renderVars() {+ const table = $('variablesTable');+ table.innerHTML = '';+ if (!vars.length) table.innerHTML = '<div class="var-empty">No variables. Use {{{KEY}}} in the HTML, then "sync from HTML".</div>';+ vars.forEach((v, i) => table.appendChild(varRow(v, i)));+ renderVarMarks();+}++function varRow(v, index) {+ const row = document.createElement('div');+ row.className = 'var-row';+ row.innerHTML = `+ <input type="text" class="k" placeholder="KEY" value="${escapeHtml(v.key || '')}">+ <select class="t" data-tooltip="Resend supports string and number.">+ <option value="string" ${v.type !== 'number' ? 'selected' : ''}>string</option>+ <option value="number" ${v.type === 'number' ? 'selected' : ''}>number</option>+ </select>+ <input type="text" class="fb" placeholder="(required)" value="${escapeHtml(blank(v.fallback) ? '' : String(v.fallback))}">+ <input type="text" class="s" placeholder="sample for preview" value="${escapeHtml(blank(v.sample) ? '' : String(v.sample))}">+ <button class="remove-var" data-tooltip="Remove this variable.">×</button>`;+ const sync = () => {+ v.key = row.querySelector('.k').value;+ v.type = row.querySelector('.t').value;+ const fb = row.querySelector('.fb').value;+ v.fallback = fb === '' ? null : (v.type === 'number' && !isNaN(Number(fb)) ? Number(fb) : fb);+ v.sample = row.querySelector('.s').value;+ onEdit();+ };+ row.querySelectorAll('input, select').forEach(el => el.addEventListener('input', sync));+ row.querySelector('.remove-var').addEventListener('click', () => { vars.splice(index, 1); renderVars(); onEdit(); });+ return row;+}++function renderVarMarks() {+ if (!$('variablesTable')) return;+ const used = usedVars($('subjectInput').value + '\n' + $('htmlInput').value);+ const keys = vars.map(v => v.key.trim());+ $('variablesTable').querySelectorAll('.var-row').forEach((row, i) => {+ const k = keys[i];+ const input = row.querySelector('.k');+ const bad = !k || RESERVED.includes(k) || keys.indexOf(k) !== i;+ input.classList.toggle('bad', bad);+ input.classList.toggle('unused', !bad && !used.includes(k));+ input.title = bad ? (RESERVED.includes(k) ? 'Reserved by Resend' : keys.indexOf(k) !== i ? 'Duplicate key' : 'Empty key') : (used.includes(k) ? '' : 'Not used in the subject or HTML');+ });+ const missing = used.filter(k => !keys.includes(k));+ $('varCount').textContent = `${keys.filter(Boolean).length} declared` + (missing.length ? `, ${missing.length} used but undeclared` : '');+}++function syncVars() {+ const used = usedVars($('subjectInput').value + '\n' + $('htmlInput').value);+ const keys = vars.map(v => v.key.trim());+ const add = used.filter(k => !keys.includes(k));+ for (const k of add) vars.push({ key: k, type: 'string', fallback: null, sample: '' });+ renderVars();+ onEdit();+ toast(add.length ? `Added ${add.join(', ')}` : 'Every variable in the HTML is already declared.');+}++// ---------- preview ----------+function valueFor(key) {+ const v = vars.find(x => x.key.trim() === key);+ if (!v) return null;+ if (!blank(v.sample)) return String(v.sample);+ if (!blank(v.fallback)) return String(v.fallback);+ return null;+}++function updatePreview() {+ if (!$('previewFrame')) return;+ const html = $('htmlInput').value;+ // Values go in unescaped, matching Resend's triple-brace substitution. A+ // missing value inside a tag (an href, say) stays as literal text; in body+ // text it gets a red marker so it can't be missed.+ const out = html.replace(VAR_RE, (m, key, offset) => {+ const v = valueFor(key);+ if (v !== null) return v;+ const before = html.slice(0, offset);+ const inTag = before.lastIndexOf('<') > before.lastIndexOf('>');+ return inTag ? m : `<span style="background:#ffe8e6;color:#c4231a;font-family:monospace;font-size:12px;padding:0 3px;border-radius:3px">${escapeHtml(m)}</span>`;+ });+ $('previewFrame').srcdoc = '<meta name="referrer" content="no-referrer">' + out;++ const subj = $('pvSubject');+ subj.innerHTML = '';+ const s = $('subjectInput').value;+ let last = 0;+ for (const m of s.matchAll(VAR_RE)) {+ subj.appendChild(document.createTextNode(s.slice(last, m.index)));+ const v = valueFor(m[1]);+ if (v !== null) subj.appendChild(document.createTextNode(v));+ else { const sp = document.createElement('span'); sp.className = 'pv-missing'; sp.textContent = m[0]; subj.appendChild(sp); }+ last = m.index + m[0].length;+ }+ subj.appendChild(document.createTextNode(s.slice(last)));+ if (!s) subj.innerHTML = '<span class="pv-missing">(no subject)</span>';+ $('pvFrom').textContent = $('fromInput').value || appState.default_from || '';+}++// ---------- status ----------+function renderStatus() {+ const el = $('statusLine');+ if (!el || !current) return;+ const chips = [];+ if (unsaved) chips.push('<span class="chip unsaved">unsaved changes</span>');+ const r = current.remote;+ if (!r) chips.push(`<span class="chip warn" data-tooltip="Upload creates it on Resend as an unpublished draft.">not on Resend yet</span>`);+ else if (r.status === 'published') chips.push(`<span class="chip ok" data-tooltip="Real sends use the published version.">published${r.published_at ? ' ' + escapeHtml(String(r.published_at).slice(0, 16)) : ''}</span>`);+ else chips.push('<span class="chip warn" data-tooltip="Resend refuses to send an unpublished template, so nothing can use this yet.">on Resend, never published</span>');+ if (r && r.has_unpublished_versions) chips.push('<span class="chip warn" data-tooltip="Resend\'s working draft differs from what is published. Publishing would make it live.">Resend draft differs from published</span>');+ if (current.hasDraft && current.dirty && r) chips.push('<span class="chip warn" data-tooltip="Saved here, not yet uploaded to Resend.">local draft not uploaded</span>');+ if (!current.hasDraft && r) chips.push('<span class="chip" data-tooltip="You are looking at Resend\'s copy. Saving creates a local draft.">no local draft</span>');+ const src = current.editable.source;+ if (src && src.startsWith('sendgrid:')) chips.push(`<span class="chip" data-tooltip="Converted from this SendGrid template.">from SendGrid ${escapeHtml(src.slice(9, 20))}...</span>`);+ if (current.remoteError) chips.push(`<span class="chip warn">${escapeHtml(current.remoteError)}</span>`);+ el.innerHTML = chips.join('');+ refreshButtons();+}++function refreshButtons() {+ if (!$('uploadBtn') || !current) return;+ const conf = appState.resend_configured;+ $('uploadBtn').disabled = !conf;+ $('saveBtn').classList.toggle('pulse', unsaved);+ const canPublish = conf && current.remote && current.hasDraft && !current.dirty && !unsaved;+ $('publishBtn').disabled = !canPublish;+ $('publishBtn').setAttribute('data-tooltip', canPublish+ ? 'Make Resend\'s current draft the version real emails use. Asks you to type the alias to confirm.'+ : !conf ? 'Needs RESEND_API_KEY.'+ : !current.remote ? 'Upload first; there is nothing on Resend to publish yet.'+ : 'Save and upload first, so what gets published is exactly what you see here.');+ $('sendTestBtn').disabled = !conf;+}++function renderRemote() {+ const el = $('remoteInfo');+ const r = current.remote;+ if (!r) { el.innerHTML = '<div class="pane-sub">Not on Resend yet.</div>'; return; }+ const rows = [['id', r.id], ['alias', r.alias], ['status', r.status], ['published', r.published_at], ['updated', r.updated_at], ['version', r.current_version_id], ['unpublished', r.has_unpublished_versions === undefined ? null : String(r.has_unpublished_versions)]];+ el.innerHTML = rows.filter(([, v]) => !blank(v)).map(([k, v]) => `<div class="popover-row"><span class="k">${k}</span><span class="v">${escapeHtml(v)}</span></div>`).join('');+}++// ---------- actions ----------+async function save(quiet = false) {+ if (!current) return false;+ const body = collect();+ if (!body.name.trim()) { toast('Name is required.', 'error'); return false; }+ const r = await api('api/templates/' + encodeURIComponent(current.key) + '/draft', { method: 'POST', body });+ if (!r.ok) { toast('Save failed: ' + r.body.error, 'error'); return false; }+ unsaved = false;+ current.hasDraft = true;+ current.dirty = r.body.dirty;+ Object.assign(current.editable, body);+ $('subjectName').textContent = body.name;+ $('subjectMeta').textContent = body.alias;+ renderLint(r.body.lint || []);+ renderStatus();+ loadList();+ if (!quiet) toast('Saved locally. Resend is unchanged.');+ return true;+}++async function upload(force = false) {+ if (!(await save(true))) return;+ const r = await api('api/templates/' + encodeURIComponent(current.key) + '/upload', { method: 'POST', body: { force: force === true } });+ if (r.status === 422) {+ const list = (r.body.lint || []).map(i => `<li>${escapeHtml(i.message)}</li>`).join('');+ openModal({+ title: 'Lint errors', narrow: true,+ body: `<p>Resend would probably reject sends of this template:</p><ul>${list}</ul>`,+ buttons: [{ label: 'Fix first' }, { label: 'Upload anyway', cls: 'btn-danger', onClick: () => { upload(true); } }],+ });+ return;+ }+ if (!r.ok) { toast('Upload failed: ' + r.body.error, 'error'); return; }+ const how = r.body.created ? 'Created on Resend' : r.body.linked_existing ? 'Linked to the existing Resend template with this alias and updated its draft' : 'Updated Resend\'s draft';+ toast(how + '. Live email is unchanged until you publish.');+ await selectTemplate(current.key, true);+ loadList();+}++function publish() {+ if (!current) return;+ const expected = current.editable.alias || current.key;+ const wrap = document.createElement('div');+ wrap.innerHTML = `+ <p>Publishing makes Resend's current draft of <strong>${escapeHtml(current.editable.name)}</strong> the version every real send uses, starting with the next email.</p>+ <p>Type <code>${escapeHtml(expected)}</code> to confirm.</p>+ <input type="text" id="confirmInput" autocomplete="off" spellcheck="false" class="mono">`;+ openModal({+ title: 'Publish to live email', narrow: true, body: wrap,+ buttons: [+ { label: 'Cancel' },+ {+ label: 'Publish', cls: 'btn-danger', id: 'confirmPublishBtn', disabled: true,+ onClick: async () => {+ const r = await api('api/templates/' + encodeURIComponent(current.key) + '/publish', { method: 'POST', body: { confirm: $('confirmInput').value } });+ if (!r.ok) { toast('Publish failed: ' + r.body.error, 'error'); return; }+ toast('Published. Real sends now use this version.');+ await selectTemplate(current.key, true);+ loadList();+ },+ },+ ],+ });+ const input = $('confirmInput');+ input.focus();+ input.addEventListener('input', () => { $('confirmPublishBtn').disabled = input.value !== expected; });+}++async function sendTest() {+ const to = $('testEmailInput').value.trim();+ if (!to.includes('@')) { toast('Enter an email address first.', 'warn'); return; }+ lsSet('resend-studio:test-to', to);+ const mode = $('testModeSelect').value;+ if (unsaved && !(await save(true))) return;+ if (!current.hasDraft && mode === 'draft' && !(await save(true))) return;+ const r = await api('api/templates/' + encodeURIComponent(current.key) + '/send-test', { method: 'POST', body: { to, mode } });+ if (!r.ok) { toast('Send failed: ' + r.body.error, 'error'); return; }+ toast(`Test sent to ${to} (${mode === 'draft' ? 'this draft, rendered here' : 'published version via Resend'}).`);+}++async function moreAction(act) {+ if (!current) return;+ if (act === 'load-remote') {+ const r = current.remote;+ if (!r) { toast('Not on Resend yet.', 'warn'); return; }+ if (!(await confirmModal('Replace editor content', 'Replace the editor with Resend\'s PUBLISHED copy (Resend\'s API does not expose its unpublished draft)? Local samples for matching keys are kept. Nothing is saved until you press Save.', 'Replace'))) return;+ const oldSamples = Object.fromEntries(vars.map(v => [v.key, v.sample]));+ current.editable = Object.assign({}, current.editable, {+ name: r.name || '', alias: r.alias || '', subject: r.subject || '', from: r.from || '',+ reply_to: Array.isArray(r.reply_to) ? r.reply_to.join(', ') : (r.reply_to || ''), html: r.html || '',+ });+ vars = (r.variables || []).map(v => ({ key: v.key, type: v.type || 'string', fallback: blank(v.fallback_value) ? null : v.fallback_value, sample: oldSamples[v.key] ?? (v.fallback_value ?? '') }));+ fillForm(current.editable);+ $('notesInput').value = current.editable.notes || '';+ onEdit();+ } else if (act === 'discard') {+ if (!current.hasDraft) { toast('There is no local draft to discard.', 'warn'); return; }+ if (!(await confirmModal('Discard local draft', `Delete the local draft of ${current.editable.name}? ${current.remote ? 'Resend\'s copy is untouched.' : 'It was never uploaded, so this deletes it for good.'}`, 'Discard', true))) return;+ const r = await api('api/templates/' + encodeURIComponent(current.key) + '/draft', { method: 'DELETE' });+ if (!r.ok) { toast(r.body.error, 'error'); return; }+ unsaved = false;+ const remoteKey = current.remote ? (current.remote.alias || current.remote.id) : null;+ current = null;+ await loadList();+ if (remoteKey) selectTemplate(remoteKey, true);+ else $('editorArea').innerHTML = '<div class="empty-state large">Draft discarded.</div>';+ } else if (act === 'copy-json') {+ const variables = {};+ for (const v of vars) if (v.key) variables[v.key] = blank(v.sample) ? (v.fallback ?? '') : v.sample;+ const snippet = JSON.stringify({+ from: $('fromInput').value || appState.default_from,+ to: ['[email protected]'],+ template: { id: $('aliasInput').value || current.editable.resend_id || current.key, variables },+ }, null, 2);+ try { await navigator.clipboard.writeText(snippet); toast('Copied a POST /emails body that sends this template by alias.'); }+ catch { openModal({ title: 'Send snippet', body: `<pre class="mono">${escapeHtml(snippet)}</pre>` }); }+ }+}++document.addEventListener('keydown', e => {+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); if (current) save(); }+});++// ---------- new template ----------+$('newBtn').addEventListener('click', () => {+ const wrap = document.createElement('div');+ wrap.innerHTML = `+ <p>Creates a local draft from a blank starter. Nothing reaches Resend until you upload.</p>+ <div class="sg-row"><span class="k">name</span><input type="text" id="newName" placeholder="Welcome Email"></div>+ <div class="sg-row"><span class="k">alias</span><input type="text" id="newAlias" class="mono" placeholder="welcome-email"></div>`;+ openModal({+ title: 'New template', narrow: true, body: wrap,+ buttons: [{ label: 'Cancel' }, {+ label: 'Create', cls: 'btn-accent',+ onClick: async () => {+ const r = await api('api/templates', { method: 'POST', body: { name: $('newName').value, alias: $('newAlias').value } });+ if (!r.ok) { toast(r.body.error, 'error'); return true; }+ await loadList();+ selectTemplate(r.body.key);+ },+ }],+ });+ const n = $('newName'), a = $('newAlias');+ let touched = false;+ a.addEventListener('input', () => { touched = true; });+ n.addEventListener('input', () => { if (!touched) a.value = n.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); });+ n.focus();+});++// ---------- SendGrid import ----------+$('importBtn').addEventListener('click', openImport);+$('emptyImportBtn').addEventListener('click', openImport);++async function openImport() {+ const body = document.createElement('div');+ body.innerHTML = '<p>Loading SendGrid templates (read-only)...</p>';+ openModal({ title: 'Import from SendGrid', body });+ const r = await api('api/sendgrid/templates');+ if (!r.ok) { body.innerHTML = `<p>${escapeHtml(r.body.error)}</p>`; return; }+ body.innerHTML = `<p>Each template's ACTIVE SendGrid version is converted to Resend syntax and saved as a local draft. Resend has no conditionals, so choose what happens to each <code>{{#if}}</code> block: <b>split</b> makes one template with the block and one without (the sender picks), <b>keep</b> always renders it (a missing value uses the fallback), <b>drop</b> never renders it.</p>`;+ for (const t of r.body.templates) body.appendChild(importCard(t));+}++function importCard(t) {+ const card = document.createElement('div');+ card.className = 'sg-card';+ const modes = {}, fallbacks = {};+ for (const c of t.analysis.conditionals) { modes[c.var] = c.suggested; if (c.fallback_hint) fallbacks[c.var] = c.fallback_hint; }+ const conds = t.analysis.conditionals;+ card.innerHTML = `+ <div class="sg-top"><span class="sg-name">${escapeHtml(t.name)}</span><span class="sg-id">${escapeHtml(t.id)}</span>+ ${t.imported_as.length ? `<span class="badge info" style="margin-left:auto">imported as ${escapeHtml(t.imported_as.join(', '))}</span>` : ''}</div>+ <div class="sg-subject">${escapeHtml(t.subject)}</div>+ <div class="sg-row"><span class="k">alias</span><input type="text" class="alias mono" value="${escapeHtml(t.suggested_alias)}"></div>+ ${conds.length ? `<div class="sg-row" style="align-items:start"><span class="k">conditionals</span><div class="cond-table">+ ${conds.map(c => `<span class="var">{{#if ${escapeHtml(c.var)}}}</span>+ <div class="seg" data-var="${escapeHtml(c.var)}">${['split', 'keep', 'drop'].map(m => `<button data-m="${m}" class="${c.suggested === m ? 'on' : ''}">${m}</button>`).join('')}</div>+ <input type="text" class="fb" data-var="${escapeHtml(c.var)}" placeholder="fallback when kept" value="${escapeHtml(c.fallback_hint || '')}" data-tooltip="Only matters for keep: the value Resend uses when a send leaves ${escapeHtml(c.var)} out.">`).join('')}+ </div></div>` : ''}+ <div class="sg-row"><span class="k">creates</span><div class="variants"></div></div>+ ${t.analysis.unsupported.length ? `<div class="sg-unsupported">Needs a manual rewrite after import (no Resend equivalent): ${escapeHtml(t.analysis.unsupported.join(' '))}</div>` : ''}+ <div class="sg-actions"><span class="hint"></span><button class="btn go ${t.imported_as.length ? '' : 'btn-accent'}">${t.imported_as.length ? 'Re-import (overwrite drafts)' : 'Import'}</button></div>`;+ const variantsEl = card.querySelector('.variants');+ const aliasEl = card.querySelector('.alias');+ let lastVariants = [];+ async function refresh() {+ const r = await api('api/convert', { method: 'POST', body: { html: t.html, subject: t.subject, modes } });+ if (!r.ok) { variantsEl.innerHTML = `<span class="pv-missing">${escapeHtml(r.body.error)}</span>`; lastVariants = []; return; }+ lastVariants = r.body.variants;+ variantsEl.innerHTML = lastVariants.map(v => `<code data-tooltip="Variables: ${escapeHtml(v.variables.join(', ') || 'none')}">${escapeHtml(aliasEl.value + v.alias_suffix)}</code>`).join('');+ }+ card.querySelectorAll('.seg').forEach(seg => seg.addEventListener('click', e => {+ const m = e.target.dataset.m; if (!m) return;+ modes[seg.dataset.var] = m;+ seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.m === m));+ refresh();+ }));+ card.querySelectorAll('input.fb').forEach(inp => inp.addEventListener('input', () => { fallbacks[inp.dataset.var] = inp.value; }));+ aliasEl.addEventListener('input', refresh);+ card.querySelector('.go').addEventListener('click', async () => {+ const overwrite = t.imported_as.length > 0;+ if (overwrite && !(await confirmModal('Overwrite drafts', `Replace the local drafts imported from ${t.name}? Unsaved edits to them are lost. Links to Resend are kept.`, 'Overwrite', true))) { openImport(); return; }+ const fb = Object.fromEntries(Object.entries(fallbacks).filter(([k, v]) => v !== '' && modes[k] === 'keep'));+ const r = await api('api/import/sendgrid', { method: 'POST', body: { sendgrid_id: t.id, alias: aliasEl.value, modes, fallbacks: fb, overwrite } });+ if (!r.ok) {+ const hint = card.querySelector('.hint');+ hint.textContent = r.body.error + (r.body.conflicts ? ': ' + r.body.conflicts.join(', ') : '');+ hint.style.color = 'var(--red)';+ if (overwrite) openImport();+ return;+ }+ toast(`Imported ${t.name} as ${r.body.created.join(', ')}. Nothing was sent to Resend.`);+ t.imported_as = r.body.created;+ loadList();+ if (overwrite) openImport();+ else {+ card.querySelector('.go').textContent = 'Re-import (overwrite drafts)';+ card.querySelector('.go').classList.remove('btn-accent');+ const hint = card.querySelector('.hint');+ hint.style.color = 'var(--green)';+ hint.textContent = 'Imported as local drafts.';+ }+ });+ refresh();+ return card;+}++// ---------- settings ----------+document.addEventListener('click', e => { if (e.target.closest('[data-open-settings]')) openSettings(); });+$('settingsBtn').addEventListener('click', openSettings);+$('resendStatus').addEventListener('click', openSettings);++function keyNote(k, which) {+ if (which === 'sendgrid') {+ if (k.source === 'none') return ['Optional. Only used to import templates from SendGrid (read-only).', ''];+ return [`Saved: ${k.masked}${k.source === 'env' ? ' (from the SENDGRID_API_KEY environment variable, which wins over Settings)' : ''}`, 'ok'];+ }+ const where = k.source === 'env' ? ' (from the RESEND_API_KEY environment variable, which wins over Settings)' : '';+ if (k.source === 'none') return ['Create one at resend.com/api-keys. Use Full access: Sending access keys can\'t manage templates.', ''];+ if (k.access === 'full') return [`Connected: ${k.masked}, full access${where}.`, 'ok'];+ if (k.access === 'sending') return [`${k.masked} is a Sending access key${where}: test sends work, but listing, uploading, and publishing templates need a Full access key.`, 'warn'];+ if (k.access === 'invalid') return [`${k.masked} was rejected by Resend${where}: ${k.error || ''}`, 'bad'];+ return [`${k.masked}: couldn't reach Resend to check it${where}. ${k.error || ''}`, 'warn'];+}++async function openSettings() {+ const r = await api('api/settings');+ if (!r.ok) { toast(r.body.error || 'could not load settings', 'error'); return; }+ const st = r.body;+ const [rn, rc] = keyNote(st.resend, 'resend');+ const [sn, sc] = keyNote(st.sendgrid, 'sendgrid');+ const wrap = document.createElement('div');+ wrap.innerHTML = `+ <div class="settings-grid">+ <div class="settings-section">Resend</div>+ <span class="k">API key</span><input type="password" id="setResend" autocomplete="off" spellcheck="false" placeholder="${st.resend.masked ? 'leave blank to keep ' + escapeHtml(st.resend.masked) : 're_...'}">+ <p class="settings-note ${rc}" id="resendNote">${escapeHtml(rn)}</p>+ <div class="settings-section">Defaults for new templates and test sends</div>+ <span class="k">From</span><input type="text" id="setFrom" placeholder="Acme <[email protected]>" value="${escapeHtml(st.default_from)}">+ <p class="settings-note">Must be on a domain you verified in Resend.</p>+ <span class="k">Reply-to</span><input type="text" id="setReplyTo" placeholder="optional" value="${escapeHtml(st.default_reply_to)}">+ <span class="k">Test recipient</span><input type="text" id="setTestTo" placeholder="[email protected]" value="${escapeHtml(st.default_test_to)}">+ <div class="settings-section">SendGrid import (optional)</div>+ <span class="k">API key</span><input type="password" id="setSendgrid" autocomplete="off" spellcheck="false" placeholder="${st.sendgrid.masked ? 'leave blank to keep ' + escapeHtml(st.sendgrid.masked) : 'SG....'}">+ <p class="settings-note ${sc}">${escapeHtml(sn)}</p>+ </div>+ <p style="margin-top:18px">Keys are stored on this machine only, in <code>${escapeHtml(st.file)}</code> (owner-readable), and are only ever sent to Resend's and SendGrid's own APIs. The browser never sees them again after you save.</p>`;+ const buttons = [{ label: 'Cancel' }];+ if (st.resend.source === 'saved') buttons.unshift({ label: 'Remove Resend key', cls: 'btn-danger', onClick: async () => {+ if (!(await confirmModal('Remove Resend key', 'Forget the saved Resend API key on this machine? Local drafts are kept.', 'Remove', true))) { openSettings(); return; }+ const d = await api('api/settings', { method: 'POST', body: { resend_api_key: '' } });+ if (!d.ok) toast(d.body.error, 'error'); else toast('Resend key removed.');+ await pollState(); loadList();+ } });+ buttons.push({ label: 'Save', cls: 'btn-accent', onClick: async () => {+ const body = { default_from: $('setFrom').value, default_reply_to: $('setReplyTo').value, default_test_to: $('setTestTo').value };+ if ($('setResend').value.trim()) body.resend_api_key = $('setResend').value.trim();+ if ($('setSendgrid').value.trim()) body.sendgrid_api_key = $('setSendgrid').value.trim();+ const d = await api('api/settings', { method: 'POST', body });+ if (!d.ok) { toast(d.body.error, 'error'); return true; }+ const [note, cls] = keyNote(d.body.resend, 'resend');+ toast(note, cls === 'ok' ? 'ok' : cls === 'bad' ? 'error' : 'warn', 7000);+ await pollState();+ loadList();+ if (current) selectTemplate(current.key, true);+ } });+ openModal({ title: 'Settings', narrow: false, body: wrap, buttons });+ $('modal').style.width = 'min(640px, 100%)';+ ($('setResend').placeholder.startsWith('re_') ? $('setResend') : $('setFrom')).focus();+}++// ---------- boot ----------+(async function boot() {+ await pollState();+ setInterval(pollState, 4000);+ await loadList();+ if (!appState.resend_configured && !lsGet('resend-studio:settings-seen')) { lsSet('resend-studio:settings-seen', '1'); openSettings(); }+ const last = lsGet('resend-studio:last');+ if (last && templates.some(t => t.key === last)) selectTemplate(last);+})();
ui/index.htmladded+168@@ -0,0 +1,168 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1.0">+<title>Resend Studio</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<link rel="stylesheet" href="style.css">+</head>+<body>++<header>+ <div class="brand" data-tooltip-target="app-info" tabindex="0">+ <img class="logo" src="favicon.svg" alt="" aria-hidden="true">+ <span class="app-name">adom-resend-studio</span>+ <span class="sep">/</span>+ <span class="app-subtitle">Resend template editor</span>+ </div>++ <div class="vsep" aria-hidden="true"></div>++ <div class="subject">+ <span class="subject-name" id="subjectName">No template selected</span>+ <span class="subject-meta" id="subjectMeta"></span>+ </div>++ <div class="actions">+ <span id="sendgridStatus" class="status-pill" data-tooltip="Whether SendGrid import is available (add a SendGrid key in Settings). Import is read-only: it pulls each SendGrid template's ACTIVE version and never writes to SendGrid.">sendgrid</span>+ <button id="resendStatus" class="status-pill as-btn" data-tooltip="Your Resend connection. Click to open Settings.">checking</button>+ <button id="settingsBtn" class="icon-btn" data-tooltip="Settings: your Resend API key, an optional SendGrid key for importing, and sender defaults. Everything stays on this machine.">⚙</button>+ </div>++ <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+ <div class="popover-title">adom-resend-studio</div>+ <div class="popover-row"><span class="k">about</span><span class="v">Edit, preview, test, and publish Resend email templates. Edits stay local until you upload; uploads only change Resend's draft; real email changes only when you publish.</span></div>+ <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.0</span></div>+ <div class="popover-row"><span class="k">shortcut</span><span class="v">Ctrl+S saves the local draft</span></div>+ </div>+</header>++<div class="layout">+ <aside class="sidebar">+ <div class="sidebar-header">+ <span>templates</span>+ <div class="sidebar-actions">+ <button id="newBtn" class="icon-btn" data-tooltip="Start a new template from a blank starter. It is created as a local draft only; nothing reaches Resend until you upload it.">+</button>+ <button id="importBtn" class="icon-btn wide" data-tooltip="Import templates from SendGrid. Converts Handlebars to Resend's {{{VAR}}} syntax and lets you decide what happens to each {{#if}} block, since Resend has no conditionals.">import</button>+ <button id="refreshBtn" class="icon-btn" data-tooltip="Reload the template list from Resend. Local drafts are never touched by a refresh.">↻</button>+ </div>+ </div>+ <div class="list-filter">+ <input type="search" id="filterInput" placeholder="Filter templates" data-tooltip="Filter by name or alias.">+ </div>+ <div id="remoteError" class="remote-error" hidden></div>+ <div id="templateList" class="template-list">+ <div class="empty-state">Loading...</div>+ </div>+ <div class="legend">+ <span><i class="dot live"></i>published</span>+ <span><i class="dot draft"></i>on Resend, unpublished</span>+ <span><i class="dot local"></i>local only</span>+ </div>+ </aside>++ <main class="editor" id="editorArea">+ <div class="empty-state large">+ <div class="big-title">Pick a template on the left</div>+ <div>or <button class="link-btn" id="emptyImportBtn">import from SendGrid</button> to start the migration.</div>+ </div>+ </main>+</div>++<template id="editorTemplate">+ <div class="status-line" id="statusLine"></div>++ <div class="meta-grid">+ <label class="field"><span>Name</span><input type="text" id="nameInput" data-tooltip="Display name in Resend's dashboard. Not shown to recipients."></label>+ <label class="field"><span>Alias</span><input type="text" id="aliasInput" class="mono" data-tooltip="The stable handle your code sends by, e.g. welcome-email. Changing it on a template that is already live breaks every sender still using the old alias."></label>+ <label class="field wide"><span>Subject</span><input type="text" id="subjectInput" data-tooltip="Default subject. Variables work here too, as {{{KEY}}}."></label>+ <label class="field"><span>From</span><input type="text" id="fromInput" data-tooltip="Default sender, e.g. Acme <[email protected]>, on a domain verified in Resend. A sender can override it per email."></label>+ <label class="field"><span>Reply-to</span><input type="text" id="replyToInput" data-tooltip="Default reply-to address."></label>+ <div class="toolbar-actions">+ <button id="saveBtn" class="btn" data-tooltip="Save to this app's local database (Ctrl+S). Does not touch Resend.">Save</button>+ <button id="uploadBtn" class="btn btn-accent" data-tooltip="Save, then send this draft to Resend. Creates the template if it doesn't exist there yet; otherwise updates Resend's DRAFT. Live email keeps using the published version until you publish.">Upload to Resend</button>+ <button id="publishBtn" class="btn btn-danger" data-tooltip="Make Resend's current draft the version real emails use. Asks you to type the alias to confirm.">Publish</button>+ <button id="moreBtn" class="icon-btn" data-tooltip="More actions">⋯</button>+ <div class="menu" id="moreMenu" hidden>+ <button data-act="load-remote">Replace editor with Resend's published copy</button>+ <button data-act="discard">Discard local draft</button>+ <button data-act="copy-json">Copy send snippet</button>+ </div>+ </div>+ </div>++ <div class="editor-panes">+ <div class="pane">+ <div class="pane-header">HTML <span class="pane-sub">variables are {{{KEY}}}; no conditionals or loops</span></div>+ <textarea id="htmlInput" class="code-area" spellcheck="false"></textarea>+ </div>+ <div class="pane">+ <div class="pane-header">+ Preview <span class="pane-sub">substituted the way Resend does it</span>+ <div class="seg" id="widthSeg">+ <button data-w="desktop" class="on" data-tooltip="Full pane width.">desktop</button>+ <button data-w="mobile" data-tooltip="375 px, a typical phone.">mobile</button>+ </div>+ </div>+ <div class="inbox-head">+ <div class="ih-row"><span class="k">from</span><span id="pvFrom"></span></div>+ <div class="ih-row"><span class="k">subject</span><span id="pvSubject" class="pv-subject"></span></div>+ </div>+ <div class="preview-wrap" id="previewWrap">+ <iframe id="previewFrame" class="preview-frame" title="Template preview" sandbox="allow-same-origin"></iframe>+ </div>+ </div>+ </div>++ <div class="lower-panes">+ <div class="pane">+ <div class="pane-header">+ Variables <span class="pane-sub" id="varCount"></span>+ <button id="syncVarsBtn" class="icon-btn wide small" data-tooltip="Add a row for every {{{KEY}}} used in the subject or HTML that isn't declared yet.">sync from HTML</button>+ <button id="addVarBtn" class="icon-btn small" data-tooltip="Add a variable row.">+</button>+ </div>+ <div class="var-head">+ <span>key</span><span>type</span>+ <span data-tooltip="Used when a sender leaves the variable out. No fallback means the variable is required: Resend rejects a send without it.">fallback</span>+ <span data-tooltip="Local only: the value used for the preview and draft test sends. Never uploaded.">sample</span><span></span>+ </div>+ <div id="variablesTable" class="variables-table"></div>+ </div>++ <div class="pane side-stack">+ <div class="pane-header">Checks <span class="pane-sub" id="lintCount"></span></div>+ <div id="lintList" class="lint-list"></div>++ <div class="pane-header">Send a test</div>+ <div class="send-test">+ <input type="email" id="testEmailInput" placeholder="[email protected]" data-tooltip="Where the test goes. Only used for this send.">+ <select id="testModeSelect" data-tooltip="'This draft' renders the editor's content here with your sample values and sends it as plain HTML, so you can test before uploading or publishing. 'Published version' sends through Resend's template engine, which only works once the template is published.">+ <option value="draft">this draft</option>+ <option value="published">published version</option>+ </select>+ <button id="sendTestBtn" class="btn" data-tooltip="Send one real email. The subject gets a [TEST] prefix in draft mode.">Send test</button>+ </div>++ <div class="pane-header">Notes <span class="pane-sub">local, for whoever picks this up next</span></div>+ <textarea id="notesInput" class="notes-area" placeholder="Migration decisions, who sends this, what to watch for"></textarea>++ <div class="pane-header">On Resend</div>+ <div id="remoteInfo" class="remote-info"></div>+ </div>+ </div>+</template>++<div class="modal-backdrop" id="modalBackdrop" hidden>+ <div class="modal" id="modal" role="dialog" aria-modal="true">+ <div class="modal-head"><span id="modalTitle"></span><button class="icon-btn" id="modalClose" aria-label="Close">×</button></div>+ <div class="modal-body" id="modalBody"></div>+ <div class="modal-foot" id="modalFoot"></div>+ </div>+</div>++<div id="toasts" class="toasts" aria-live="polite"></div>++<script src="app.js"></script>+</body>+</html>
ui/style.cssadded+269@@ -0,0 +1,269 @@+@font-face { font-family: 'Familjen Grotesk'; src: url('https://wiki.adom.inc/static/fonts/familjen-grotesk-700.woff2') format('woff2'); font-weight: 600 700; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://wiki.adom.inc/static/fonts/satoshi-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://wiki.adom.inc/static/fonts/satoshi-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }++:root {+ --bg: #0d1117;+ --surface: #161b22;+ --surface2: #1c2129;+ --elevated: #1c2128;+ --border: #30363d;+ --border-muted: #21262d;+ --text: #e6edf3;+ --text2: #8b949e;+ --text3: #59616b;+ --accent: #00b8b0;+ --accent-ink: #04211f;+ --green: #3fb950;+ --red: #f85149;+ --yellow: #d29922;+ --blue: #58a6ff;++ --z-floating-menu: 200;+ --z-toast: 400;+ --z-modal: 600;+ --z-tooltip: 99999;+}++* { box-sizing: border-box; }+html, body {+ margin: 0; padding: 0; height: 100%;+ background: var(--bg); color: var(--text);+ font: 13px/1.45 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;+}+button { font-family: inherit; cursor: pointer; }+input, textarea, select { font-family: inherit; }+.mono { font-family: 'JetBrains Mono', monospace !important; font-size: 12px !important; }+[hidden] { display: none !important; }++/* ---------- Header ---------- */+header {+ display: flex; align-items: center; gap: 12px;+ padding: 0 16px; height: 44px;+ border-bottom: 1px solid var(--border-muted);+ background: var(--surface);+ position: relative; flex-shrink: 0;+ font-family: 'Familjen Grotesk', sans-serif;+}+header .brand { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; cursor: default; flex-shrink: 0; outline: none; }+header .brand .logo { width: 18px; height: 18px; }+header .brand .app-name { color: var(--accent); }+header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+header .brand .sep { color: var(--text3); }+header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }+header .subject { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }+header .subject-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }+header .actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-shrink: 0; }++.status-pill { font-family: 'Satoshi', sans-serif; font-weight: 500; font-size: 11px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--text2); }+.status-pill.ok { color: var(--green); border-color: rgba(63,185,80,0.4); }+.status-pill.bad { color: var(--red); border-color: rgba(248,81,73,0.4); }+.status-pill.off { color: var(--text3); }+.status-pill.warn { color: var(--yellow); border-color: rgba(210,153,34,0.45); }+.status-pill.as-btn { background: none; cursor: pointer; }+.status-pill.as-btn:hover { border-color: var(--text2); }+.settings-grid { display: grid; grid-template-columns: 130px 1fr; gap: 10px 14px; align-items: center; }+.settings-grid .k { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); }+.settings-grid input { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 8px 10px; font-size: 13px; }+.settings-note { font-size: 12px; color: var(--text2); margin: 4px 0 0; grid-column: 2; }+.settings-note.ok { color: var(--green); }+.settings-note.warn { color: var(--yellow); }+.settings-note.bad { color: var(--red); }+.settings-section { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; font-size: 13px; margin: 18px 0 8px; grid-column: 1 / -1; }+.settings-section:first-child { margin-top: 0; }+.modal-body a { color: var(--accent); }++header .hover-popover {+ position: absolute; top: calc(100% + 6px); left: 16px;+ background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);+ padding: 12px 14px; min-width: 300px; max-width: 440px;+ font-family: 'Satoshi', sans-serif; font-size: 12px;+ z-index: var(--z-floating-menu);+ opacity: 0; transform: translateY(-2px); pointer-events: none;+ transition: opacity 120ms ease, transform 120ms ease;+}+header .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+.popover-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 700; margin-bottom: 8px; }+.popover-row { display: flex; gap: 12px; padding: 3px 0; align-items: baseline; }+.popover-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; width: 64px; flex-shrink: 0; }+.popover-row .v { flex: 1; }++#global-tooltip {+ position: fixed; z-index: var(--z-tooltip); top: 0; left: 0;+ transform: translate(var(--tip-x, -9999px), var(--tip-y, -9999px));+ background: rgba(22,27,34,0.97); backdrop-filter: blur(16px);+ border: 1px solid rgba(0,184,177,0.25); border-radius: 10px;+ padding: 10px 14px; font-size: 12px; line-height: 1.55; color: #e6edf3;+ max-width: min(320px, calc(100vw - 40px)); white-space: pre-wrap;+ pointer-events: none; box-shadow: 0 12px 40px rgba(0,0,0,0.55);+ opacity: 0; display: none; transition: opacity 0.18s ease;+}+#global-tooltip.visible { display: block; }+#global-tooltip.shown { opacity: 1; }++/* ---------- Layout ---------- */+.layout { display: flex; height: calc(100vh - 44px); }++.sidebar { width: 290px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border-muted); display: flex; flex-direction: column; overflow: hidden; }+.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 10px 14px; font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 12px; color: var(--text2); border-bottom: 1px solid var(--border-muted); }+.sidebar-actions { display: flex; gap: 6px; }+.list-filter { padding: 8px 12px; border-bottom: 1px solid var(--border-muted); }+.list-filter input { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 6px 9px; font-size: 12px; }+.remote-error { margin: 8px 12px; padding: 8px 10px; border-radius: 6px; font-size: 11.5px; color: var(--yellow); background: rgba(210,153,34,0.08); border: 1px solid rgba(210,153,34,0.3); }+.template-list { overflow-y: auto; flex: 1; }+.template-item { padding: 9px 14px; border-bottom: 1px solid var(--border-muted); cursor: pointer; border-left: 2px solid transparent; }+.template-item:hover { background: var(--surface2); }+.template-item.active { background: var(--surface2); border-left-color: var(--accent); }+.template-item .row1 { display: flex; align-items: center; gap: 7px; }+.template-item .name { font-weight: 500; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.template-item .alias { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text3); margin: 2px 0 0 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.template-item .badges { display: flex; gap: 4px; flex-wrap: wrap; margin: 4px 0 0 15px; }+.badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; border: 1px solid var(--border); color: var(--text2); white-space: nowrap; }+.badge.warn { color: var(--yellow); border-color: rgba(210,153,34,0.4); }+.badge.info { color: var(--blue); border-color: rgba(88,166,255,0.35); }+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; background: var(--text3); }+.dot.live { background: var(--green); }+.dot.draft { background: var(--yellow); }+.dot.local { background: transparent; border: 1.5px solid var(--text2); }+.legend { display: flex; gap: 10px; flex-wrap: wrap; padding: 8px 14px; border-top: 1px solid var(--border-muted); font-size: 10.5px; color: var(--text3); }+.legend span { display: inline-flex; align-items: center; gap: 5px; }++.editor { flex: 1; display: flex; flex-direction: column; overflow: auto; padding: 14px 16px 16px; gap: 12px; min-width: 0; }+.empty-state { color: var(--text2); padding: 24px; text-align: center; }+.empty-state.large { margin: auto; font-size: 13px; }+.big-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 18px; font-weight: 700; color: var(--text); margin-bottom: 6px; }+.link-btn { background: none; border: none; color: var(--accent); padding: 0; font-size: inherit; text-decoration: underline; text-underline-offset: 3px; }++.status-line { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; font-size: 12px; color: var(--text2); min-height: 20px; }+.status-line .chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border); }+.status-line .chip.ok { color: var(--green); border-color: rgba(63,185,80,0.35); }+.status-line .chip.warn { color: var(--yellow); border-color: rgba(210,153,34,0.4); }+.status-line .chip.unsaved { color: var(--accent); border-color: rgba(0,184,176,0.45); }++.meta-grid { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 10px 12px; align-items: end; }+.field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }+.field.wide { grid-column: span 1; }+.field span { font-size: 11px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }+.field input { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 7px 10px; font-size: 13px; min-width: 0; }+.field input:focus, .code-area:focus, .notes-area:focus { outline: 1px solid rgba(0,184,176,0.5); }+.toolbar-actions { display: flex; align-items: center; gap: 8px; justify-content: flex-end; position: relative; }++.btn { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 7px 13px; font-size: 13px; font-weight: 500; white-space: nowrap; }+.btn:hover:not(:disabled) { border-color: var(--text2); }+.btn:disabled { opacity: 0.45; cursor: default; }+.btn-accent { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }+.btn-accent:hover:not(:disabled) { filter: brightness(1.08); border-color: var(--accent); }+.btn-danger { color: #ffb4ae; border-color: rgba(248,81,73,0.45); }+.btn-danger:hover:not(:disabled) { background: rgba(248,81,73,0.12); border-color: var(--red); }+.btn.pulse { border-color: var(--accent); color: var(--accent); }++.icon-btn { background: transparent; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); min-width: 26px; height: 26px; font-size: 14px; line-height: 1; padding: 0 6px; }+.icon-btn.wide { font-size: 11px; padding: 0 8px; }+.icon-btn.small { height: 22px; min-width: 22px; font-size: 12px; }+.icon-btn.wide.small { font-size: 10.5px; }+.icon-btn:hover { color: var(--text); border-color: var(--text2); }++.menu { position: absolute; top: calc(100% + 6px); right: 0; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 4px; z-index: var(--z-floating-menu); box-shadow: 0 8px 24px rgba(0,0,0,0.5); min-width: 240px; }+.menu button { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--text); padding: 7px 10px; border-radius: 5px; font-size: 12.5px; }+.menu button:hover { background: var(--surface2); }++.editor-panes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; flex: 1 0 420px; min-height: 420px; }+.lower-panes { display: grid; grid-template-columns: 1.3fr 1fr; gap: 12px; flex: 0 0 auto; }++.pane { background: var(--surface); border: 1px solid var(--border-muted); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }+.pane-header { padding: 9px 14px; border-bottom: 1px solid var(--border-muted); font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 12px; display: flex; align-items: center; gap: 8px; }+.pane-header .icon-btn:first-of-type { margin-left: auto; }+.side-stack .pane-header { border-top: 1px solid var(--border-muted); }+.side-stack .pane-header:first-child { border-top: none; }+.pane-sub { font-family: 'Satoshi', sans-serif; font-weight: 400; font-size: 11px; color: var(--text2); }++.code-area { flex: 1; resize: none; border: none; background: var(--bg); color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.55; padding: 12px; tab-size: 2; white-space: pre; }+.seg { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }+.seg button { background: none; border: none; color: var(--text2); font-size: 11px; padding: 3px 9px; }+.seg button.on { background: var(--surface2); color: var(--text); }+.inbox-head { padding: 8px 14px; border-bottom: 1px solid var(--border-muted); background: var(--surface2); font-size: 12px; }+.ih-row { display: flex; gap: 10px; padding: 1px 0; min-width: 0; }+.ih-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; width: 52px; flex-shrink: 0; padding-top: 1px; }+.ih-row span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.pv-subject { font-weight: 500; }+.pv-missing { color: var(--red); font-family: 'JetBrains Mono', monospace; font-size: 11px; }+.preview-wrap { flex: 1; display: flex; justify-content: center; background: #e9ebef; overflow: hidden; }+.preview-frame { flex: 1; width: 100%; border: none; background: #fff; }+.preview-wrap.mobile .preview-frame { flex: 0 0 375px; width: 375px; box-shadow: 0 0 0 1px #c9ccd2; }++.var-head, .var-row { display: grid; grid-template-columns: minmax(110px,1.1fr) 82px 1fr 1.2fr 24px; gap: 8px; align-items: center; }+.var-head { padding: 7px 14px 4px; font-size: 10.5px; color: var(--text3); font-family: 'JetBrains Mono', monospace; }+.variables-table { overflow-y: auto; padding: 0 14px 10px; max-height: 300px; }+.var-row { padding: 5px 0; border-bottom: 1px solid var(--border-muted); }+.var-row input, .var-row select { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 5px 8px; font-size: 12px; width: 100%; min-width: 0; }+.var-row input.k { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; }+.var-row input.k.bad { border-color: rgba(248,81,73,0.6); }+.var-row input.k.unused { color: var(--text2); }+.var-row .remove-var { background: none; border: none; color: var(--text3); font-size: 15px; }+.var-row .remove-var:hover { color: var(--red); }+.var-empty { color: var(--text3); font-size: 12px; padding: 10px 0; }++.lint-list { padding: 8px 14px 10px; display: flex; flex-direction: column; gap: 5px; max-height: 190px; overflow-y: auto; }+.lint { display: flex; gap: 8px; font-size: 12px; line-height: 1.45; align-items: baseline; }+.lint .lv { font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; width: 38px; flex-shrink: 0; }+.lint.error .lv { color: var(--red); }+.lint.warn .lv { color: var(--yellow); }+.lint.info .lv { color: var(--text3); }+.lint.info { color: var(--text2); }+.lint-ok { color: var(--green); font-size: 12px; }++.send-test { padding: 10px 14px; display: flex; gap: 8px; flex-wrap: wrap; }+.send-test input { flex: 1; min-width: 160px; }+.send-test input, .send-test select { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 7px 10px; font-size: 12.5px; }+.notes-area { margin: 10px 14px; min-height: 64px; resize: vertical; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 8px 10px; font-size: 12.5px; line-height: 1.5; }+.remote-info { padding: 10px 14px 14px; font-size: 12px; display: flex; flex-direction: column; gap: 3px; }+.remote-info .popover-row .k { width: 92px; }+.remote-info .popover-row .v { font-family: 'JetBrains Mono', monospace; font-size: 11px; word-break: break-all; }++/* ---------- Modal ---------- */+.modal-backdrop { position: fixed; inset: 0; background: rgba(1,4,9,0.7); z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; padding: 24px; }+.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; width: min(980px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; box-shadow: 0 24px 64px rgba(0,0,0,0.6); }+.modal.narrow { width: min(480px, 100%); }+.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border-muted); font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; font-size: 15px; }+.modal-body { padding: 16px 18px; overflow-y: auto; font-size: 13px; }+.modal-body p { margin: 0 0 10px; color: var(--text2); }+.modal-body input[type=text] { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 8px 10px; font-size: 13px; }+.modal-foot { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 18px; border-top: 1px solid var(--border-muted); }+.modal-foot:empty { display: none; }++.sg-card { border: 1px solid var(--border-muted); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; background: var(--surface2); }+.sg-top { display: flex; align-items: center; gap: 10px; }+.sg-name { font-weight: 600; font-size: 13.5px; }+.sg-id { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text3); }+.sg-subject { font-size: 12px; color: var(--text2); margin: 3px 0 8px; }+.sg-row { display: grid; grid-template-columns: 90px 1fr; gap: 10px; align-items: center; margin: 6px 0; font-size: 12px; }+.sg-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; }+.sg-row input { background: var(--bg) !important; padding: 5px 8px !important; font-size: 12px !important; }+.cond-table { display: grid; grid-template-columns: minmax(120px, 0.8fr) 220px 1fr; gap: 6px 10px; align-items: center; font-size: 12px; }+.cond-table .var { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; }+.cond-table input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 4px 8px; font-size: 12px; width: 100%; }+.cond-table .seg { margin-left: 0; }+.cond-table .seg button { padding: 3px 10px; }+.variants { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }+.variants code { font-family: 'JetBrains Mono', monospace; font-size: 11px; background: var(--bg); border: 1px solid var(--border-muted); padding: 2px 7px; border-radius: 5px; }+.sg-unsupported { color: var(--yellow); font-size: 12px; margin-top: 6px; }+.sg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; align-items: center; }+.sg-actions .hint { font-size: 11.5px; color: var(--text2); margin-right: auto; }++/* ---------- Toasts ---------- */+.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: var(--z-toast); max-width: 420px; }+.toast { background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px; font-size: 12.5px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: tin 160ms ease; white-space: pre-wrap; }+.toast.error { border-left-color: var(--red); }+.toast.warn { border-left-color: var(--yellow); }+@keyframes tin { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }++@media (max-width: 1200px) {+ .meta-grid { grid-template-columns: 1fr 1fr; }+ .toolbar-actions { grid-column: 1 / -1; justify-content: flex-start; }+ .editor-panes, .lower-panes { grid-template-columns: 1fr; }+ .editor-panes { flex-basis: auto; }+ .editor-panes .pane { min-height: 380px; }+}
uninstall.shadded+8@@ -0,0 +1,8 @@+#!/bin/sh+set -e++rm -f "$HOME/.local/bin/adom-resend-studio"+rm -rf "$HOME/.claude/skills/adom-resend-studio"+# Drafts and the env file in ~/.local/share/adom-resend-studio are kept.++echo "uninstalled"