Visit homepage

Cultivating email with Val Town

  • Planted:
  • Last watered:

I’ve been slowly thinking through how I’ll use email for my garden. My basic wish is a subscription option for anyone who cares to read what I write and doesn’t use RSS.

There are a ton of email vendors out there—an overwhelming number, actually—so my main goal was to simplify as much as possible. I narrowed it down to two options: buttondown.email, or build my own custom logic with Val Town.

Buttondown

I went with Val Town, but if I ever want to turn to a more feature-rich solution, I’d probably use Buttondown. If you want to see it in action, Robin Rendle, swyx, cassidoo, and People & Blogs all use it for their newsletters. It has off-the-shelf email verification, you can write emails in markdown, plus there’s a bunch of other stuff like analytics and monetization (which I don’t have any plans for, btw).

Val Town

I wrote this show ’n’ tell in November 2023 around when I wrote that I hoped my next, next job would be at Val Town. That was two years before I ended up joining Val Town. Dan Levine linked to this post in Accel's announcement for Val Town's Seed round. I’ve since refactored the original val because vals are now versioned folders of code instead of single files, but I’m preserving this blog post with static code as a snapshot of that moment in time.

I wrote a Val for email subscription signup—feel free to fork and remix if you’d like. I’ll explain a few things, but if you want to jump to demo + code, my newsletter signup is in the footer and the code is below. You can also view the val’s README (which, at this point, is a much tighter explanation of how it works—although it’s missing the Why Val Town at the bottom of this post).


import { fetchVerificationEmailHtml } from "https://esm.town/v/petermillspaugh/fetchVerificationEmailHtml";
import { refreshVerificationToken } from "https://esm.town/v/petermillspaugh/refreshVerificationToken";
import { sendVerificationEmail } from "https://esm.town/v/petermillspaugh/sendVerificationEmail";
import { upsertEmailSubscriber } from "https://esm.town/v/petermillspaugh/upsertEmailSubscriber";
import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { Hono } from "npm:hono";
export async function emailSubscription(req: Request) {
const app = new Hono();
app.post("/send-verification", async (c) => {
const formData = await c.req.formData();
const name = formData.get("name");
const email = formData.get("email");
if (typeof name !== "string" || typeof email !== "string") {
return Response.json({
message: "Unexpected missing value for name or email.",
});
}
const token = crypto.randomUUID();
await upsertEmailSubscriber({ name, email, token });
// Lack of await is intentional: send optimistic success response, then send email and notify myself async
sendVerificationEmail({
emailAddress: email,
html: fetchVerificationEmailHtml({ email, token }),
});
return Response.json({
success: true,
message: "Sent verification email.",
});
});
app.put("/confirm-verification", async (c) => {
const email = c.req.query("email");
const token = c.req.query("token");
const { newToken, didRefresh } = await refreshVerificationToken({
email,
token,
});
if (didRefresh) {
// Lack of await is intentional: send optimistic success response, then send email and notify myself async
sendVerificationEmail({
emailAddress: email,
html: fetchVerificationEmailHtml({
email,
token: newToken,
reVerifying: true,
}),
});
return Response.json({ message: "Resent confirmation email." });
}
await sqlite.execute({
sql: `UPDATE subscribers SET verified = 1 WHERE email = ?;`,
args: [email],
});
// No need to await: just emailing myself a notification
sendEmail({
subject: `${email} verified for petemillspaugh.com subscription`,
text: `Verification complete for ${email}’s subscription to petemillspaugh.com`,
});
return Response.json({
confirmed: true,
message: "Verified email address.",
});
});
app.get("/", async (c) => {
return c.html(`
<html>
<head>
<title>Sign up for petemillspaugh.com</title>
</head>
<body>
<h2>Sign up for clippings from Pete’s garden</h2>
<form action="https://petermillspaugh-emailSubscription.web.val.run/send-verification" method="post">
<label for="name">First name:</label><br>
<input type="text" id="name" name="name" required><br>
<label for="email">Email:</label><br>
<input type="email" id="email" name="email" required><br><br>
<input type="submit" value="Sign up">
</form>
</body>
</html>
`);
});
return app.fetch(req);
}

