A Double Opt-In Newsletter in Next.js with SQLite and Nodemailer, No Mailchimp
The form at the bottom of this site’s homepage does not go to Mailchimp. It appends a row to a SQLite table, a background job sends a confirmation link, and when a new article ships, every confirmed address gets one email with a working one-click unsubscribe. Here is the whole thing, and the rules it has to follow.
Every Next.js newsletter tutorial I could find ends the same way: an API route that calls Mailchimp, Brevo, or SendGrid. That is fine if you want a marketing platform. I wanted a list of a few hundred readers, an email when I publish, and the consent records in my own database where I can see them. That is a few hundred lines, not a subscription, and the interesting parts are not the sending. They are the state machine, the tokens, and the two sets of rules a small Canadian sender has to satisfy: CASL, and the Gmail and Yahoo bulk-sender requirements that arrived in 2024.
Everything below is the code that runs on this site. If you subscribe at the bottom of the homepage, this is what happens to your address.
Three states, folded from events
The site is event sourced on one SQLite table, which I wrote about in a separate post. For the newsletter that means there is no subscribers table to update. There are five kinds of fact, appended in order, and a subscriber’s status is whatever falls out of folding them:
email_submitted { email } -- the homepage form
newsletter_verification_sent { email } -- confirmation email went out
newsletter_verification_failed { email, attempt, error } -- it did not
newsletter_email_verified { email, source } -- the link was clicked
newsletter_unsubscribed { email, source } -- link clicked, or mail-client POST// One record per address, in first-signup order.
for (const row of rows) { // ORDER BY rowid
const email = normalize(row.data.email);
const current = signups.get(email);
switch (row.type) {
case "email_submitted":
if (!current || current.status === "unsubscribed") signups.set(email, fresh("pending"));
else if (current.status === "pending" && sentMoreThanADayBefore(current, row)) resetVerification(current);
break;
case "newsletter_verification_sent": if (current) current.verificationSentAt = row.timestamp; break;
case "newsletter_verification_failed": if (current) current.verificationAttempts += 1; break;
case "newsletter_email_verified": if (current?.status === "pending") current.status = "verified"; break;
case "newsletter_unsubscribed": if (current) current.status = "unsubscribed"; break;
}
}Four rules live in those fifteen lines, and each one is a policy decision I would otherwise have had to bolt on later:
- A signup is pending until confirmed. Only
verifiedaddresses are ever emailed an article. - Unsubscribing ends the cycle. A later signup starts a fresh pending cycle that must confirm again, and a
newsletter_email_verifiedonly counts against a pending record, so an old confirmation link cannot revive an address that opted out. - Repeat submissions do not spam. Submitting the form twice in a minute is one pending record and one confirmation email. Submitting again a day or more after the last confirmation was sent resets it, because the first one was probably lost.
- Failures are counted, not flagged. Three
newsletter_verification_failedevents and the job stops trying that address.
The confirmation link is an HMAC, not a row
Most tutorials store a random token per subscriber and look it up. That is a second write, a column to keep, and a way for the token and the record to disagree. The link here carries the address and an HMAC over it, so the server can verify the click with no lookup at all:
export function verificationToken(db, email) {
return crypto.createHmac("sha256", signingKey(db))
.update(`verify:${normalize(email)}`)
.digest("hex");
}
export function verifyVerificationToken(db, email, token) {
const expected = Buffer.from(verificationToken(db, email));
const given = Buffer.from(token);
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
// https://8examples.com/api/newsletter/verify?email=…&token=…The unsubscribe token is the same construction over a different prefix, so the two links can never be swapped. The signing key comes from an environment variable if one is set; otherwise it is generated once, 32 random bytes, and kept in the events table as its own fact, so links keep working across deploys without a new secret in the pipeline. Compare with a constant-time function, always. An HMAC compared with === leaks its bytes one timing measurement at a time.
Sending the confirmation
The signup route does two things: append email_submitted, then kick the background job so the confirmation goes out immediately rather than on the next scheduled run. The job is a query for facts lacking a marker: every pending address with no newsletter_verification_sent gets one, in batches of twenty, and the marker is appended only after the send succeeds.
const due = signups.filter((s) => s.status === "pending" && !s.verificationSentAt && s.verificationAttempts < 3);
for (const signup of due.slice(0, batchSize)) {
try {
const delivered = await send(composeVerificationEmail(signup.email, verificationUrl(db, signup.email)));
if (!delivered) return; // mail not configured: stop, the next run retries
appendEvent(db, "newsletter_verification_sent", { email: signup.email });
} catch (error) {
appendEvent(db, "newsletter_verification_failed", { email: signup.email, attempt: signup.verificationAttempts + 1, error: String(error) });
}
}The email itself says what was asked for, has one button, and says what happens if you ignore it: nothing. That last line is not decoration. It is the answer to "why am I getting this," and it is the reason a wrong address does not become a complaint.
Rolling this out on an existing list was the same job with nothing special added. Every address that had signed up before verification existed folded as pending, so the first few runs after deploy sent them all a confirmation, twenty at a time. Nobody who had not confirmed received another article after that.
The blast: one email per confirmed address, exactly once
A new article is an event too, appended when the deploy’s catalog is reconciled with the stream; that mechanism is in the event sourcing post. It becomes a newsletter_blast_enqueued, and the sender works through the verified list:
const sent = new Set(sentMarkersFor(blast.blastId)); // newsletter_email_sent { blastId, email }
const remaining = newsletterSubscribers(db) // verified only, signup order
.filter((email) => !sent.has(email) && attempts(email) < 3);
for (const email of remaining) {
if (budget-- <= 0) return; // at most 20 per run
const message = composeBlastEmail(blast, email, unsubscribeUrl(db, email));
try {
if (!(await send(message))) return;
appendEvent(db, "newsletter_email_sent", { blastId: blast.blastId, email });
} catch (error) {
appendEvent(db, "newsletter_email_failed", { blastId: blast.blastId, email, attempt: attempts(email) + 1 });
}
}The sent marker per (blast, email) is the whole idempotency story. If the process dies after the tenth send, the next run starts at the eleventh. If the job runs twice by accident, the second run finds nothing to do. A blast completes once its list first drains, but it stays open to late confirmers for seven days, so someone who clicks their confirmation link on Thursday still gets Monday’s article.
Sending goes through Nodemailer to Gmail SMTP, because that is what the rest of the site uses. A plain Gmail account allows roughly 500 messages a day and a Workspace account about 2,000. The batch size and the one-run-a-minute pump keep a blast well under that, and the marker means moving to a transactional provider later is a change to one function.
One-click unsubscribe, the way mail clients expect it
An unsubscribe link in the footer is table stakes. What Gmail and Yahoo now want from anyone sending at volume, and what I would do regardless, is RFC 8058: two headers that let the mail client offer an unsubscribe button and act on it without opening a page.
headers: {
"List-Unsubscribe": "<https://8examples.com/api/newsletter/unsubscribe?email=…&token=…>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}The route behind that URL has to accept two callers. A person clicking the footer link arrives with a GET and should see a page. A mail client honouring the header arrives with a POST whose body is literally List-Unsubscribe=One-Click, and it expects a 200 with no confirmation step, no "are you sure," no sign-in. Both verify the HMAC, both append newsletter_unsubscribed once, and the second call for the same address is a no-op that still returns 200.
async function handle(request) {
const { searchParams } = new URL(request.url);
const email = searchParams.get("email"), token = searchParams.get("token");
if (!email || !token || !verifyUnsubscribeToken(db, email, token)) return page("That link did not check out.", 400);
recordUnsubscribe(db, email, request.method === "POST" ? "list-unsubscribe" : "link"); // idempotent
return page("You are unsubscribed.", 200);
}
export const GET = handle;
export const POST = handle;Two things the RFC and the mailbox providers are strict about. The URL must be HTTPS. And the headers must be covered by your DKIM signature, which they are automatically if your SMTP provider signs the message, as Gmail does. The bulk-sender rules apply at 5,000 messages a day and require the opt-out to take effect within two days; this one takes effect before the response is sent.
What CASL asks of a Canadian sender
Canada’s Anti-Spam Legislation is stricter than the US rules most tutorials are written against, and it applies to a newsletter the moment it promotes anything you sell. The requirements, and where each one lives in this design:
- Express consent before the first message. Consent has to be an opt-in action by the recipient, and you have to be able to show it. Here it is two events with timestamps: the signup and the confirmation click from the address itself. That is a better record than a checkbox, and it is the reason double opt-in is worth the friction.
- Identify the sender. Your name, a mailing address, and a way to contact you, valid for at least 60 days after sending. The footer of every email carries all three.
- An unsubscribe mechanism that works. It has to function for 60 days after the message and be honoured within 10 business days. The HMAC link never expires, and the unsubscribe is recorded on the click.
- Keep the records. An append-only table is a consent ledger by construction. Nothing about a subscriber is ever overwritten, so the history of an address is a query, not a reconstruction.
Testing it without sending anything
Every job takes its mail sender as a parameter, so the tests run against an in-memory SQLite database with a recording sender and assert on the exact events appended. The suite covers the pending-until-confirmed rule, the resend-after-a-day rule, unsubscribe-then-resignup, the late-confirmer window and its cutoff, three failures then give up, and a live run through the real routes: sign up, confirm, unsubscribe by GET, unsubscribe again by POST, and check that the old confirmation link is now dead. Eleven tests, about a second.
One of them failed intermittently for a day. It tampered with a token by overwriting its first character with a zero, which is a no-op one time in sixteen. Worth mentioning because tokens are exactly where a flaky test hides a real bug.
What you get for a few hundred lines
- A list you own, in a file you can copy, with the consent history of every address.
- No monthly fee, no external dashboard, no API key that can leak your subscribers.
- Double opt-in, one-click unsubscribe headers, and the CASL identification and record-keeping rules, met in code rather than in a vendor’s settings page.
- At-least-once sending with an idempotency key, so a crash or a retry never means a duplicate.
What you do not get is analytics, templates, A/B tests, or a warmed-up sending domain. If you need those, the tutorials that end at Mailchimp are right for you. If you need a few hundred people to hear about the next post and want to be certain each of them asked, this is enough.
Frequently asked questions
- Do I need double opt-in for a newsletter?
- Canadian law does not use the phrase, but it does require express consent you can prove, and a confirmed click from the address itself is the cleanest proof there is. Double opt-in also keeps typos, bots, and other people's addresses off your list, which protects your sender reputation. If you send to Canadians, treat it as required.
- How do one-click unsubscribe headers work?
- Two headers on every email: List-Unsubscribe with an HTTPS URL, and List-Unsubscribe-Post with the exact value List-Unsubscribe=One-Click. A mail client that shows an unsubscribe button sends an HTTP POST to that URL with no human involved, so the URL has to unsubscribe on POST without any confirmation step. Gmail and Yahoo require this for senders over 5,000 messages a day and expect the opt-out honoured within two days.
- Can I send a newsletter through Gmail SMTP?
- For a small list, yes. A regular Gmail account sends about 500 messages a day and a Workspace account about 2,000. Send in batches, keep the per-recipient sent marker so a retry never double-sends, and move to a transactional provider before the list outgrows the limit.
- What does CASL require in each newsletter email?
- Who you are, including a mailing address and a way to contact you that stays valid for 60 days; an unsubscribe mechanism that works for 60 days after sending; and honouring an unsubscribe within 10 business days. And you need express consent before the first message, recorded so you can show when and how it was given.
- What happens if someone unsubscribes and then signs up again?
- They start over as pending and have to confirm again. An old confirmation link must not revive an unsubscribed address, because the consent it proved was withdrawn. Folding the events in order gives you that for free: an unsubscribe ends the cycle, and a later signup begins a new one.
Related
- Event sourcing on SQLite in production: the table all of this folds from, and how a static blog tells the server a post exists.
- Rocket.Chat 429 and the realtime API: the same at-least-once thinking applied to a chat integration.
- Websites for Calgary small businesses: where the CASL details matter most.
Comments 0
No comments yet. Start the conversation.