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 |