Send automatic "you've been paid" emails via SendGrid

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 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-13 13:03:26 -05:00
parent 6b17419646
commit e3f07057d9
3 changed files with 60 additions and 11 deletions
+54 -11
View File
@@ -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 <no-reply@mybrandedvoice.com>'; }
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)}
});