📬 Build an email list (like this one)
A lead magnet worth an email, double opt-in that link scanners can't trigger, tags from answers, a drip that sends itself, and the rules that keep it legal.
You'll leave with
- A signup on your app that feeds a hosted list (Substack or Reach), with its double opt-in
- Your first useful email from your own domain, with a legal footer
- The rules for building your own drip later: button-page confirms, one-click unsubscribe
If you took this course's quiz and tapped the button in the email that followed, you're on an email list right now. Today your app (the site or tool you're building) gets the lazy version: a hosted list, a plain signup form, and one useful email from your domain (your name on the web, like yourdomain.com). Then come the rules behind this course's own list, for the day you build one.
You'll need your app live on your server, the rented computer it runs on (Deploy behind one front door). Step 2 also needs the mailbox from Email that arrives and a small backend, server code of your own, from Add a backend only when forced.
The zero-backend newsletter
An email list is the one audience you own: a search engine can drop you and a social feed can bury you, but an inbox is still there next month. Start one with no backend at all. Create a free Substack first. Substack, a newsletter platform, gives you a signup page, double opt-in, unsubscribe and sending, with zero code.
What's double opt-in?
Signing up takes two steps: someone enters an address, then that inbox's owner clicks a link in a confirmation email. Nothing else is sent until they do, which keeps typos, bots and pranks off your list.
Domain at Hostinger? Its email tool, Reach, has a free first-year plan (check its limits) and double opt-in too, but its signup forms are made for WordPress and Hostinger's Website Builder. On a hand-built app, keep Reach for announcements later.
Create the list and put its form on your app
On substack.com, sign up and create a publication, Substack's word for a newsletter (the button has been Start writing and Create your Substack; it moves). Its address is written YOURNAME.substack.com from here on: replace it with yours. Then, in your dashboard, open Settings → Emails and turn on the toggle labeled Require email address confirmation (Substack's help has the current path). Substack warns that means fewer subscribers: fewer, real ones.
Skip Substack's embed, a piece of its page shown inside yours: it loads extra code, including trackers that report your visitors elsewhere, and can break your layout. A plain HTML form, written in your page's own language, needs no JavaScript (the code pages run):
<form action="https://YOURNAME.substack.com/subscribe" method="get">
<label for="sub-email">Your email</label>
<input id="sub-email" type="email" name="email" required autocomplete="email" placeholder="you@example.com">
<button>Subscribe</button>
<p>Free. You'll confirm on Substack.</p>
</form>
What are a request, GET and POST?
A request is a browser asking a server (the computer that answers) for something. GET means "show me", with form fields riding along in the address; POST means "do this", with the data sent separately. Only a POST should change anything.
⚠️ Keep "You'll confirm on Substack" beside the button, or people who land on Substack's page think they're done and never finish signing up.
Ask Claude Code (the AI assistant in VS Code, your code editor) to add it:
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: GET puts the address into the link, so Substack's page opens filled in. Test with a second address you own (Gmail delivers YOU+test@gmail.com to YOU@gmail.com): Substack's page should show it in the box, and once you confirm by email it appears in your dashboard's subscriber list. Then deploy (copy the change to your server) and add the same form to your footer.
Optional: your latest posts, from your own domain
The traqqit.com home page shows this form plus my newest posts. The page can't read Substack's feed directly:
What are a feed, headers and CORS?
A feed (RSS) is a file, Substack's is /feed, listing a site's newest posts as data. Headers are labels on every request and response (the server's answer). CORS is the browser rule that a page may read another site's data only if that site's response carries a header allowing it. Substack's feed carries none.
So a job on your server fetches the feed every hour and writes posts.json, a small file of JSON (data written as text), onto your own domain. Ask Claude for an hourly systemd timer, Linux's built-in scheduler (Big public data on a small server covers timers properly), or link to your Substack instead.
The prompt: Add an hourly systemd timer, myapp-posts, on my server: fetch https://YOURNAME.substack.com/feed, keep the newest 3 posts (title, date, link), write posts.json.tmp, rename it over /root/myapp/posts.json, and run it once. Keep posts.json out of my deploys, list the posts on my page, and show me the timer and the live file.
What's happening underneath: CORS binds browsers, not servers. Writing a temporary file and renaming it means visitors never get half a file. And posts.json now belongs to the server: a deploy that uploads your old copy, or mirrors your folder and deletes it, leaves the list stale or empty. Check both halves; curl fetches an address and prints the answer (replace myapp.yourdomain.com):
systemctl list-timers myapp-posts.timer
curl -s https://myapp.yourdomain.com/posts.json
The first shows the timer's next run, under an hour away. The second prints your posts, or [] before your first one.
Offer a copy, never a wall
The friendliest capture comes after the answer is already on screen, never before it: "Email me this comparison", then exactly one email, the thing they asked for.
📍 Following a marketing brief, my price tool, Medical Price & Bill Coach, added "Email me this" cards instead of an email gate, so every price and letter stays free on the page. The email itself says: "This is the one email you asked for. We don't sell or share your address."
The newsletter checkbox is separate, off by default, and stored with a timestamp. Record it and do nothing more until you actually run a newsletter; a pre-ticked box, or a yes not stored on its own, makes that future list unusable. Then three guards: validate the address, rate-limit it (cap requests per IP address, the number identifying a visitor's connection), and hide the whole card if sending isn't configured, so the page never promises an email it can't send.
Build the "Email me this" card
This needs your small server: a browser can't send email, and your mailbox password must never reach one. That password already sits in the server's env file, the root-only file of NAME=value settings a program reads at startup.
What are an endpoint, SMTP and SQLite?
An endpoint is one address on your server that a page sends requests to, like /api/email. SMTP is how a server hands an email to your mail host: it logs in to your mailbox, and the host delivers. SQLite is a whole database in one file, built into Python and recent Node versions.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: the server checks the address, counts this IP's last hour, writes a row, and hands the email to your mail host. It reads SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_FROM from the environment, so the password never touches your code or the chat. Add umami.track('email-capture') (Know if it's working) to see whether anyone uses it.
A container sees only the env files it was created with, so Claude re-creates myapp-api with your mail.env as a second --env-file; a card that stays hidden usually means it's missing. Node has no sender built in, so Claude adds nodemailer, a ready-made package (your server's first): install it in the server's code folder too, or docker logs myapp-api shows Cannot find module.
Check it yourself: submit the card on your live page, look for Sent to, and find the email in your inbox, not spam. Then read the row from your data folder, /root/myapp-data if you followed Add a backend only when forced (install the tool once with apt install sqlite3, Ubuntu's installer):
sqlite3 -header /root/myapp-data/capture.sqlite 'select * from captures order by ts desc limit 1'
The ip should be your own public address (search "what is my IP"). An internal one, starting 172., is Caddy's: your server would count every visitor as one and share a single limit. Ask Claude to read the X-Forwarded-For header Caddy adds.
Then add a line to your privacy page (Launch, honestly): you now store email and IP addresses, and why.
⚠️ Port 587 failing? It starts unencrypted and upgrades (STARTTLS); only 465 is encrypted from the first byte (secure: true in nodemailer, smtplib.SMTP_SSL in Python). Use what your mail host documents.
🛑 Keep free text the visitor typed (a name, a note) out of this email, or strangers can use your domain to mail their words to anyone.
🦥 Skipped: a shared rate limit. This one lives in memory, so it resets on a restart and miscounts across several copies of your server; move it into SQLite then.
🦥 The next three sections show how this course's own list works. Read them now; build them only when a hosted tool stops being enough.
The quiz is the lead magnet, and you're inside one (advanced: building your own, like this course)
What's a lead magnet?
Something useful you give away in exchange for an email address: a checklist, a result, a plan. A good one is worth having even if the person never opens another email.
This course is the worked example. The quiz at ship.traqqit.com/start hands you something useful, your path, before it asks for anything, and email is how the path arrives. Your answers become tags, labels stored with your address: your level, what you're building, how you'll reach people. They choose your lessons now and pick who gets the rare announcement later.
- JSON only. The signup endpoint refuses anything else. JSON forces a CORS preflight, the browser asking your server's permission first, which your server never grants other sites, so pages elsewhere can't post signups.
- A honeypot. A hidden field people never see. Bots fill it, and the server answers as if it worked and does nothing.
- One reply for everyone. It never says whether an address is already subscribed.
A daily cap per inbox treats you+anything@ and Gmail's dotted spellings as one inbox. And retaking the quiz with a subscribed address sends a "switch to this path?" email instead of an instant change, so nobody can rewire someone else's lessons.
Double opt-in that scanners can't fake (advanced: building your own, like this course)
Nothing goes out until the inbox owner confirms. Corporate mail scanners open every link in every email, so a confirm link that subscribes on a plain visit (a GET) signs up people who never clicked.
So the link opens a page with one button, Yes, send me the lessons, and only that button's POST starts the emails. Unsubscribe works the same way: the link shows a button, the POST does it. Record consent: the form wording they saw, plus the time, IP and browser of both the signup and the confirm tap. Unconfirmed signups are deleted after 30 days.
What's a signed token?
A code in a link that your server can check and nobody can forge: the server signs the address plus the link's job with a secret only it knows (an HMAC). Change one character and the check fails.
Each token is bound to one job, so a confirm token can't unsubscribe anyone and an unsubscribe token can't switch a path. Check that Claude's version signs the job too, and compares with timingSafeEqual, which takes equal time wherever a character differs, so guessing gets no hints:
const crypto = require('node:crypto');
const SECRET = process.env.MAIL_SECRET; // a long random string from your env file
const sig = (job, email) => crypto.createHmac('sha256', SECRET).update(job + '.' + email).digest('hex');
const makeToken = (job, email) => Buffer.from(email).toString('base64url') + '.' + sig(job, email);
function readToken(job, token) {
if (!SECRET || typeof token !== 'string') return null;
const i = token.lastIndexOf('.');
const email = Buffer.from(token.slice(0, i), 'base64url').toString();
const given = Buffer.from(token.slice(i + 1)), wanted = Buffer.from(sig(job, email));
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted) ? email : null;
}
readToken('unsub', t) on a confirm token returns null: wrong job, wrong signature.
A drip that sends itself, in their order (advanced: building your own, like this course)
What's a drip?
Emails sent automatically on a schedule after someone signs up: lesson 1 today, lesson 2 a few days later. Nobody presses send.
- Their pace. The first lesson goes out the moment they confirm, then one every 1, 2 or 4 days, set by one quiz question about time.
- Their path, recomputed. Before every send, the path is worked out again from the stored answers, so a new lesson reaches readers whose answers fit it, even ones who had finished.
- Its own budget. The same mailbox sends the fleet's password resets, so the drip runs on its own hourly budget, far under the mailbox's limit, with a slice held back for confirm emails.
A refusal at send time is about your account, not theirs: your host's relay (its sending server) reports dead mailboxes later, as bounce emails (automatic "could not deliver" notices). So the drip backs off 2 hours, then 4, up to 24, and stops the current round of sends instead of hammering. Read those bounce emails now and then.
Each email has one job: a subject with curiosity and a benefit, two sentences on why it matters today, one concrete action, one button.
Unsubscribe, the footer and the law
Every list email carries two headers (lines above the message, beside From and Subject) from RFC 8058, the standard for one-click unsubscribe:
List-Unsubscribe: <https://myapp.yourdomain.com/api/unsub?t=TOKEN>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Mail apps such as Gmail can then show their own Unsubscribe button, which sends a POST that takes effect immediately. Gmail and Yahoo require it from bulk senders. Substack and Reach handle unsubscribing for you.
What's CAN-SPAM?
The US law for commercial email: no misleading From or subject lines, a working opt-out you honor within 10 business days, and a valid postal address in every message.
This course's footer reads: Data Hound Tech LLC, 4539 N 22nd St, Ste N, Phoenix, AZ 85016. A business suite, never a home. No business address? A USPS PO box or a registered virtual mailbox is legal; never use your home.
- Your own domain, a real reply-to. Send as
you@yourdomain.com, with Reply-To (where answers go) set to an inbox a person reads. - Never buy a list. Those people never asked, and spam filters learn your domain fast.
- Names last. Keep the greeting name to one plain word, shown only after the inbox owner confirmed: spammers type a stranger's address, put their pitch in the name field, and let your email deliver it.
The EU, UK and Canada also expect consent before the first marketing email, with proof; recorded double opt-in is that proof. I'm a data person, not a lawyer: this is the floor, not legal advice.
What are SPF, DKIM and DMARC?
Three DNS records, entries in your domain's public address book, that receiving servers check on every message. SPF lists who may send for you, DKIM signs each message, and DMARC says what to do when a check fails.
Your list rides on the setup from Email that arrives: one SPF record, DKIM and DMARC. Each new sender (Reach, say) needs its include in that one SPF record, plus its DKIM records.
Read your email like a stranger would
Let Claude read your records first with nslookup, a command that asks DNS for one record. The Hostinger MCP (the connection from Your own server) can ask Reach too:
EXAMPLE — YOU ASK CLAUDE CODE
nslookup -type=TXT yourdomain.com to see the SPF line yourself.↳ Bash · nslookup -type=TXT on yourdomain.com, _dmarc.yourdomain.com and SELECTOR._domainkey.yourdomain.com↳ hostinger · execute · Reach connected sending domainWhat's happening underneath: receiving servers run these same lookups on every message. Now check a real delivery: send your "Email me this" email to a Gmail address and open it in Gmail on a computer (the phone app can't). Click the three-dot menu right of the Reply arrow, then Show original:
Any FAIL: ask Claude to compare your records with your mail host's docs. Other mail apps show the same in the raw message's Authentication-Results line. Then read the footer as a stranger would: why did this arrive, and from whom?
Honest metrics, and broadcasts through Reach
Count what you can actually know: confirmed subscribers, unsubscribes, bounces and emails sent. A pending signup isn't a subscriber.
What are a tracking pixel and an open rate?
A tracking pixel is a tiny invisible image in an email; when a mail app loads it, the sender logs an "open". The open rate is opens divided by emails delivered.
This course's emails carry no tracking pixel, so there's no open rate to flatter me. Treat any tool's open rate as rough anyway: some mail apps load every image automatically, and scanners click links. Replies and unsubscribes are the numbers that don't lie. Instead, the links in them carry UTM tags, so my own analytics shows which lesson brought someone back.
What are UTM tags?
Labels on the end of a link, like ?utm_source=email&utm_campaign=welcome. The page ignores them, but your analytics records them, so you see which email sent each visit. Umami, from Know if it's working, can report on them.
The drip stays automatic; one-off announcements to the whole list go through an email tool such as Hostinger Reach, which has the campaign editor and the stats. To copy a list you built into it:
- Sync only confirmed subscribers, with their tags.
- Switch Reach's own double opt-in off for that import (they already confirmed, or everyone gets a second confirmation), and back on afterwards if Reach's form feeds the list.
- Never import pending, unsubscribed or bounced rows.
- Re-sync before each announcement so new unsubscribes stay out, and copy Reach's unsubscribes back to your list.
Ticks from your "Email me this" box never confirmed anything: import those with double opt-in on.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: Reach imports in the background, so its "success" means accepted, not finished; the matching counts are the proof.
Do it with Claude: the prompts
- Add an Email me this card: one email, a default-off newsletter checkbox stored with a timestamp, rate-limited per IP.
- Build double opt-in where the emailed link opens a page with one button and only the POST subscribes. Do the same for unsubscribe, add RFC 8058 headers, and put my business postal address in the footer.
- Export my confirmed subscribers with their tags and sync them to my Reach list, with Reach's double opt-in off for the import.
What's happening underneath: robots fetch every link in an email before a human sees it, which is why state only ever changes on a POST. Have Claude prove it for every emailed link: curl the link, then show the subscriber's status didn't change.
Your turn
- Your app's form opens your Substack's subscribe page with a test address filled in, and once you confirm by email, that address is in your subscriber list.
- Require email address confirmation is on, and "You'll confirm on Substack" sits beside your button.
- Your "Email me this" email reached a Gmail inbox from your own domain, not spam: Show original says SPF, DKIM and DMARC PASS, and the footer gives the reason and a postal address that isn't your home.
- The newest
capturesrow shows optin 0; tick the box on a second try and the new row shows 1 with its time. Your privacy page mentions the addresses you store. - NOTES.md, your project's notes file, has "Rules for my own list": emailed links open a button page whose POST does the work, tokens bound to one job, RFC 8058 headers, confirmed-only syncs.
- You wrote down the one useful thing you'd email someone in exchange for their address.
Next, Get found on Google: your list brings people back, and search brings people who have never heard of you.