Files
store/resend.sune

1 line
16 KiB
JSON

[{"id":"ayzmwqh","name":"Resend","pinned":false,"avatar":"","url":"gh://sune-org/store@main/resend.sune","updatedAt":1764807170440,"settings":{"model":"google/gemini-3-pro-preview","temperature":"","top_p":"","top_k":"","frequency_penalty":"","repetition_penalty":"","min_p":"","top_a":"","verbosity":"","reasoning_effort":"default","system_prompt":"","html":"<div x-data=\"resendMasterV5()\" x-init=\"init()\" class=\"w-full max-w-md mx-auto bg-slate-50 border border-slate-200 rounded-xl shadow-sm overflow-hidden font-sans text-slate-800\">\n <!-- Header -->\n <div class=\"bg-white p-4 border-b border-slate-200 flex justify-between items-center\">\n <div class=\"flex items-center gap-2\">\n <div class=\"bg-black text-white p-1 rounded\"><i data-lucide=\"send\" class=\"w-4 h-4\"></i></div>\n <h2 class=\"font-bold text-sm tracking-tight\">Resend Master</h2>\n </div>\n <span class=\"text-[10px] font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded\" x-text=\"'v'+v\"></span>\n </div>\n\n <!-- Tabs -->\n <div class=\"flex border-b border-slate-200 bg-white\">\n <template x-for=\"t in ['setup', 'contacts', 'compose']\">\n <button @click=\"tab = t\" :class=\"{'text-black border-black': tab === t, 'text-slate-500 border-transparent': tab !== t}\" class=\"flex-1 py-3 text-xs font-bold border-b-2 capitalize transition-colors\" x-text=\"t\"></button>\n </template>\n </div>\n\n <!-- Content -->\n <div class=\"p-4 min-h-[340px]\">\n \n <!-- SETUP -->\n <div x-show=\"tab === 'setup'\" class=\"space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300\">\n <div class=\"bg-amber-50 border border-amber-200 rounded p-3\">\n <p class=\"text-[10px] text-amber-800 leading-tight\"><strong>Note:</strong> A Cloudflare Worker proxy is required to bypass CORS. Paste your worker URL below.</p>\n </div>\n <div>\n <label class=\"block text-xs font-bold text-slate-700 mb-1\">Worker Proxy URL</label>\n <input type=\"text\" x-model=\"proxy\" @input=\"save\" class=\"w-full bg-white border border-slate-300 rounded px-3 py-2 text-xs font-mono text-slate-600 focus:ring-2 focus:ring-black/5 focus:border-black outline-none transition-all\" placeholder=\"https://my-worker.usr.workers.dev\">\n </div>\n <div>\n <label class=\"block text-xs font-bold text-slate-700 mb-1\">Resend API Key</label>\n <input type=\"password\" x-model=\"k\" @input=\"save\" class=\"w-full bg-white border border-slate-300 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-black/5 focus:border-black outline-none transition-all\" placeholder=\"re_123...\">\n </div>\n <div class=\"pt-2\">\n <button @click=\"testConnection\" :disabled=\"loading\" class=\"w-full bg-slate-100 text-slate-600 border border-slate-200 py-2 rounded text-xs font-bold hover:bg-slate-200 transition-colors flex justify-center items-center gap-2\">\n <span x-text=\"loading ? 'Checking...' : 'Test Connection'\"></span>\n <i x-show=\"!loading\" data-lucide=\"wifi\" class=\"w-3 h-3\"></i>\n </button>\n <p x-show=\"msg && tab === 'setup'\" x-text=\"msg\" :class=\"err ? 'text-red-500' : 'text-emerald-600'\" class=\"text-xs text-center font-medium mt-2\"></p>\n </div>\n </div>\n\n <!-- CONTACTS -->\n <div x-show=\"tab === 'contacts'\" class=\"space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-300\">\n <div class=\"flex justify-between items-center bg-slate-100 p-2 rounded border border-slate-200\">\n <div class=\"flex flex-col\">\n <span class=\"text-xs font-bold text-slate-700\" x-text=\"contacts.length + ' Total'\"></span>\n <span class=\"text-[10px] text-slate-500\" x-text=\"subCount + ' Subscribed'\"></span>\n </div>\n <button @click=\"syncContacts\" :disabled=\"loading\" class=\"flex items-center gap-1 bg-white border border-slate-300 shadow-sm px-2 py-1 rounded text-xs font-bold hover:bg-slate-50 disabled:opacity-50 transition-all active:scale-95\">\n <i :class=\"loading ? 'animate-spin' : ''\" data-lucide=\"refresh-cw\" class=\"w-3 h-3\"></i>\n <span x-text=\"loading ? 'Syncing...' : 'Sync Contacts'\"></span>\n </button>\n </div>\n \n <div class=\"flex justify-between items-center px-1\">\n <div class=\"flex gap-2\">\n <button @click=\"toggleAll\" class=\"text-[10px] uppercase font-bold text-slate-500 hover:text-black transition-colors\">Select All Subs</button>\n <button @click=\"contacts.forEach(c=>c.sel=false)\" class=\"text-[10px] uppercase font-bold text-slate-500 hover:text-red-500 transition-colors\">None</button>\n </div>\n <span class=\"text-[10px] font-mono text-slate-400\" x-text=\"selCount + ' Selected'\"></span>\n </div>\n\n <div class=\"h-52 overflow-y-auto border border-slate-200 rounded bg-white divide-y divide-slate-100 scrollbar-thin relative\">\n <template x-for=\"c in contacts\" :key=\"c.email\">\n <div class=\"flex items-center p-2 hover:bg-slate-50 transition-colors group\" :class=\"{'opacity-50 bg-slate-50': c.status === 'unsubscribed'}\">\n <!-- Checkbox -->\n <div @click=\"if(c.status === 'subscribed') c.sel = !c.sel\" \n class=\"w-4 h-4 border rounded flex items-center justify-center mr-3 transition-colors cursor-pointer\"\n :class=\"c.sel ? 'bg-black border-black' : (c.status === 'unsubscribed' ? 'bg-slate-100 border-slate-200 cursor-not-allowed' : 'bg-white border-slate-300 group-hover:border-slate-400')\">\n <i x-show=\"c.sel\" data-lucide=\"check\" class=\"w-3 h-3 text-white\"></i>\n </div>\n \n <!-- Info -->\n <div class=\"flex-1 min-w-0\">\n <p class=\"text-xs font-medium truncate text-slate-700\" x-text=\"c.email\"></p>\n </div>\n\n <!-- Status Badge -->\n <span class=\"ml-2 text-[9px] uppercase font-bold px-1.5 py-0.5 rounded border\"\n :class=\"c.status === 'subscribed' ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-red-50 text-red-600 border-red-100'\"\n x-text=\"c.status === 'subscribed' ? 'SUB' : 'UNSUB'\">\n </span>\n </div>\n </template>\n \n <!-- Empty State -->\n <div x-show=\"contacts.length === 0 && !loading\" class=\"absolute inset-0 flex flex-col items-center justify-center p-8 text-center\">\n <i data-lucide=\"users\" class=\"w-8 h-8 text-slate-200 mb-2\"></i>\n <p class=\"text-xs text-slate-400\">No contacts found.</p>\n <button @click=\"syncContacts\" class=\"mt-2 text-[10px] text-blue-500 hover:underline\">Try Syncing</button>\n </div>\n \n <!-- Loading State Overlay -->\n <div x-show=\"loading && tab === 'contacts'\" class=\"absolute inset-0 bg-white/80 flex items-center justify-center z-10\">\n <i data-lucide=\"loader-2\" class=\"w-6 h-6 animate-spin text-black\"></i>\n </div>\n </div>\n </div>\n\n <!-- COMPOSE -->\n <div x-show=\"tab === 'compose'\" class=\"space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-300\">\n <div class=\"grid grid-cols-2 gap-2\">\n <div>\n <label class=\"block text-[10px] uppercase font-bold text-slate-400 mb-1\">From</label>\n <input type=\"text\" x-model=\"from\" @input=\"save\" class=\"w-full border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-black outline-none\" placeholder=\"me@domain.com\">\n </div>\n <div>\n <label class=\"block text-[10px] uppercase font-bold text-slate-400 mb-1\">Reply To</label>\n <input type=\"text\" x-model=\"reply\" @input=\"save\" class=\"w-full border border-slate-300 rounded px-2 py-1.5 text-sm focus:border-black outline-none\">\n </div>\n </div>\n <div>\n <label class=\"block text-[10px] uppercase font-bold text-slate-400 mb-1\">Subject</label>\n <input type=\"text\" x-model=\"sub\" @input=\"save\" class=\"w-full border border-slate-300 rounded px-2 py-1.5 text-sm font-medium focus:border-black outline-none\">\n </div>\n <div>\n <label class=\"block text-[10px] uppercase font-bold text-slate-400 mb-1\">HTML Body</label>\n <textarea x-model=\"body\" @input=\"save\" class=\"w-full h-20 border border-slate-300 rounded px-2 py-1.5 text-xs font-mono resize-none focus:border-black outline-none\" placeholder=\"<h1>Hello</h1>\"></textarea>\n </div>\n \n <div class=\"pt-2\">\n <button @click=\"sendBatch\" :disabled=\"loading || selCount === 0\" class=\"w-full py-2.5 rounded-lg bg-black text-white font-bold text-sm shadow-lg shadow-slate-200 hover:bg-slate-800 hover:shadow-xl hover:-translate-y-0.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none flex justify-center items-center gap-2\">\n <span x-show=\"!loading\">Send to <span x-text=\"selCount\"></span> Subscribers</span>\n <i x-show=\"loading\" data-lucide=\"loader-2\" class=\"w-4 h-4 animate-spin\"></i>\n </button>\n <p x-show=\"msg && tab === 'compose'\" x-text=\"msg\" :class=\"err ? 'text-red-500' : 'text-emerald-600'\" class=\"text-xs text-center font-medium mt-2 animate-pulse\"></p>\n </div>\n </div>\n </div>\n</div>\n\n<script>\nfunction resendMasterV5() {\n return {\n v: '1.5.0',\n tab: 'setup',\n k: '', proxy: '', \n from: '', reply: '', sub: '', body: '',\n contacts: [], \n loading: false, msg: '', err: false,\n \n get selCount() { return this.contacts.filter(c => c.sel && c.status === 'subscribed').length; },\n get subCount() { return this.contacts.filter(c => c.status === 'subscribed').length; },\n get pUrl() { return this.proxy.replace(/\\/$/, ''); },\n \n init() {\n const id = window.SUNE?.id || 'resend_master_v5';\n const s = JSON.parse(localStorage.getItem(id) || '{}');\n this.k = s.k || ''; this.proxy = s.proxy || '';\n this.from = s.from || ''; this.reply = s.reply || '';\n this.sub = s.sub || ''; this.body = s.body || '';\n this.contacts = s.contacts || [];\n this.$nextTick(() => lucide.createIcons());\n },\n \n save() {\n const id = window.SUNE?.id || 'resend_master_v5';\n localStorage.setItem(id, JSON.stringify({\n k: this.k, proxy: this.proxy,\n from: this.from, reply: this.reply, sub: this.sub, body: this.body,\n contacts: this.contacts\n }));\n },\n\n toggleAll() {\n const allSubbed = this.contacts.filter(c => c.status === 'subscribed');\n const allSel = allSubbed.every(c => c.sel);\n this.contacts.forEach(c => {\n if(c.status === 'subscribed') c.sel = !allSel;\n });\n this.save();\n },\n\n async testConnection() {\n if(!this.k || !this.proxy) return this.notify('API Key & Proxy required', true);\n this.loading = true; this.msg = 'Testing...';\n try {\n // Simple GET to /contacts to verify auth\n const res = await fetch(`${this.pUrl}/contacts`, { headers: { 'Authorization': `Bearer ${this.k}` }});\n if(!res.ok) throw new Error('Connection failed. Check Key/Proxy.');\n this.notify(`Connected! Ready to sync.`);\n } catch(e) { this.notify(e.message, true); }\n this.loading = false;\n },\n\n async syncContacts() {\n if(!this.k || !this.proxy) return this.notify('Configure Setup first', true);\n this.loading = true; this.msg = '';\n \n try {\n // Direct fetch to /contacts as requested\n const res = await fetch(`${this.pUrl}/contacts`, { headers: { 'Authorization': `Bearer ${this.k}` }});\n if(!res.ok) throw new Error('Failed to fetch contacts');\n const data = await res.json();\n \n if(data.data) {\n const newMap = new Map();\n data.data.forEach(c => {\n const status = c.unsubscribed ? 'unsubscribed' : 'subscribed';\n newMap.set(c.email, status);\n });\n\n const merged = [];\n // Preserve selection of existing contacts\n this.contacts.forEach(old => {\n if(newMap.has(old.email)) {\n merged.push({ email: old.email, sel: old.sel, status: newMap.get(old.email) });\n newMap.delete(old.email);\n }\n });\n // Add new ones\n newMap.forEach((status, email) => {\n merged.push({ email, sel: false, status });\n });\n \n this.contacts = merged;\n this.save();\n this.notify(`Synced! ${this.subCount} subscribers.`);\n } else {\n this.notify('No data returned from API.', true);\n }\n } catch(e) { \n console.error(e);\n this.notify(e.message, true); \n }\n this.loading = false;\n },\n\n async sendBatch() {\n if(!this.k || !this.from || !this.sub || !this.body) return this.notify('Missing fields', true);\n \n const targets = this.contacts.filter(c => c.sel && c.status === 'subscribed').map(c => c.email);\n if(targets.length === 0) return this.notify('No subscribed contacts selected', true);\n\n this.loading = true; this.msg = 'Sending...';\n \n const chunks = [];\n for (let i = 0; i < targets.length; i += 100) chunks.push(targets.slice(i, i + 100));\n\n try {\n let sent = 0;\n for(const chunk of chunks) {\n const payload = chunk.map(to => ({\n from: this.from, to: [to], subject: this.sub, html: this.body,\n reply_to: this.reply || undefined\n }));\n \n const res = await fetch(`${this.pUrl}/emails/batch`, {\n method: 'POST',\n headers: { 'Authorization': `Bearer ${this.k}`, 'Content-Type': 'application/json' },\n body: JSON.stringify(payload)\n });\n if(!res.ok) throw new Error('Batch send failed');\n sent += chunk.length;\n }\n this.notify(`Sent to ${sent} subscribers!`);\n this.contacts.forEach(c => c.sel = false);\n this.save();\n } catch(e) { this.notify(e.message, true); }\n this.loading = false;\n },\n\n notify(m, isErr=false) {\n this.msg = m; this.err = isErr;\n setTimeout(() => this.msg = '', 4000);\n }\n }\n}\n</script>\n","extension_html":"<sune src='https://raw.githubusercontent.com/sune-org/store/refs/heads/main/sync.sune' private></sune>","hide_composer":false,"include_thoughts":false,"json_output":false,"img_output":false,"ignore_master_prompt":false,"json_schema":""},"storage":{}}]