Building the thing nobody asked for (again)

It’s 5:43 am in Dubai.
My wife is asleep. Work was done before midnight. The free hours are mine, clean, no obligations attached.
Claude Code is open. Cursor is open. Codex is running. Three AI tools, all built to help me create things faster, all aimed at a newsletter system with zero subscribers.
This is not a story about procrastination.
Procrastinators watch Netflix. I’m shipping RFC 8058 compliance.
Three Tools Built to Help Me Write. Guess What We Built Instead.
Claude Code didn’t flinch. Cursor didn’t blink. Codex just nodded along.
None of them asked the obvious question. None of them said, “Hey, shouldn’t you be writing?” They’re enthusiastic accomplices with no moral compass and unlimited energy. You point them at something, and they go. Fast. Clean. With terrifying competence.
Old AQ had to fall down these holes alone. It took effort. Friction had a chance to save him. He might get tired halfway through and go to bed like a normal person.
New AQ has three AI drug dealers and a tunnel that looks short from the entrance.
The dangerous thing about AI isn’t that it replaces your work. Everyone’s scared of the wrong thing. The real danger is quieter. AI turbocharges avoidance. It makes the wrong thing feel exactly like the right thing, faster than ever before.
You’re not scrolling. You’re building. You’re not wasting time. You’re shipping. The dopamine is identical. The outcome is not.
The Stack
Before we get to what I built, you need to know what I built it on. Because the stack is half the story.
Next.js 16 App Router on Coolify, deployed via Docker on a single VPS. Not because it’s trendy. Because one box, one deploy, one place to look when things break at 5am.
SQLite with Drizzle ORM. Every indie builder eventually has the SQLite argument with themselves. I had mine at 2am. SQLite won. One file. WAL mode. Fast enough for everything I’ll ever need at this scale and honest about what it is. The DB is lazy-initialized behind a Proxy to avoid SQLITE_BUSY errors during multi-worker builds. Migrations run at runtime on first access. Zero pre-deploy step.
Tiptap 3 with tiptap-markdown for prose editing. I considered rebuilding the editor from scratch. That thought lasted four minutes. Rebuilding a prose editor from scratch is a trap every developer falls into once. The cursor blinks and you think “I’ll just add a few features.” Six months later you have a half-finished contenteditable wrapper and no blog posts. Tiptap handles the hard parts.
Resend for email with Svix webhooks. Email delivery isn’t a fetch() call. It’s a conversation with a mail server that can reject you at any moment for reasons that have nothing to do with your content. Svix gives you the webhook infrastructure to listen when that conversation goes wrong. More on that in a minute.
Cloudflare R2 for media storage. NextAuth v5 for auth. Single password. Simple because I’m building this alone at 5am and Fort Knox can wait. Reoon for email verification. ipinfo.io with an in-memory LRU cache for geo lookups. marked for server-side markdown rendering at send time.
That’s the machine. Here’s what I did to it last night.
Look What I Built (For Nobody)
The newsletter body isn’t a markdown string. Every newsletter is a JSON array of typed sections.
type Section =
| { id: string; type: "markdown"; md: string }
| { id: string; type: "post_card"; postId: string; showImage?: boolean; showDate?: boolean }
| { id: string; type: "link"; url: string; title: string; description?: string; note?: string }
| { id: string; type: "section_header"; text: string; subtitle?: string }
| { id: string; type: "divider" }
| { id: string; type: "cta"; label: string; url: string }
| { id: string; type: "quote"; text: string; cite?: string }
| { id: string; type: "image"; url: string; alt: string; caption?: string }
| { id: string; type: "callout"; tone: "tip" | "note" | "warn"; md: string }
| { id: string; type: "poll"; question: string; options: string[] };
That discriminated union is load-bearing. TypeScript’s exhaustiveness check means every renderer, editor, and helper that forgets a new section type fails at compile time, not runtime. When I added the poll type last night, it instantly broke six compile sites. All caught before the code ran once. That’s the good kind of breaking.
The naive send approach renders HTML once per subscriber. For 5,000 subscribers, that’s 5,000 calls to marked.parse(). Wasteful. So instead: render once with sentinel tokens, swap per recipient.
const UNSUB_PLACEHOLDER = "__UNSUB_URL_PLACEHOLDER__";
const REF_PLACEHOLDER = "__REF_CODE__";
const SUBTOKEN_PLACEHOLDER = "__SUB_TOKEN__";
const templateHtml = buildNewsletterHtml(ctx, UNSUB_PLACEHOLDER); // once
const templateText = renderPlainText(ctx, UNSUB_PLACEHOLDER); // once
for (const batch of chunk(toSend, 100)) {
const emails = batch.map((sub, i) => ({
html: templateHtml
.replaceAll(UNSUB_PLACEHOLDER, unsubscribeUrl(sub.id))
.replaceAll(REF_PLACEHOLDER, refs[i])
.replaceAll(SUBTOKEN_PLACEHOLDER, subTokens[i]),
text: templateText
.replaceAll(UNSUB_PLACEHOLDER, unsubscribeUrl(sub.id)),
to: sub.email,
}));
await sendBatchWithRetry(emails);
}
Three placeholders. Three per-recipient secrets. Markdown parses once. replaceAll does the cheap work per subscriber. At 5,000 sends, that’s the difference between one render and 5,000 little acts of waste.
Then came the bug that almost destroyed the list before anyone joined it.
Most naive send loops treat all errors the same. Catch the error. Mark the batch as bounced. Move on. That’s wrong in a way that silently corrupts data without telling you.
A failed send means our code encountered an error before Resend touched anything. Transient network blip. Rate limit. 503. The subscriber never rejected anything. We fumbled the handoff.
A bounced send means Resend’s email.bounced webhook fired. The recipient’s mail server looked at our email and said no. Permanently.
These are completely different things. I conflated them. One transient 503 flagged 100 real subscribers as permanently bounced, and they were excluded from every future send. Silent data loss. No error thrown. No warning. 100 people who stopped receiving emails and didn’t know why.
Fix: only the webhook flips a subscriber to bounced. The send loop retries with backoff, then marks failed. Never bounced.
async function sendBatchWithRetry(emails: EmailPayload[]) {
let attempt = 0;
while (attempt < 3) {
try {
return await resend.batch.send(emails);
} catch (err) {
attempt++;
if (!isRetriable(err) || attempt >= 3) {
throw new SendFailedError(err);
}
await sleep(2 * (attempt - 1) 1000); // 1s, 2s, 4s
}
}
}
Failed rows will be retried on the next run. Bounced rows are permanent. That distinction sounds small until the wrong label quietly removes real humans from your future forever.
Then the datetime footgun. This one ate 20 minutes and taught me the most.
SQLite’s datetime('now') default produces this:
"2026-04-17 00:08:34"
JavaScript’s new Date().toISOString() produces this:
"2026-04-17T00:08:07.172Z"
Both are living in the same column. Both represent the same moment. But look at position 10.
Space is 0x20. T is 0x54. Space sorts lower. So gte(subscribedAt, activeFrom) was always false. Every automation’s qualifying query excluded every subscriber. Forever. No error. No warning. Just silence and zero sends.
function toSqliteDt(d: Date): string {
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
// "2026-04-17T00:08:07.172Z" → "2026-04-17 00:08:07"
}
function parseDbDate(s: string): Date {
return new Date(s.includes("T") ? s : s.replace(" ", "T") + "Z");
}
Two functions. Twelve lines. Fixes a silent bug that would have broken automations forever. The meta-lesson: inconsistent formats silently corrupt string-based queries. Write the helper the first time you see the ambiguity. Use it everywhere.
Also shipped:
- BIMI compliance so the logo shows in emails.
- RFC 8058 List-Unsubscribe with one-click POST support.
- HMAC-signed unsubscribe tokens with zero schema cost.
- Auto-UTM rewriting on every internal link.
- Table-based layouts for Outlook.
- Dark mode and light mode rendering.
- Plain-text fallback.
- Per-subscriber referral attribution.
And the kicker? Zero subscribers have seen any of it.
Twenty Years of Building Printing Presses. Code Compiles. Thoughts Don’t.
Yesterday’s post ended with “Cool CMS, AQ. Now write.”
My response was to build the newsletter layer.
WordPress. Ghost. Substack. Custom Astro CMS. Now, a full newsletter and automation system on top of the custom CMS I built last week. Same man. Deeper hole. Better tooling.
I’ve buried more blogs than most people have launched. Each time, there was a reason. The platform was wrong. The friction was too high. Each time, the platform wasn’t the problem.
Lazy people sleep. It’s 5:43 am. This isn’t laziness.
It’s a specific addiction. Building gives you a compile step. You change a line, the system responds. The datetime bug appears, then disappears. The 503 stops mislabeling real subscribers as permanently dead. Progress has edges you can see, touch, and ship.
There’s no compile step for whether your thoughts are correct. There’s no TypeScript exhaustiveness check to confirm whether you have something worth saying. No green test suite confirming the idea matters.
The CMS won’t humiliate you. A blank page might reveal you don’t.
Three AI tools make the building more frictionless than ever. The gap between the idea and the working system is now measured in hours. Which means the gap between “I should write” and “I built something instead” is also hours. AI didn’t create this pattern. It’s twenty years old. It just removed every speed bump on the road to it.
Both True at the Same Time
Happy I achieved this. I’m not happy that I achieved this instead of what moves my career and business forward.
Both true. Right next to each other at 5:43 am.
Not guilt. Guilt at least picks a side.
This is an achievement and a loss of wearing the same face. The dangerous kind of productive. Where you close the laptop feeling accomplished and let down at the exact same time.
That’s the specific pain.
Nobody Left to Blame
The unsubscribe works from Gmail with one click. Failed rows retry automatically. Automations fire on a 5-minute tick. The BIMI logo will show in inboxes. The datetime helper normalizes before every query. Markdown parses once for 5,000 subscribers, not 5,000 times.
Zero subscribers have seen any of it.
Yesterday, the last excuse died. I built the CMS, I said. No more friction. No more platform to blame. The machine exists. Now write.
My response was to build the unsubscribe flow for a list that doesn’t exist yet. Then the automation scheduler. Then the datetime helper. Then the BIMI logo.
Three AI accomplices. Zero sentences written. A perfectly RFC-compliant newsletter system sitting in a clean dark terminal at 5:43 am in Dubai, while my wife sleeps and the blank page waits.
Cool newsletter system, AQ.
Now write.
~ aq