diff --git a/artery/email-blast.mjs b/artery/email-blast.mjs index 70883498aa..53b4f440cd 100644 --- a/artery/email-blast.mjs +++ b/artery/email-blast.mjs @@ -22,7 +22,7 @@ import { dirname, join } from 'path'; import { createTransport } from 'nodemailer'; import { createInterface } from 'readline'; import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync } from 'fs'; -import { createHmac, timingSafeEqual } from 'crypto'; +import { createHmac } from 'crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -30,14 +30,36 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); config({ path: join(__dirname, '../at/.env') }); config({ path: join(__dirname, '../at/deploy.env') }); +let unsubscribeSecret = process.env.UNSUBSCRIBE_SECRET || null; + +async function ensureUnsubscribeSecret() { + if (unsubscribeSecret) return unsubscribeSecret; + + const { connect } = await import('../system/backend/database.mjs'); + const database = await connect(); + + try { + const secrets = await database.db + .collection('secrets') + .findOne({ _id: 'email-blast' }); + + if (!secrets?.unsubscribeSecret) { + throw new Error('email-blast unsubscribe secret not found'); + } + + unsubscribeSecret = secrets.unsubscribeSecret; + return unsubscribeSecret; + } finally { + await database.disconnect(); + } +} + // HMAC token generation for unsubscribe links function generateUnsubscribeToken(email) { - const secret = process.env.UNSUBSCRIBE_SECRET; - if (!secret) { - console.warn('WARNING: UNSUBSCRIBE_SECRET not set — unsubscribe links will not work'); - return 'no-secret'; + if (!unsubscribeSecret) { + throw new Error('unsubscribe secret not loaded'); } - return createHmac('sha256', secret) + return createHmac('sha256', unsubscribeSecret) .update(email.toLowerCase().trim()) .digest('hex'); } @@ -219,18 +241,25 @@ if (!SMTP_CONFIG.auth.pass) { process.exit(1); } -const EMAIL_SUBJECT = 'šŸ’¾ Save us...'; +const EMAIL_SUBJECT = 'a little note from aesthetic computer'; function getEmailText(recipientEmail) { const unsubUrl = getUnsubscribeUrl(recipientEmail); - return `Aesthetic.Computer's servers were suspended. We need ~$400 to come back online. + return `Hi, + +Aesthetic Computer has had a sweet year so far. - give.aesthetic.computer - github.com/sponsors/whistlegraph +The tiny weird internet computer keeps filling up with life: thousands of paintings, more than 17,000 KidLisp programs, nearly 19,000 chat messages, and hundreds of published pages. The little orbit around it has been growing too: prompt.ac, news.aesthetic.computer, papers.aesthetic.computer, ATProto pages, and the ongoing Blank / AC Native work. -Even though chat communities, assets and user media archive, and database are offline — you can still use notepat.com, kidlisp.com, and explore pieces that don't require backend connectivity, thanks to our distributed hosting design. +If you want to help keep it alive and growing, the simplest way is: -Even $5 helps. Thank you. +https://give.aesthetic.computer + +If you want to see what support goes toward: + +https://bills.aesthetic.computer + +You can also help by replying to this email or sharing a favorite AC thing with a friend. — @jeffrey @@ -240,16 +269,29 @@ Unsubscribe: ${unsubUrl}`; function getEmailHtml(recipientEmail) { const unsubUrl = getUnsubscribeUrl(recipientEmail); - return `

Aesthetic.Computer's servers were suspended. We need ~$400 to come back online.

+ return `

Hi,

+ +

+ Aesthetic Computer has had a sweet year so far. +

+ +

+ The tiny weird internet computer keeps filling up with life: thousands of paintings, more than 17,000 KidLisp programs, nearly 19,000 chat messages, and hundreds of published pages. The little orbit around it has been growing too: prompt.ac, news.aesthetic.computer, papers.aesthetic.computer, ATProto pages, and the ongoing Blank / AC Native work. +

+ +

+ If you want to help keep it alive and growing, the simplest way is: +

+ +

give.aesthetic.computer

-give.aesthetic.computer
-github.com/sponsors/whistlegraph + If you want to see what support goes toward:

-

Even though chat communities, assets and user media archive, and database are offline — you can still use notepat.com, kidlisp.com, and explore pieces that don't require backend connectivity, thanks to our distributed hosting design.

+

bills.aesthetic.computer

-

Even $5 helps. Thank you.

+

You can also help by replying to this email or sharing a favorite AC thing with a friend.

— @jeffrey

@@ -518,7 +560,8 @@ async function exportUsers() { } // Preview email content -function previewEmail() { +async function previewEmail() { + await ensureUnsubscribeSecret(); console.log('\nšŸ“§ EMAIL PREVIEW\n'); console.log('─'.repeat(60)); console.log(`From: mail@aesthetic.computer`); @@ -530,6 +573,7 @@ function previewEmail() { // Send a single email async function sendEmail(transporter, to) { + await ensureUnsubscribeSecret(); const unsubUrl = getUnsubscribeUrl(to); const result = await transporter.sendMail({ from: '"Aesthetic Computer" ', diff --git a/lith/server.mjs b/lith/server.mjs index ead5ee0725..a3c595343d 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -183,11 +183,22 @@ function toEvent(req) { // Reconstruct body as string (Netlify handlers expect string or null) let body = null; if (req.body) { + const contentType = (req.headers["content-type"] || "").toLowerCase(); body = typeof req.body === "string" ? req.body : Buffer.isBuffer(req.body) ? req.body.toString("utf-8") + // Preserve HTML form posts as urlencoded strings so legacy handlers + // using URLSearchParams(event.body) continue to work after lith. + : contentType.includes("application/x-www-form-urlencoded") + ? new URLSearchParams( + Object.entries(req.body).flatMap(([key, value]) => + Array.isArray(value) + ? value.map((item) => [key, item]) + : [[key, value]], + ), + ).toString() : JSON.stringify(req.body); } diff --git a/plans/EMAIL-BLAST-GIVE-AC.md b/plans/EMAIL-BLAST-GIVE-AC.md index e7b49498fa..bb9f00190b 100644 --- a/plans/EMAIL-BLAST-GIVE-AC.md +++ b/plans/EMAIL-BLAST-GIVE-AC.md @@ -1,9 +1,9 @@ # Email Blast Tool: give.aesthetic.computer **Created:** January 5, 2026 -**Updated:** February 12, 2026 -**Status:** Ready to send (all 5 pre-send issues resolved) -**Goal:** Email Auth0 users asking them to support AC via give.aesthetic.computer +**Updated:** March 30, 2026 +**Status:** Prepared, verified, and intentionally on hold +**Goal:** Warmly email recent AC users about what feels alive in AC this year and invite support via give.aesthetic.computer --- @@ -18,18 +18,64 @@ node artery/email-blast.mjs --preview # 2. Test with your own email node artery/email-blast.mjs --test me@jas.life -# 3. Re-fetch users (data is from Jan 5 — get fresh list) +# 3. Use the current 60-day audience snapshot in reports/mail/ +# or re-fetch if you want a newer audience export node artery/email-blast.mjs --fetch-all -# 4. Send to verified users only (default, ~5.3k users) +# 4. Hold here unless Jeffrey explicitly wants to send node artery/email-blast.mjs --send # 5. Resume next day (Gmail caps at ~500/day) node artery/email-blast.mjs --send --resume ``` +Current recommendation: +- Do not send the blast yet. +- Use `--preview` and `--test me@jas.life` only until the audience and tone feel final. + --- +## March 30, 2026 Status + +### Audience Snapshot +- `183` verified users logged in within the last 60 days +- `182` emailable after unsubscribe filtering +- `139` have AC handles +- `99` logged in within the last 30 days +- `31` logged in within the last 7 days + +Private working files were exported locally to `reports/mail/` and should stay out of git. + +### Copy Status +- The blast copy is no longer an emergency ask. +- Current subject: `a little note from aesthetic computer` +- Current body: a softer note about what feels alive in AC this year so far, with links to: + - `https://give.aesthetic.computer` + - `https://bills.aesthetic.computer` +- Current stats referenced in the copy came from the live metrics snapshot on March 30, 2026 around 04:20 UTC: + - `4448` paintings + - `17145` KidLisp programs + - `18723` chat messages + - `252` published pages + +### Verification Status +- Lith unsubscribe POST handling was fixed after the migration. +- `application/x-www-form-urlencoded` requests now survive the lith adapter correctly. +- `artery/email-blast.mjs` now loads the unsubscribe secret from Mongo if the env var is absent, matching the live unsubscribe endpoint. +- Verified locally and on production: + - valid unsubscribe GET returns `200` + - unsubscribe POST returns `200` and inserts the Mongo unsubscribe record + - resubscribe POST returns `200` and removes the record + - invalid tokens return `403` + +### Test Sends +- Test sends were sent to `me@jas.life` only while validating the unsubscribe flow and updated copy. +- No audience send has happened. + +### Next Move +- Keep the blast paused. +- If Jeffrey wants to proceed later, start with the `99` users active within the last 30 days, not the full `182`. + ## Architecture ### Credentials @@ -100,6 +146,15 @@ https://aesthetic.computer/api/unsubscribe?email=X&token=HMAC - [x] Added `List-Unsubscribe` headers for Gmail native unsub button - [x] Added Gmail rate limit docs and day estimates in output +## March 2026 Updates + +- [x] Verified the unsubscribe endpoint still works after the lith migration +- [x] Fixed lith form POST body adaptation for urlencoded unsubscribe requests +- [x] Updated the mailer to load the unsubscribe secret from Mongo when needed +- [x] Rewrote the blast copy to be cuter, softer, and more about what is cool in AC this year +- [x] Sent test emails to `me@jas.life` only +- [x] Kept the actual audience send paused + --- ## File Map @@ -107,6 +162,7 @@ https://aesthetic.computer/api/unsubscribe?email=X&token=HMAC | File | Purpose | |------|---------| | `artery/email-blast.mjs` | Main blast tool | +| `lith/server.mjs` | Lith request adapter fix for urlencoded unsubscribe POSTs | | `system/netlify/functions/unsubscribe.mjs` | Unsubscribe/resubscribe endpoint | | `system/netlify.toml` | Function config + routing | | `system/public/give.aesthetic.computer/` | Give donation page |