From e3f07057d9738a16457a36e9309844ab2880ddcd Mon Sep 17 00:00:00 2001 From: martbost Date: Thu, 13 Aug 2026 13:03:26 -0500 Subject: [PATCH] Send automatic "you've been paid" emails via SendGrid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the chain tail sees a payout to a member whose sponsor record has a contact email, the site emails them the amount, source, and Polygonscan link (plain text — deliverability). SendGrid key is pasted in a new admin panel (stored in the data volume, like the OpenRouter key) or set via SENDGRID_API_KEY. From address defaults to no-reply@mybrandedvoice.com — the domain SendGrid is DKIM-authenticated for — and is editable in the same panel. Not gated on teamRootId; any member with an email on file. Co-Authored-By: Claude Fable 5 --- public/admin.html | 1 + public/admin.js | 5 ++++ server.js | 65 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/public/admin.html b/public/admin.html index 2f2a346..7559746 100644 --- a/public/admin.html +++ b/public/admin.html @@ -8,5 +8,6 @@

Matrix View

The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.

Add Sponsor

AI Chat

Paste an OpenRouter API key to switch the help chat from canned answers to AI (). Clear it to switch back.

+

Payment Emails (SendGrid)

When a payout hits a member whose sponsor record has a contact email, they get a "you've been paid" email automatically. Paste your SendGrid API key (Branded Voice Coolify app → Environment Variables → SENDGRID_API_KEY).

Public Page Settings

