···2222import { createTransport } from 'nodemailer';
2323import { createInterface } from 'readline';
2424import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync } from 'fs';
2525-import { createHmac, timingSafeEqual } from 'crypto';
2525+import { createHmac } from 'crypto';
26262727const __dirname = dirname(fileURLToPath(import.meta.url));
2828···3030config({ path: join(__dirname, '../at/.env') });
3131config({ path: join(__dirname, '../at/deploy.env') });
32323333+let unsubscribeSecret = process.env.UNSUBSCRIBE_SECRET || null;
3434+3535+async function ensureUnsubscribeSecret() {
3636+ if (unsubscribeSecret) return unsubscribeSecret;
3737+3838+ const { connect } = await import('../system/backend/database.mjs');
3939+ const database = await connect();
4040+4141+ try {
4242+ const secrets = await database.db
4343+ .collection('secrets')
4444+ .findOne({ _id: 'email-blast' });
4545+4646+ if (!secrets?.unsubscribeSecret) {
4747+ throw new Error('email-blast unsubscribe secret not found');
4848+ }
4949+5050+ unsubscribeSecret = secrets.unsubscribeSecret;
5151+ return unsubscribeSecret;
5252+ } finally {
5353+ await database.disconnect();
5454+ }
5555+}
5656+3357// HMAC token generation for unsubscribe links
3458function generateUnsubscribeToken(email) {
3535- const secret = process.env.UNSUBSCRIBE_SECRET;
3636- if (!secret) {
3737- console.warn('WARNING: UNSUBSCRIBE_SECRET not set — unsubscribe links will not work');
3838- return 'no-secret';
5959+ if (!unsubscribeSecret) {
6060+ throw new Error('unsubscribe secret not loaded');
3961 }
4040- return createHmac('sha256', secret)
6262+ return createHmac('sha256', unsubscribeSecret)
4163 .update(email.toLowerCase().trim())
4264 .digest('hex');
4365}
···219241 process.exit(1);
220242}
221243222222-const EMAIL_SUBJECT = '💾 Save us...';
244244+const EMAIL_SUBJECT = 'a little note from aesthetic computer';
223245224246function getEmailText(recipientEmail) {
225247 const unsubUrl = getUnsubscribeUrl(recipientEmail);
226226- return `Aesthetic.Computer's servers were suspended. We need ~$400 to come back online.
248248+ return `Hi,
227249228228- give.aesthetic.computer
229229- github.com/sponsors/whistlegraph
250250+Aesthetic Computer has had a sweet year so far.
251251+252252+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.
253253+254254+If you want to help keep it alive and growing, the simplest way is:
230255231231-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.
256256+https://give.aesthetic.computer
232257233233-Even $5 helps. Thank you.
258258+If you want to see what support goes toward:
259259+260260+https://bills.aesthetic.computer
261261+262262+You can also help by replying to this email or sharing a favorite AC thing with a friend.
234263235264— @jeffrey
236265···240269241270function getEmailHtml(recipientEmail) {
242271 const unsubUrl = getUnsubscribeUrl(recipientEmail);
243243- return `<p>Aesthetic.Computer's servers were suspended. We need ~$400 to come back online.</p>
272272+ return `<p>Hi,</p>
244273245274<p>
246246-<a href="https://give.aesthetic.computer">give.aesthetic.computer</a><br>
247247-<a href="https://github.com/sponsors/whistlegraph">github.com/sponsors/whistlegraph</a>
275275+ Aesthetic Computer has had a sweet year so far.
248276</p>
249277250250-<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>
278278+<p>
279279+ 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.
280280+</p>
281281+282282+<p>
283283+ If you want to help keep it alive and growing, the simplest way is:
284284+</p>
251285252252-<p>Even $5 helps. Thank you.</p>
286286+<p><a href="https://give.aesthetic.computer">give.aesthetic.computer</a></p>
287287+288288+<p>
289289+ If you want to see what support goes toward:
290290+</p>
291291+292292+<p><a href="https://bills.aesthetic.computer">bills.aesthetic.computer</a></p>
293293+294294+<p>You can also help by replying to this email or sharing a favorite AC thing with a friend.</p>
253295254296<p>— @jeffrey</p>
255297···518560}
519561520562// Preview email content
521521-function previewEmail() {
563563+async function previewEmail() {
564564+ await ensureUnsubscribeSecret();
522565 console.log('\n📧 EMAIL PREVIEW\n');
523566 console.log('─'.repeat(60));
524567 console.log(`From: mail@aesthetic.computer`);
···530573531574// Send a single email
532575async function sendEmail(transporter, to) {
576576+ await ensureUnsubscribeSecret();
533577 const unsubUrl = getUnsubscribeUrl(to);
534578 const result = await transporter.sendMail({
535579 from: '"Aesthetic Computer" <mail@aesthetic.computer>',
+11
lith/server.mjs
···183183 // Reconstruct body as string (Netlify handlers expect string or null)
184184 let body = null;
185185 if (req.body) {
186186+ const contentType = (req.headers["content-type"] || "").toLowerCase();
186187 body =
187188 typeof req.body === "string"
188189 ? req.body
189190 : Buffer.isBuffer(req.body)
190191 ? req.body.toString("utf-8")
192192+ // Preserve HTML form posts as urlencoded strings so legacy handlers
193193+ // using URLSearchParams(event.body) continue to work after lith.
194194+ : contentType.includes("application/x-www-form-urlencoded")
195195+ ? new URLSearchParams(
196196+ Object.entries(req.body).flatMap(([key, value]) =>
197197+ Array.isArray(value)
198198+ ? value.map((item) => [key, item])
199199+ : [[key, value]],
200200+ ),
201201+ ).toString()
191202 : JSON.stringify(req.body);
192203 }
193204
+61-5
plans/EMAIL-BLAST-GIVE-AC.md
···11# Email Blast Tool: give.aesthetic.computer
2233**Created:** January 5, 2026
44-**Updated:** February 12, 2026
55-**Status:** Ready to send (all 5 pre-send issues resolved)
66-**Goal:** Email Auth0 users asking them to support AC via give.aesthetic.computer
44+**Updated:** March 30, 2026
55+**Status:** Prepared, verified, and intentionally on hold
66+**Goal:** Warmly email recent AC users about what feels alive in AC this year and invite support via give.aesthetic.computer
7788---
99···1818# 2. Test with your own email
1919node artery/email-blast.mjs --test me@jas.life
20202121-# 3. Re-fetch users (data is from Jan 5 — get fresh list)
2121+# 3. Use the current 60-day audience snapshot in reports/mail/
2222+# or re-fetch if you want a newer audience export
2223node artery/email-blast.mjs --fetch-all
23242424-# 4. Send to verified users only (default, ~5.3k users)
2525+# 4. Hold here unless Jeffrey explicitly wants to send
2526node artery/email-blast.mjs --send
26272728# 5. Resume next day (Gmail caps at ~500/day)
2829node artery/email-blast.mjs --send --resume
2930```
30313232+Current recommendation:
3333+- Do not send the blast yet.
3434+- Use `--preview` and `--test me@jas.life` only until the audience and tone feel final.
3535+3136---
32373838+## March 30, 2026 Status
3939+4040+### Audience Snapshot
4141+- `183` verified users logged in within the last 60 days
4242+- `182` emailable after unsubscribe filtering
4343+- `139` have AC handles
4444+- `99` logged in within the last 30 days
4545+- `31` logged in within the last 7 days
4646+4747+Private working files were exported locally to `reports/mail/` and should stay out of git.
4848+4949+### Copy Status
5050+- The blast copy is no longer an emergency ask.
5151+- Current subject: `a little note from aesthetic computer`
5252+- Current body: a softer note about what feels alive in AC this year so far, with links to:
5353+ - `https://give.aesthetic.computer`
5454+ - `https://bills.aesthetic.computer`
5555+- Current stats referenced in the copy came from the live metrics snapshot on March 30, 2026 around 04:20 UTC:
5656+ - `4448` paintings
5757+ - `17145` KidLisp programs
5858+ - `18723` chat messages
5959+ - `252` published pages
6060+6161+### Verification Status
6262+- Lith unsubscribe POST handling was fixed after the migration.
6363+- `application/x-www-form-urlencoded` requests now survive the lith adapter correctly.
6464+- `artery/email-blast.mjs` now loads the unsubscribe secret from Mongo if the env var is absent, matching the live unsubscribe endpoint.
6565+- Verified locally and on production:
6666+ - valid unsubscribe GET returns `200`
6767+ - unsubscribe POST returns `200` and inserts the Mongo unsubscribe record
6868+ - resubscribe POST returns `200` and removes the record
6969+ - invalid tokens return `403`
7070+7171+### Test Sends
7272+- Test sends were sent to `me@jas.life` only while validating the unsubscribe flow and updated copy.
7373+- No audience send has happened.
7474+7575+### Next Move
7676+- Keep the blast paused.
7777+- If Jeffrey wants to proceed later, start with the `99` users active within the last 30 days, not the full `182`.
7878+3379## Architecture
34803581### Credentials
···100146- [x] Added `List-Unsubscribe` headers for Gmail native unsub button
101147- [x] Added Gmail rate limit docs and day estimates in output
102148149149+## March 2026 Updates
150150+151151+- [x] Verified the unsubscribe endpoint still works after the lith migration
152152+- [x] Fixed lith form POST body adaptation for urlencoded unsubscribe requests
153153+- [x] Updated the mailer to load the unsubscribe secret from Mongo when needed
154154+- [x] Rewrote the blast copy to be cuter, softer, and more about what is cool in AC this year
155155+- [x] Sent test emails to `me@jas.life` only
156156+- [x] Kept the actual audience send paused
157157+103158---
104159105160## File Map
···107162| File | Purpose |
108163|------|---------|
109164| `artery/email-blast.mjs` | Main blast tool |
165165+| `lith/server.mjs` | Lith request adapter fix for urlencoded unsubscribe POSTs |
110166| `system/netlify/functions/unsubscribe.mjs` | Unsubscribe/resubscribe endpoint |
111167| `system/netlify.toml` | Function config + routing |
112168| `system/public/give.aesthetic.computer/` | Give donation page |