petermillspaugh-emailsubscription.web.val.run
Readme

Val Town email subscriptions 📧

Handles email subscription signup+verification and sending.

Steps

  1. Fork and run the sibling Vals to set up SQLite tables: createSubscribers, createNewsletters, createEmailLogs
  2. Fork this Val and update for your use case (e.g. confirmation link, sendEmail args, form fields, etc.)
  3. Add an HTML form to your frontend that calls /send-verification, or just use / to return a simple HTML form
  4. Add a confirmation page on the frontend that calls /confirm-verification
  5. Fork sibling Vals to get verification email markup, send verification emails, create a list of newsletters, create a newsletter template, create an individual newsletter, send test emails, send emails, and handle unsubscribe
  6. Optionally, fork cousin Vals to view subscribers, email yourself daily subscriber count, email yourself a reminder to write your next newsletter, and send test emails

Frontend form

You should have a form that hits the /send-verification API endpoint on submit. Remember to adjust the endpoint URL to that of your fork (or else you'll be signing people up for my website!). As a simple alternative, you could use the / handler of this Val, which returns a simple HTML form. Here's a simple example using React:

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> ); };

You can see a full example on petemillspaugh.com: signup in the footer and code on github.

You can add/remove input fields as you wish, of course (e.g. maybe you don't need a name, or maybe you want a how'd-you-hear-about-us field). Just adjust the SQL and frontend implementation accordingly.

Frontend confirmation page

Create a confirmation page that accepts an email and token as query params and calls the /confirm-verification endpoint. Simple example using React (and Next.js /page directory):

const EmailConfirmationPage = () => { const router = useRouter(); 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> ); };

Full example is here and code is here.

As an alternative, you could make /confirm-verification a GET route and have your email confirmation link sent by the first route be https://petermillspaugh-emailSubscription.web.val.run/confirm-verification?email=${email}&token=${token} (swapping in your namespace). That would be marginally faster, probably, but you'd still need some way to convey confirmation to the user (e.g. add some "You're all set" message to the return). Plus, the route writes to the subscribers table, so a PUT feels more appropriate.

Notes

  • Sending emails to people other than yourself on Val Town is a paid feature—if you want to stay on the free plan, you can go with a package like nodemailer or @sendgrid/mail
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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);
}
Val Town is a social website to write and deploy JavaScript.
Build APIs and schedule functions from your browser.
Comments
Nobody has commented on this val yet: be the first!
v77
February 2, 2024