The Val is a Hono router with two endpoints: /send-verification and /confirm-verification. The first route (called from my frontend—more on that below):

  • Extracts name and email from the request form data
  • Generates a token using Deno’s built-in crypto.randomUUID() function
  • Upserts a row into the subscribers table (using Val Town’s built-in SQLite database)
  • Sends an email with a confirmation link

The confirmation link sends the user to a page on my garden, which fires a request to the second endpoint with the email and token as query params. The /confirm-verification handler:

  • Pulls the email and token from the request and finds a matching row in the subscribers table
  • If the subscribed_at timestamp written by the first endpoint is in the last 30 minutes, the verified column flips to true and the frontend updates upon the success response
  • If the timestamp is older than 30 minutes, the endpoint updates the subscribed_at timestamp and resends a verification email

I also added a simple HTML form on GET /, which you can see in action by opening the "Browser preview" in the embedded Val.

Queuing the verification email

To tidy up, I pulled out a handful of helper Vals that the main router Val imports. This one sends emails and alerts me about new signups or errors:


import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
interface VerificationEmailParams {
emailAddress: string;
html: string;
}
export async function sendVerificationEmail({
emailAddress,
html,
}: VerificationEmailParams) {
try {
// email a confirmation link to the subscriber
await sendEmail({
to: emailAddress,
from: {
name: "Pete Millspaugh",
email: "petermillspaugh.sendVerificationEmail@valtown.email",
},
replyTo: "pete@petemillspaugh.com",
subject: "Confirm your subscription to petemillspaugh.com",
html,
});
// email myself a success notification
await sendEmail({
subject: `${emailAddress} subscribed to petemillspaugh.com`,
text: `A notification for ${emailAddress} verification should come in any minute now.`,
});
} catch (error) {
const { name, message, stack } = error;
await sendEmail({
subject: `Error sending email verification to ${emailAddress}`,
html: ` <pre>
<code>
${name}
${message}
${stack}
</code>
</pre>
`,
});
}
}

Sending emails to people other than yourself is a paid feature, but if you want to stay on the free plan, you can go with a package like nodemailer or @sendgrid/mail.

Originally I was awaiting the @std/email invocation before sending a success response to the client, but I refactored to send an optimistic success response and queue up the email to send asynchronously (as a Promise). The thought is, instant feedback is expected UX on a Web form submission, but not for a confirmation email. Plus, it takes a few seconds for someone to open up their email, so that’s extra cushion. Definitely not a perfect science, but to gauge the actual latency I measured the awaited sendEmail call a dozen times, and it’s quite fast: 95 milliseconds, 98, 151, 92, 30, 119, 79, 36, 26, 30, 72, and 21.

Upserting subscribers and refreshing tokens with SQLite

As mentioned, Val Town comes with your own SQLite database courtesy of Turso. Here’s the Val that /send-verification invokes to upsert a subscriber:


import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
interface UpsertEmailSubscriberParams {
name: string;
email: string;
token: string;
}
export async function upsertEmailSubscriber({ name, email, token }) {
return sqlite.execute({
sql: `
INSERT INTO subscribers (name, email, verification_token)
VALUES (?, ?, ?)
ON CONFLICT(email)
DO UPDATE SET
name = excluded.name,
verification_token = excluded.verification_token,
subscribed_at = CURRENT_TIMESTAMP;
`,
args: [name, email, token],
});
}

And here’s the Val that /confirm-verification calls to refresh the token if it’s been over 30 minutes:


import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
interface RefreshVerificationTokenParams {
email: string;
token: string;
}
export async function refreshVerificationToken({
email,
token,
}: RefreshVerificationTokenParams) {
const newToken = crypto.randomUUID();
const { rowsAffected } = await sqlite.execute({
sql: `
UPDATE subscribers
SET verification_token = CASE
WHEN datetime(subscribed_at) < datetime(’now’, ’-30 minutes’) THEN ?
ELSE verification_token
END,
subscribed_at = CASE
WHEN datetime(subscribed_at) < datetime(’now’, ’-30 minutes’) THEN CURRENT_TIMESTAMP
ELSE subscribed_at
END
WHERE email = ? AND verification_token = ?
AND (datetime(subscribed_at) < datetime(’now’, ’-30 minutes’));
`,
args: [newToken, email, token],
});
return { newToken, didRefresh: rowsAffected === 1 };
}

Signup form

Here’s a simplified example of a signup form using React. My full implementation is here. As mentioned, the GET / handler returns an HTML form that fires a POST /send-verification, so it is possible to skip your own frontend and just fork the Val.


const EmailSignupForm = () => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
async function handleSubmit(e) {
e.preventDefault();
setName("");
setEmail("");
const formData = new FormData();
formData.append("name", name);
formData.append("email", email);
await fetch(
"https://petermillspaugh-emailSubscription.web.val.run/send-verification",
{
method: "POST",
body: formData,
},
);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">First name</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
type="text"
required={true}
/>
<label htmlFor="email">Email</label>
<input
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
required={true}
/>
<button type="submit">Subscribe</button>
</form>
);
};

Confirmation page

And here’s a simplified example of a confirmation page. Full implementation here. As a stretch feature, I’d like to move this confirmation UI into a Val so that full email subscriptions work out of the box just by forking a handful of Vals.


const EmailConfirmationPage = () => {
const router = useRouter(); // Next.js page directory router
const { email, token } = router.query;
const [isConfirmed, setIsConfirmed] = useState(false);
useEffect(() => {
async function confirmEmail() {
if (!email || !token) return;
const response = await fetch(
`https://petermillspaugh-emailSubscription.web.val.run/confirm-verification?email=${email}&token=${token}`,
{ method: "PUT" },
);
const { confirmed } = await response.json();
if (confirmed) setIsConfirmed(true);
}
confirmEmail();
}, [email, token]);
if (!isConfirmed) return null;
return (
<main>
<h1>You’re all set!</h1>
</main>
);
};

Sending emails

I built the vals for email signup and verification a couple months before building out the logic to actually send emails. That is to say—if you’re unsure how you’d like to use email for your blog, but you at least want to start cultivating subscriber emails, that’s definitely an option.

Here’s the feature set of my SLC email newsletter:

  • Send emails to subscribers monthly on a cron
  • Log newsletters and all sent emails in SQL tables
  • Create a monthly newsletter by forking a template
  • Send test emails to myself
  • Send a monthly reminder to myself to write the next newsletter

The emails themselves are just unstyled HTML, but I link to the Web version for easy-on-the-eyes reading. If I end up wanting custom email components with styling and all, I’ve bookmarked MJML and HEML as options to research. Josh Comeau wrote a nice blog post on his robust MJML email workflow.

Here’s the Val that sends newsletters on a 0 12 1 * * cron (first of the month at 12pm UTC):


/** @jsxImportSource https://esm.sh/preact */
import { generateNewsletterJsx } from "https://esm.town/v/petermillspaugh/generateNewsletterJsx";
import { newsletters } from "https://esm.town/v/petermillspaugh/newsletters";
import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { render } from "npm:preact-render-to-string";
type SubscriberRow = [subscriberId: number, emailAddress: string];
export async function sendEmailNewsletter(interval: Interval) {
const {
jsx: newsletterContent,
subject,
webUrl,
targetSendDate,
} = newsletters[newsletters.length - 1];
// no-op and alert if the current timestamp isn’t within five minutes of the targetSendDate
const fiveMinutes = 5 * 60 * 1000;
if (Math.abs(new Date(targetSendDate).getTime() - Date.now()) > fiveMinutes) {
return await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: `Send attempt for newsletter_id=${newsletters.length} is not within 5 minutes of target send date`,
});
}
// no-op and alert if interval was run <28 days ago (enforce max one newsletter per month)
const twentyEightDaysAgo = Date.now() - 28 * 24 * 60 * 60 * 1000;
if (
!interval.lastRunAt ||
interval.lastRunAt.getTime() > twentyEightDaysAgo
) {
return await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: "Val fired twice in <28 days",
});
}
const { rows: newsletterEmailLogs } = await sqlite.execute({
sql: `SELECT * FROM email_logs WHERE newsletter_id = ?;`,
args: [newsletters.length],
});
// no-op and alert if there’s already a log of the latest newsletter
if (newsletterEmailLogs.length > 0) {
return await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: `Duplicate send attempt for newsletter_id=${newsletters.length}`,
});
}
const { rows: subscribers } = await sqlite.execute(
`
SELECT id, email
FROM subscribers
WHERE verified = 1
AND subscribed_at IS NOT NULL;
`,
);
for (const [
subscriberId,
emailAddress,
] of subscribers as unknown as SubscriberRow[]) {
const { rows: subscriberEmailLogs } = await sqlite.execute({
sql: `
SELECT *
FROM email_logs
WHERE newsletter_id = ?
AND subscriber_id = ?;
`,
args: [newsletters.length, subscriberId],
});
// skip subscriber and alert if log exists for newsletter + subscriber
if (subscriberEmailLogs.length > 0) {
await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: `Duplicate send attempt for subscriber_id=${subscriberId} and newsletter_id=${newsletters.length}`,
});
continue;
}
const jsx = generateNewsletterJsx({
webUrl,
newsletterContent,
emailAddress,
});
await sendEmail({
subject,
html: render(jsx),
to: emailAddress,
from: {
name: "Pete Millspaugh",
email: "petermillspaugh.sendEmailNewsletter@valtown.email",
},
replyTo: "pete@petemillspaugh.com",
});
// log sent email
await sqlite.execute({
sql: `
INSERT INTO email_logs (newsletter_id, subscriber_id)
VALUES (?, ?);
`,
args: [newsletters.length, subscriberId],
});
}
}

This Val has several layers of protection to avoid double sending emails. It no-ops and alerts me if:

  • The cron interval was run less than 28 days ago
  • The invocation is not within 5 minutes of the target send date
  • The latest newsletter has already been sent
  • There’s already a log of that newsletter emailed to a given subscriber

Since this Val is public, anyone can run it, so the first two checks ensure an early return if it’s manually run. The third check would trigger if the cron runs and I hadn’t gotten around to adding the next newsletter (which seems inevitable). I also log each sent email, so the final check ensures a particular subscriber hasn’t already received the latest edition.

Those mechanisms feel a bit hacky, so any suggestions are welcome! Feel free to comment on the Val or submit a PR.

Sending test emails

I also have a Val to send test emails. When you’re writing up a newsletter to send to subscribers, it’s helpful to send it to yourself ahead of time to proofread, see how it looks in different email clients, etc.


/** @jsxImportSource https://esm.sh/preact */
import { generateNewsletterJsx } from "https://esm.town/v/petermillspaugh/generateNewsletterJsx";
import { getJune2024Newsletter } from "https://esm.town/v/petermillspaugh/june2024";
import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { render } from "npm:preact-render-to-string";
export function sendTestEmailNewsletter(interval: Interval) {
/*
* Since this is a public Val, anyone can run it.
* This early return prevents spamming me with test emails.
* Comment out the early return to actually test.
*/
if (interval.lastRunAt) {
return console.log("early return");
}
const { jsx: newsletterContent, subject, webUrl } = getJune2024Newsletter();
const jsx = generateNewsletterJsx({
webUrl,
newsletterContent,
emailAddress: "test",
});
sendEmail({
subject,
html: render(jsx),
from: {
name: "Pete Millspaugh",
email: "petermillspaugh.sendTestEmailNewsletter@valtown.email",
},
replyTo: "pete@petemillspaugh.com",
});
}

Publishing process

I write and publish the Web version first, either the day or morning before the newsletter goes out. So creating a fresh newsletter means forking the Val for a prior one—like the one below from January—and converting my MDX to JSX. I could also go directly from MDX to HTML in the Val with an npm package like I do in my garden, but it’s pretty easy to have Copilot convert from MDX to JSX for me.


/** @jsxImportSource https://esm.sh/preact */
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
export function getJanuary2024Newsletter() {
const subject = "#1 — January 2024";
const webUrl = "https://petemillspaugh.com/january-2024";
const targetSendDate = "2024-02-01 23:40:00";
const jsx = (
<main>
<h1>{subject}</h1>
<p>...</p>
</main>
);
return {
jsx,
subject,
webUrl,
targetSendDate,
};
}
async function insertIntoNewslettersTable() {
const { subject, webUrl, targetSendDate } = getJanuary2024Newsletter();
await sqlite.execute({
sql: `
INSERT INTO newsletters (subject, web_url, target_send_date)
VALUES (?, ?, ?);
`,
args: [subject, webUrl, targetSendDate],
});
}
// await insertIntoNewslettersTable();
// console.log(await sqlite.execute(`SELECT * from newsletters;`));

I also considered writing the newletter in a Val first then fetching it the garden at runtime. That way I wouldn’t have to copy-paste-convert, and there’d be one source of truth for the newsletter content. That felt a bit heavy, though, and I wanted to maintain the snappy load times of my static site for the Web version.

Storing subscribers, newsletters, and sent emails with SQLite

As mentioned, I have a few SQLite tables for subscribers, each newsletter version, and logs for all sent emails.


import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
export async function createSubscribers() {
await sqlite.execute(
`
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
verified BOOLEAN DEFAULT 0,
verification_token TEXT UNIQUE,
subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`,
);
}


import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
export async function createNewslettersTable() {
await sqlite.execute(
`
CREATE TABLE IF NOT EXISTS newsletters (
id INTEGER PRIMARY KEY,
subject TEXT NOT NULL UNIQUE,
web_url TEXT NOT NULL UNIQUE,
target_send_date TIMESTAMP NOT NULL UNIQUE
);
`,
);
}


import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
export async function createEmailLogsTable() {
await sqlite.execute(
`
CREATE TABLE IF NOT EXISTS email_logs (
id INTEGER PRIMARY KEY,
subscriber_id INTEGER NOT NULL,
newsletter_id INTEGER NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscriber_id) REFERENCES subscribers (id) ON DELETE CASCADE,
FOREIGN KEY (newsletter_id) REFERENCES newsletters (id) ON DELETE CASCADE
);
`,
);
}
await createEmailLogsTable();

In email_logs, the ON DELETE CASCADE clause means if a subscriber or newsletter record was deleted it would delete all related logs. I don’t expect this to come up, but maybe if someone asked me to hard delete their personal info.

Over time it’s possible the email_logs table could become big enough that I’d want to do stuff for performance, like maybe adding indexes on subscriber_id and newsletter_id. That would be a good problem to have! No need to prematurely optimize now, though.

Unsubscribe

I also wrote an unsubscribe Val that sets subscribed_at to null and alerts me via email. I thought about omitting the alert so I don’t get sad if a stranger or friend unsubscribes, but that hasn’t happened yet (phew). And I would want to know if people started dropping like flies so I could consider slowing down newsletter frequency, for example.


import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
export async function unsubscribe(req: Request) {
const searchParams = new URL(req.url).searchParams;
const emailAddress = searchParams.get("email");
if (!emailAddress) {
// No-op if email query param is missing
return Response.json(
"Email address missing in unsubscribe URL. If you tried to unsubscribe and are still getting emails, send me a note at pete@petemillspaugh.com",
);
}
await sqlite.execute({
sql: `
UPDATE subscribers
SET subscribed_at = NULL
WHERE email = ?
`,
args: [emailAddress],
});
sendEmail({
subject: `Someone unsubscribed from petemillspaugh.com clippings`,
text: `${emailAddress} unsubscribed 😢`,
});
const responseHtml = `
<main>
<p>You’ve successfully unsubscribed.</p>
<p>Take care, Pete</p>
</main>
`;
return new Response(responseHtml, {
headers: { "Content-Type": "text/html" },
});
}

Why go with Val Town

Buttondown would be easier, but Val Town is more fun and yields more learning for me, which is a major reason why I tend to my garden in the first place. It’s also completely customizable, so I can build a robust system over time if I want to. Price is about the same (free tier, then $9/mo for Buttondown versus $10/mo for Val Town Pro).

A major benefit to using Val Town is being able to send emails with no extra dependency. It’s also handy because you get a SQLite database out of the box—some meta frameworks offer something similar, like Vercel Storage. And because my Vals are public, others can fork and improve upon them (any suggestions are welcome—just comment on a Val). Not only that—knowing my code is public and might be used by others forces me to write cleaner/better code (in theory, at least).

As always, the Val Town team is super helpful and responsive. Thanks to André and Steve for answering questions I had while building this. Hop in the Discord if you’re interested to see all sorts of Vals people are coming up with!

Backlinks