From 8d9f56f68ac4834879b070345accce9cbff4a6ed Mon Sep 17 00:00:00 2001 From: martbost Date: Tue, 8 Sep 2026 07:13:50 -0500 Subject: [PATCH] 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 --- public/assets/my.js | 4 ++-- public/my.html | 6 +++++- sendy.js | 38 ++++++++++++++++++++++++++++++++++++++ server.js | 3 +++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 sendy.js diff --git a/public/assets/my.js b/public/assets/my.js index a03273e..b54562d 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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(); diff --git a/public/my.html b/public/my.html index 163ec0d..da7eff1 100644 --- a/public/my.html +++ b/public/my.html @@ -18,6 +18,10 @@

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.

+ - + diff --git a/sendy.js b/sendy.js new file mode 100644 index 0000000..4d42e55 --- /dev/null +++ b/sendy.js @@ -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 }; diff --git a/server.js b/server.js index 512dc9f..8a76606 100644 --- a/server.js +++ b/server.js @@ -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 });