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