Visit homepage

Building an email-based course creator with Val Town

  • Planted:
  • Last watered:

This Show ’n’ tell is a Seedling that I planted a couple years ago. I’m not actively working on the project, but I’ve preserved this writeup for posterity.

I recently read Make It Stick, a book written by cognitive psychologists about effective learning techniques, backed by research. One of the topics covered is spaced repetition, which I started practicing in my own learning this summer using flashcard software Anki.

While reading, I was reminded of Just JavaScript, which I took in 2021 as an email-based course in beta before its full launch as a web application (amazing course btw, for anyone who wants to strengthen their JavaScript mental models). An email-based course means you receive lessons or chapters as emails rather than in a PDF or website.

Just JavaScript was programmed so that when you submitted the quiz at the end of each lesson you’d receive the next one. But what if instead of receiving the next lesson immediately you had to wait a day? Spaced repetition is key to learning, and email can be a useful medium to naturally bake in time between lessons.

Email-based course creator

With all that in mind, I am making an email-based course creator using val.town that anyone who knows some JavaScript and HTML can fork to create their own email-based course. Along with spaced repetition, I lean on other effective learning techniques from the book to inform course structure:

  • Fill-in-the-blank before each lesson as generation
  • Mixing up order of content as interleaving
  • Quizzing as retrieval practice
  • Free form writing at the end of each lesson as elaboration and reflection

As an example (and hopefully a useful resource), I’m using the course creator to write a course about learning techniques and research covered in Make It Stick. And if you’re interested in Val Town, I also wrote about creating a global button and managing email subscribers.

Signing up

Note that when I first wrote this, vals were single JavaScript/TypeScript files. Now vals are versioned folders of code, so the architecture of a project like this wouldn’t be quite as modular. I’ve kept the original implementation here.

For signup and email verification, I forked a Hono router Val that I wrote for petemillspaugh.com subscribers (signup for that is in the footer). See the code below. I added a root endpoint / that returns a signup form for the course, which you can test out in the "Browser preview" at the bottom of the iframe. I also set up a landing page for the course that hits the /send-verification endpoint (update: I’ve removed the form since I’m not actively maintaining the project). I cover signup and verfication in more detail in my Show ’n Tell on cultivating emails.


import { fetchConfirmationHtml } from "https://esm.town/v/petermillspaugh/fetchConfirmationHtml";
import { fetchSignupHtml } from "https://esm.town/v/petermillspaugh/fetchSignupHtml";
import { markLessonComplete } from "https://esm.town/v/petermillspaugh/markLessonComplete";
import { refreshToken } from "https://esm.town/v/petermillspaugh/refreshToken";
import { sendLesson } from "https://esm.town/v/petermillspaugh/sendLesson";
import { sendLessonResponses } from "https://esm.town/v/petermillspaugh/sendLessonResponses";
import { sendVerification } from "https://esm.town/v/petermillspaugh/sendVerification";
import { upsertStudent } from "https://esm.town/v/petermillspaugh/upsertStudent";
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";
import { JSX } from "npm:react";
export async function emailSubscription(req: Request) {
const app = new Hono();
app.get("/", async (c) => {
return c.html(fetchSignupHtml());
});
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 upsertStudent({ name, email, token });
// Lack of await is intentional: send optimistic success response, then send email and notify myself async
sendVerification({
emailAddress: email,
html: fetchConfirmationHtml({ 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 refreshToken({ email, token });
if (didRefresh) {
// Lack of await is intentional: send optimistic success response, then send email and notify myself async
sendVerification({
emailAddress: email,
html: fetchConfirmationHtml({
email,
token: newToken,
reVerifying: true,
}),
});
return Response.json({ message: "Resent confirmation email." });
}
await sqlite.execute({
sql: `UPDATE students SET verified = 1 WHERE email = ?;`,
args: [email],
});
// Send the first lesson
await sendLesson({ emailAddress: email, lesson: 0 });
// No need to await: just emailing myself a notification
sendEmail({
subject: `${email} verified for subscription to: Make It Stick (in 10 days, via email)`,
text: `Verification complete for ${email}’s subscription to: Make It Stick (in 10 days, via email)`,
});
return Response.json({
confirmed: true,
message: "Verified email address.",
});
});
/*
* TODO: POST requests are blocked for security reasons on mobile email clients.
* I could use a GET request with a req body, but that’s a hack/anti-pattern.
* I don’t want to move the form to a webpage because it should be in the lesson flow.
* TBD how to handle this. Perhaps an email handler Val?
*/
app.post("/complete-lesson", async (c) => {
const email = c.req.query("email");
if (typeof email !== "string") {
// Notify myself of the unexpected case
sendEmail({
subject: "Unexpected missing email query param on /complete-lesson",
text: "Email query param should be populated: check lesson form submission.",
});
return;
}
await markLessonComplete(email);
const lesson = c.req.query("lesson");
const formData = await c.req.formData();
await sendLessonResponses({ email, lesson, formData });
const responseHtml = `
<main>
<p>Thanks for completing the lesson! Your responses should be in your inbox any second, and the next lesson will go out tomorrow.</p>
<p>In the meantime, feel free to send any feedback to <a href="mailto:pete@petemillspaugh.com">pete@petemillspaugh.com</a>, or by replying directly to any lesson email.</p>
</main>
`;
return new Response(responseHtml, {
headers: { "Content-Type": "text/html" },
});
});
return app.fetch(req);
}

To be written (maybe)

The rest of the code stores subscribers with SQLite, send lessons via email with a cron, handles unsubscribe, emails student responses, and more. I don’t plan to continue working on this anytime soon, although I do still like the idea and think it sounds fun. That said, feel free to reach out if you’re interested in talking about it. I’m interested in interactive education, and I generally like email as a medium.

Backlinks