Newsletter opt-in: silent Sendy subscribe on join

Pre-checked "InstantAdPay newsletter" opt-in on the join screen (read by both the
email-code and password signup paths). On new-account creation only, the server
silently subscribes them to the Sendy "InstantAdPay Newsletter" list
(boolean=true, opt-out always wins). New sendy.js helper reads the API key from
SENDY_API_KEY env or DATA_DIR/sendy.key on the volume (same pattern as
sendgrid.key); subscribe is fire-and-forget and never blocks signup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-08 07:13:50 -05:00
parent 8ec6286ff7
commit 8d9f56f68a
4 changed files with 48 additions and 3 deletions
+2 -2
View File
@@ -1332,7 +1332,7 @@
IAP.status('Fresh code sent.', 'ok');
}));
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value });
const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
IAP.status('You are in.', 'ok');
if (r.created && !(r.account && r.account.username)) await showOnboard(); // pick a username first
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
@@ -1360,7 +1360,7 @@
}
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value });
const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
IAP.status('Welcome aboard. You are in.', 'ok');
if (!(r.account && r.account.username)) await showOnboard();
await render();
+5 -1
View File
@@ -18,6 +18,10 @@
<p class="lead" id="introLead" style="font-size:16px">Join free with your email. Your wallet only
comes out when you buy a package or switch on payouts, and it stays yours the whole time.</p>
</section>
<label id="nlOptinRow" style="display:flex;gap:9px;align-items:flex-start;margin:0 4px 16px;font-size:13px;color:var(--muted,#8ba69c);cursor:pointer">
<input type="checkbox" id="nlOptin" checked style="margin-top:2px;flex:0 0 auto">
<span>Keep me posted with the InstantAdPay newsletter (tips and updates). You can unsubscribe anytime.</span>
</label>
<div class="card" id="sponsorNote" style="border-color:var(--mint);text-align:center" hidden>
<p style="margin:0"><span class="muted small">You're joining the line of</span><br>
<b id="sponsorNoteName" style="font-size:19px;color:var(--mint)"></b></p>
@@ -687,7 +691,7 @@
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/wallet.js?v=20260907q"></script>
<script src="/assets/my.js?v=20260908f"></script>
<script src="/assets/my.js?v=20260908g"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
// sendy.js — silent newsletter opt-in via Sendy.
// API key from SENDY_API_KEY env, else DATA_DIR/sendy.key on the volume (same
// pattern as sendgrid.key). URL + list have safe non-secret defaults (the
// hashed list id is public — it appears in Sendy's own subscribe-form HTML).
// subscribe() is fire-and-forget and never throws: a Sendy hiccup must never
// break signup. boolean=true = silent (no confirmation email); Sendy itself
// refuses unsubscribed/bounced addresses, so opt-out always wins.
const https = require('https');
const fs = require('fs');
const path = require('path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const URL_BASE = (process.env.SENDY_URL || 'https://valuedreply.xyz').replace(/\/+$/, '');
const LIST = process.env.SENDY_LIST || 'W892hm1pgk3pIiK7OBYfHTWw'; // InstantAdPay Newsletter (brand 3)
function apiKey() {
if (process.env.SENDY_API_KEY) return process.env.SENDY_API_KEY.trim();
try { return fs.readFileSync(path.join(DATA_DIR, 'sendy.key'), 'utf8').trim(); } catch (e) { return ''; }
}
function enabled() { return !!apiKey(); }
function subscribe(email, name) {
const key = apiKey();
const e = String(email || '').trim();
if (!key || !e) return Promise.resolve(false);
const body = new URLSearchParams({ api_key: key, list: LIST, email: e, name: String(name || ''), boolean: 'true' }).toString();
const u = new URL(URL_BASE + '/subscribe');
return new Promise(resolve => {
const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(/^(1|true|already)/i.test(d.trim()))); });
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.end(body);
});
}
module.exports = { subscribe, enabled };
+3
View File
@@ -10,6 +10,7 @@ const dns = require('dns');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sendy = require('./sendy');
const { URL } = require('url');
const chain = require('./chain');
const auth = require('./auth');
@@ -408,6 +409,7 @@ const server = http.createServer(async (req, res) => {
const r = await accounts.signup(b.email, b.password, ref);
if (r.error) return json(res, 400, r);
nudgeReferrer(ref).catch(() => {});
if (b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent
const token = await auth.mintSession({ email: r.account.email });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
@@ -453,6 +455,7 @@ const server = http.createServer(async (req, res) => {
const r = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
if (r.error) return json(res, 400, r);
if (r.created) { nudgeReferrer(ref).catch(() => {}); sendWelcome(e, ref).catch(() => {}); }
if (r.created && b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent, new joins only
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });