Visit homepage

Cultivating emails 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 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 README.

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:

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:

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

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.

EmailSignup.tsx

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.

EmailConfirmation.tsx

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

Btw, 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):

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.

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.

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.

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.

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