diff --git a/public/admin.js b/public/admin.js index 35ec033..11634ff 100644 --- a/public/admin.js +++ b/public/admin.js @@ -31,6 +31,8 @@ function render(){ const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`${i+1}${esc(s.name)}
ID ${esc(s.id)}
${s.email?`
✉ ${esc(s.email)}
`:''}${esc(s.parentId||'—')}${s.directs}/2${levelSelect(s)}${esc(s.status)}${s.clicks||0}
${s.status!=='qualified'?``:``}${s.status==='waiting'?``:''}
`).join('')||'No sponsors in the queue.'; renderAnalytics(); const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'● AI chat is ON — key is set':'○ AI chat is OFF — using built-in answers'; + const em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'● Payment emails are ON — key is set':'○ Payment emails are OFF — no SendGrid key'; + const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||''; const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress; } document.getElementById('loginForm').addEventListener('submit',async e=>{e.preventDefault();const err=document.getElementById('loginError');err.textContent='';try{await api('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('password').value})});document.getElementById('password').value='';await loadState()}catch(x){err.textContent=x.message}}); @@ -41,6 +43,9 @@ rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-ac document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}}); document.getElementById('aiKeyClear').addEventListener('click',async()=>{if(!confirm('Turn off AI chat and go back to built-in answers?'))return;try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key:''})});state.aiChat={...(state.aiChat||{}),configured:d.configured};render();showToast('AI chat disabled')}catch(x){showToast(x.message)}}); rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data-action="level"]');if(!sel)return;try{const d=await api(`/api/admin/sponsors/${sel.dataset.id}`,{method:'PATCH',body:JSON.stringify({level:sel.value})});state.sponsors=d.sponsors;render();showToast('Level updated')}catch(x){showToast(x.message);render()}}); +document.getElementById('sgKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key})});state.email={...(state.email||{}),configured:d.configured};inp.value='';render();showToast('Payment emails enabled')}catch(x){showToast(x.message)}}); +document.getElementById('sgKeyClear').addEventListener('click',async()=>{if(!confirm('Disable "you\'ve been paid" emails?'))return;try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key:''})});state.email={...(state.email||{}),configured:d.configured};render();showToast('Payment emails disabled')}catch(x){showToast(x.message)}}); +document.getElementById('emailFromSave').addEventListener('click',async()=>{const v=document.getElementById('emailFromInput').value.trim();try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({emailFrom:v})});state.config=d.config;showToast('From address saved')}catch(x){showToast(x.message)}}); document.getElementById('lookupForm').addEventListener('submit',async e=>{ e.preventDefault(); const id=document.getElementById('lookupId').value.trim(),out=document.getElementById('lookupResult'); diff --git a/server.js b/server.js index 9202530..755de73 100644 --- a/server.js +++ b/server.js @@ -67,6 +67,34 @@ function sendTelegram(text) { }).then(async r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status, (await r.text().catch(()=>'')).slice(0,200)); }) .catch(e=>console.error('telegram error', e.message)); } +const SENDGRID_KEY_FILE = path.join(DATA_DIR, 'sendgrid.key'); +function getSendgridKey() { + if (process.env.SENDGRID_API_KEY) return process.env.SENDGRID_API_KEY; + try { return fs.readFileSync(SENDGRID_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; } +} +// SendGrid is domain-authenticated for mybrandedvoice.com (SPF/DKIM, 2026-07-07) +// — the from address must stay on that domain or DKIM fails. +function emailFrom() { return getConfig().emailFrom || 'The RM Circle Team '; } +function sendPaidEmail(toEmail, memberName, evt) { + const key = getSendgridKey(); + if (!key) return; + const fromStr = emailFrom(); + const m = fromStr.match(/^(.*)<([^>]+)>\s*$/); + const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() }; + const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`; + const verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : ''; + const text = `Hi ${memberName || 'there'},\n\nGood news — your RM Circle position #${evt.toId} just received ${evt.pol.toFixed(2)} POL (${kindLine}).${verify}\n\nKeep the momentum going: check your level so the next payment in your leg doesn't pass you by.\nhttps://rmcircle.saasy.top/training\n\n— The RM Circle Team\n\nYou're receiving this because your team admin has this address on file for team-build updates. Reply to this email to be removed.`; + fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: toEmail }] }], + from, subject: `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, + content: [{ type: 'text/plain', value: text }] + }) + }).then(r => { if (r.status >= 300) r.text().then(t => console.error('sendgrid status', r.status, t.slice(0, 200))); }) + .catch(e => console.error('sendgrid error', e.message)); +} function firePostback(clickid, txid, source) { const pb = getConfig().bemobPostbackUrl; if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return; @@ -256,7 +284,15 @@ async function handleApi(req,res,pathname){ return json(res,200,r); }catch(e){return json(res,502,{error:e.message||'Lookup failed'})} } - if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}});} + if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL},email:{configured:!!getSendgridKey(),from:emailFrom()}});} + if(req.method==='POST'&&pathname==='/api/admin/sendgrid-key'){ + const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null; + if(key===null)return json(res,400,{error:'Invalid request.'}); + if(key===''){try{fs.unlinkSync(SENDGRID_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getSendgridKey()});} + if(!/^SG\./.test(key)||key.length<40||/\s/.test(key))return json(res,400,{error:'That does not look like a SendGrid API key (starts with SG.).'}); + fs.writeFileSync(SENDGRID_KEY_FILE,key,{mode:0o600}); + return json(res,200,{configured:true}); + } if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){ const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null; if(key===null)return json(res,400,{error:'Invalid request.'}); @@ -270,7 +306,7 @@ async function handleApi(req,res,pathname){ const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors}); } if(req.method==='PATCH'&&pathname==='/api/admin/config'){ - const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next}); + const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next}); } const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/); if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'}); @@ -304,15 +340,22 @@ chain.startIndexer(evt=>{ try{ const c=getConfig(); const rootId=Number(c.teamRootId)||0; - if(!rootId)return; const ids=evt.type==='payout'?[evt.toId,evt.fromId]:[evt.id]; - if(!ids.some(i=>chain.isInTeam(i,rootId)))return; - const contact=id=>{const s=getSponsors().find(x=>String(x.id)===String(id));return s&&s.email?`\nContact: ${s.name?s.name+' — ':''}${s.email}`:''}; - let text; - if(evt.type==='registered')text=`📈 TEAM BUILD: new position!\n#${evt.id} registered under #${evt.referrerId} (${evt.tierName}).`; - else if(evt.type==='upgraded')text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.`; - else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`; - if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`; - sendTelegram(text); + if(rootId&&ids.some(i=>chain.isInTeam(i,rootId))){ + const contact=id=>{const s=getSponsors().find(x=>String(x.id)===String(id));return s&&s.email?`\nContact: ${s.name?s.name+' — ':''}${s.email}`:''}; + let text; + if(evt.type==='registered')text=`📈 TEAM BUILD: new position!\n#${evt.id} registered under #${evt.referrerId} (${evt.tierName}).`; + else if(evt.type==='upgraded')text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.`; + else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`; + if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`; + sendTelegram(text); + } }catch(e){console.error('team alert error',e.message)} + // "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated) + try{ + if(evt.type==='payout'){ + const sp=getSponsors().find(x=>String(x.id)===String(evt.toId)); + if(sp&&sp.email)sendPaidEmail(sp.email,sp.name,evt); + } + }catch(e){console.error('paid email error',e.message)} });