Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix lith unsubscribe flow and refresh email blast plan

+134 -23
+62 -18
artery/email-blast.mjs
··· 22 22 import { createTransport } from 'nodemailer'; 23 23 import { createInterface } from 'readline'; 24 24 import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync } from 'fs'; 25 - import { createHmac, timingSafeEqual } from 'crypto'; 25 + import { createHmac } from 'crypto'; 26 26 27 27 const __dirname = dirname(fileURLToPath(import.meta.url)); 28 28 ··· 30 30 config({ path: join(__dirname, '../at/.env') }); 31 31 config({ path: join(__dirname, '../at/deploy.env') }); 32 32 33 + let unsubscribeSecret = process.env.UNSUBSCRIBE_SECRET || null; 34 + 35 + async function ensureUnsubscribeSecret() { 36 + if (unsubscribeSecret) return unsubscribeSecret; 37 + 38 + const { connect } = await import('../system/backend/database.mjs'); 39 + const database = await connect(); 40 + 41 + try { 42 + const secrets = await database.db 43 + .collection('secrets') 44 + .findOne({ _id: 'email-blast' }); 45 + 46 + if (!secrets?.unsubscribeSecret) { 47 + throw new Error('email-blast unsubscribe secret not found'); 48 + } 49 + 50 + unsubscribeSecret = secrets.unsubscribeSecret; 51 + return unsubscribeSecret; 52 + } finally { 53 + await database.disconnect(); 54 + } 55 + } 56 + 33 57 // HMAC token generation for unsubscribe links 34 58 function generateUnsubscribeToken(email) { 35 - const secret = process.env.UNSUBSCRIBE_SECRET; 36 - if (!secret) { 37 - console.warn('WARNING: UNSUBSCRIBE_SECRET not set — unsubscribe links will not work'); 38 - return 'no-secret'; 59 + if (!unsubscribeSecret) { 60 + throw new Error('unsubscribe secret not loaded'); 39 61 } 40 - return createHmac('sha256', secret) 62 + return createHmac('sha256', unsubscribeSecret) 41 63 .update(email.toLowerCase().trim()) 42 64 .digest('hex'); 43 65 } ··· 219 241 process.exit(1); 220 242 } 221 243 222 - const EMAIL_SUBJECT = '💾 Save us...'; 244 + const EMAIL_SUBJECT = 'a little note from aesthetic computer'; 223 245 224 246 function getEmailText(recipientEmail) { 225 247 const unsubUrl = getUnsubscribeUrl(recipientEmail); 226 - return `Aesthetic.Computer's servers were suspended. We need ~$400 to come back online. 248 + return `Hi, 227 249 228 - give.aesthetic.computer 229 - github.com/sponsors/whistlegraph 250 + Aesthetic Computer has had a sweet year so far. 251 + 252 + 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. 253 + 254 + If you want to help keep it alive and growing, the simplest way is: 230 255 231 - 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. 256 + https://give.aesthetic.computer 232 257 233 - Even $5 helps. Thank you. 258 + If you want to see what support goes toward: 259 + 260 + https://bills.aesthetic.computer 261 + 262 + You can also help by replying to this email or sharing a favorite AC thing with a friend. 234 263 235 264 — @jeffrey 236 265 ··· 240 269 241 270 function getEmailHtml(recipientEmail) { 242 271 const unsubUrl = getUnsubscribeUrl(recipientEmail); 243 - return `<p>Aesthetic.Computer's servers were suspended. We need ~$400 to come back online.</p> 272 + return `<p>Hi,</p> 244 273 245 274 <p> 246 - <a href="https://give.aesthetic.computer">give.aesthetic.computer</a><br> 247 - <a href="https://github.com/sponsors/whistlegraph">github.com/sponsors/whistlegraph</a> 275 + Aesthetic Computer has had a sweet year so far. 248 276 </p> 249 277 250 - <p>Even though chat communities, assets and user media archive, and database are offline — you can still use <a href="https://notepat.com">notepat.com</a>, <a href="https://kidlisp.com">kidlisp.com</a>, and explore pieces that don't require backend connectivity, thanks to our distributed hosting design.</p> 278 + <p> 279 + 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: <a href="https://prompt.ac">prompt.ac</a>, <a href="https://news.aesthetic.computer">news.aesthetic.computer</a>, <a href="https://papers.aesthetic.computer">papers.aesthetic.computer</a>, ATProto pages, and the ongoing Blank / AC Native work. 280 + </p> 281 + 282 + <p> 283 + If you want to help keep it alive and growing, the simplest way is: 284 + </p> 251 285 252 - <p>Even $5 helps. Thank you.</p> 286 + <p><a href="https://give.aesthetic.computer">give.aesthetic.computer</a></p> 287 + 288 + <p> 289 + If you want to see what support goes toward: 290 + </p> 291 + 292 + <p><a href="https://bills.aesthetic.computer">bills.aesthetic.computer</a></p> 293 + 294 + <p>You can also help by replying to this email or sharing a favorite AC thing with a friend.</p> 253 295 254 296 <p>— @jeffrey</p> 255 297 ··· 518 560 } 519 561 520 562 // Preview email content 521 - function previewEmail() { 563 + async function previewEmail() { 564 + await ensureUnsubscribeSecret(); 522 565 console.log('\n📧 EMAIL PREVIEW\n'); 523 566 console.log('─'.repeat(60)); 524 567 console.log(`From: mail@aesthetic.computer`); ··· 530 573 531 574 // Send a single email 532 575 async function sendEmail(transporter, to) { 576 + await ensureUnsubscribeSecret(); 533 577 const unsubUrl = getUnsubscribeUrl(to); 534 578 const result = await transporter.sendMail({ 535 579 from: '"Aesthetic Computer" <mail@aesthetic.computer>',
+11
lith/server.mjs
··· 183 183 // Reconstruct body as string (Netlify handlers expect string or null) 184 184 let body = null; 185 185 if (req.body) { 186 + const contentType = (req.headers["content-type"] || "").toLowerCase(); 186 187 body = 187 188 typeof req.body === "string" 188 189 ? req.body 189 190 : Buffer.isBuffer(req.body) 190 191 ? req.body.toString("utf-8") 192 + // Preserve HTML form posts as urlencoded strings so legacy handlers 193 + // using URLSearchParams(event.body) continue to work after lith. 194 + : contentType.includes("application/x-www-form-urlencoded") 195 + ? new URLSearchParams( 196 + Object.entries(req.body).flatMap(([key, value]) => 197 + Array.isArray(value) 198 + ? value.map((item) => [key, item]) 199 + : [[key, value]], 200 + ), 201 + ).toString() 191 202 : JSON.stringify(req.body); 192 203 } 193 204
+61 -5
plans/EMAIL-BLAST-GIVE-AC.md
··· 1 1 # Email Blast Tool: give.aesthetic.computer 2 2 3 3 **Created:** January 5, 2026 4 - **Updated:** February 12, 2026 5 - **Status:** Ready to send (all 5 pre-send issues resolved) 6 - **Goal:** Email Auth0 users asking them to support AC via give.aesthetic.computer 4 + **Updated:** March 30, 2026 5 + **Status:** Prepared, verified, and intentionally on hold 6 + **Goal:** Warmly email recent AC users about what feels alive in AC this year and invite support via give.aesthetic.computer 7 7 8 8 --- 9 9 ··· 18 18 # 2. Test with your own email 19 19 node artery/email-blast.mjs --test me@jas.life 20 20 21 - # 3. Re-fetch users (data is from Jan 5 — get fresh list) 21 + # 3. Use the current 60-day audience snapshot in reports/mail/ 22 + # or re-fetch if you want a newer audience export 22 23 node artery/email-blast.mjs --fetch-all 23 24 24 - # 4. Send to verified users only (default, ~5.3k users) 25 + # 4. Hold here unless Jeffrey explicitly wants to send 25 26 node artery/email-blast.mjs --send 26 27 27 28 # 5. Resume next day (Gmail caps at ~500/day) 28 29 node artery/email-blast.mjs --send --resume 29 30 ``` 30 31 32 + Current recommendation: 33 + - Do not send the blast yet. 34 + - Use `--preview` and `--test me@jas.life` only until the audience and tone feel final. 35 + 31 36 --- 32 37 38 + ## March 30, 2026 Status 39 + 40 + ### Audience Snapshot 41 + - `183` verified users logged in within the last 60 days 42 + - `182` emailable after unsubscribe filtering 43 + - `139` have AC handles 44 + - `99` logged in within the last 30 days 45 + - `31` logged in within the last 7 days 46 + 47 + Private working files were exported locally to `reports/mail/` and should stay out of git. 48 + 49 + ### Copy Status 50 + - The blast copy is no longer an emergency ask. 51 + - Current subject: `a little note from aesthetic computer` 52 + - Current body: a softer note about what feels alive in AC this year so far, with links to: 53 + - `https://give.aesthetic.computer` 54 + - `https://bills.aesthetic.computer` 55 + - Current stats referenced in the copy came from the live metrics snapshot on March 30, 2026 around 04:20 UTC: 56 + - `4448` paintings 57 + - `17145` KidLisp programs 58 + - `18723` chat messages 59 + - `252` published pages 60 + 61 + ### Verification Status 62 + - Lith unsubscribe POST handling was fixed after the migration. 63 + - `application/x-www-form-urlencoded` requests now survive the lith adapter correctly. 64 + - `artery/email-blast.mjs` now loads the unsubscribe secret from Mongo if the env var is absent, matching the live unsubscribe endpoint. 65 + - Verified locally and on production: 66 + - valid unsubscribe GET returns `200` 67 + - unsubscribe POST returns `200` and inserts the Mongo unsubscribe record 68 + - resubscribe POST returns `200` and removes the record 69 + - invalid tokens return `403` 70 + 71 + ### Test Sends 72 + - Test sends were sent to `me@jas.life` only while validating the unsubscribe flow and updated copy. 73 + - No audience send has happened. 74 + 75 + ### Next Move 76 + - Keep the blast paused. 77 + - If Jeffrey wants to proceed later, start with the `99` users active within the last 30 days, not the full `182`. 78 + 33 79 ## Architecture 34 80 35 81 ### Credentials ··· 100 146 - [x] Added `List-Unsubscribe` headers for Gmail native unsub button 101 147 - [x] Added Gmail rate limit docs and day estimates in output 102 148 149 + ## March 2026 Updates 150 + 151 + - [x] Verified the unsubscribe endpoint still works after the lith migration 152 + - [x] Fixed lith form POST body adaptation for urlencoded unsubscribe requests 153 + - [x] Updated the mailer to load the unsubscribe secret from Mongo when needed 154 + - [x] Rewrote the blast copy to be cuter, softer, and more about what is cool in AC this year 155 + - [x] Sent test emails to `me@jas.life` only 156 + - [x] Kept the actual audience send paused 157 + 103 158 --- 104 159 105 160 ## File Map ··· 107 162 | File | Purpose | 108 163 |------|---------| 109 164 | `artery/email-blast.mjs` | Main blast tool | 165 + | `lith/server.mjs` | Lith request adapter fix for urlencoded unsubscribe POSTs | 110 166 | `system/netlify/functions/unsubscribe.mjs` | Unsubscribe/resubscribe endpoint | 111 167 | `system/netlify.toml` | Function config + routing | 112 168 | `system/public/give.aesthetic.computer/` | Give donation page |