Avatar

petermillspaugh

46 public vals
Joined July 29, 2023

Val Town email subscriptions: newsletter reminder

Cousin Val to @petermillspaugh/emailSubscription for emailing yourself a reminder to send your next newsletter.

Since this Val is public, anyone can run it. I've commented out the function body that actually emails me to prevent anyone from spamming me, but you can fork this as a private Val to set a cron reminder.

1
2
3
4
5
6
7
8
9
import { newsletters } from "https://esm.town/v/petermillspaugh/newsletters";
import { email } from "https://esm.town/v/std/email?v=11";
export async function sendNewsletterReminder(interval: Interval) {
// await email({
// subject: `Reminder to prepare newsletter #${newsletters.length + 1}`,
// html: `One week left to write up the next newsletter!`,
// });
}

Val Town email subscriptions: generate newsletter template

Cousin Val to @petermillspaugh/emailSubscription — see docs there.

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
/** @jsxImportSource https://esm.sh/preact */
import { JSX } from "npm:preact";
interface NewsletterJsxParams {
webUrl: string;
newsletterContent: JSX.Element;
emailAddress: string;
}
export function generateNewsletterJsx({ webUrl, newsletterContent, emailAddress }) {
return (
<main>
<header>
<p>
<em>
You can also read the <a href={webUrl}>Web version</a> of this clipping.
</em>
</p>
</header>
{newsletterContent}
<footer>
<hr />
<p>
<em>
This clipping was sent via my{" "}
<a href="https://petemillspaugh.com/cultivating-emails">custom email newsletter logic</a> built with{" "}
<a href="https://val.town">Val Town</a>.
</em>
</p>
<p>
<a href={`https://petermillspaugh-unsubscribeFromNewsletter.web.val.run?email=${emailAddress}`}>
Unsubscribe
</a>
</p>
</footer>
</main>
);
}

Val Town email subscriptions: unsubscribe

Cousin Val to @petermillspaugh/emailSubscription — see docs there.

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

Val Town email subscriptions: newsletters

Cousin Val to @petermillspaugh/emailSubscription.

Process for sending out a newsletter:

  1. Publish newsletter on the Web
  2. Fork and update monthly newsletter Val like january2024
  3. Add new newsletter to this list Val
  4. sendEmailNewsletter cron val will attempt to send latest newsletter on the first of the month
1
2
3
4
5
import { getJanuary2024Newsletter } from "https://esm.town/v/petermillspaugh/january2024";
export const newsletters = [
getJanuary2024Newsletter(),
];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
);
`,
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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();

petemillspaugh.com clippings: #1 – January 2024

Process for sending out a newsletter:

  1. Publish newsletter on the Web
  2. Fork this val and update subject, webUrl, targetSendDate
  3. Uncomment call to insertIntoNewslettersTable
  4. Add to @petermillspaugh/newsletters list Val
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @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>Hello!</p>
<p>
This is the first clipping from my digital garden. I’m still thinking through what I want these to be, but my
rough idea is an email newsletter that I send every month or two (ish) with a selection of stuff I’ve written
since the last clipping. I planted a note riffing on{" "}
<a href="https://petemillspaugh.com/newsletters">what I want clippings to be</a>, and I’m also writing about
{" "}
<a href="https://petemillspaugh.com/cultivating-emails">
my custom email setup using Val Town
</a>, if you’re curious.
</p>
<p>
There are relatively few of you subscribed, so thanks for being an early reader! Please do{" "}
<a href="mailto:pete@petemillspaugh.com">reply</a> if you feel like it to lmk what you think.
</p>
<h2>Planting my digital garden</h2>
<p>
This clipping will be a bit longer than most because I’m rounding up January 2024 and also looking back on what
I planted in 2023.
</p>
<p>
In the fall I redesigned my personal website as a digital garden. I cover this on my{" "}
<a href="https://petemillspaugh.com/about">about</a>{" "}
page, so I’ll save words here. To learn about digital gardening <em>generally</em>, you can skip right to{" "}
<a href="https://maggieappleton.com/garden-history">Maggie Appleton’s wonderful essay</a>{" "}
on the ethos and history of digital gardens.
</p>
<p>
I wrote about all sorts of stuff last year. Most of it relates to the Web in some way, but there are some bits
about effective learning and career ambitions mixed in. It’s the first time I’ve consistently written in public,
which feels good. I still write plenty for myself—the ratio of private to public writing I do is probably like 4
to 1. Here are some of my personal favorites from 2023:
</p>
<ul>
<li>
<a href="https://petemillspaugh.com/edison-bulb">
<strong>Edison bulb night mode</strong>
</a>. My coworker <a href="https://www.dannyguo.com/">Danny</a> linked this in a{" "}
<a href="https://news.ycombinator.com/item?id=38135892">post</a>{" "}
on Hacker News, which generated some helpful feedback and sent me over the Vercel analytics free tier (feeding
my vanity, ofc)
</li>
<li>
<a href="https://petemillspaugh.com/silly-tlds">
<strong>Silly TLDs</strong>
</a>. This is a short, fun one. It’s the thing I’ve written that friends outside of tech seem most interested
in / leads to the most fun conversations
</li>
<li>
<a href="https://petemillspaugh.com/my-next-next-next-job">
<strong>My next, next, next job</strong>
</a>. I initially wrote this as a private thought exercise then published it after a nudge from some friends
who I’d shared it with
</li>
<li>
<a href="https://petemillspaugh.com/weeks-of-your-life">
<strong>Weeks of your life</strong>
</a>. I built{" "}
<a href="https://weeksofyour.life">weeksofyour.life</a>—an interactive visualization of your life in
weeks—during a couple free days over the holidays. I posted it on{" "}
<a href="https://news.ycombinator.com/item?id=38753911">Show HN</a>, which spurred some heady philosophical
discussions about the meaning of life and also handy tips around performance (and again—vanity food)
</li>
<li>
<a href="https://petemillspaugh.com/map-in-the-woods">
<strong>Downloading a 30MB map in the woods</strong>
</a>. This was my first stab at a format I came up with called "Brainstorms" where I scribble down a thought
stream of questions on a topic I’m curious about (sans Internet), then return later to research
</li>
<li>
<a href="https://petemillspaugh.com/nextjs-search-with-pagefind">
<strong>Add search to your Next.js static site with Pagefind</strong>
</a>. This project was type 2 fun, and I’m really glad I stuck with it. The Pagefind creator{" "}
<a href="https://pagefind.app/docs/resources/#using-pagefind-with-a-specific-ssg">added</a>{" "}
my show ’n tell to the Pagefind docs, which felt good
</li>
<li>
<a href="https://petemillspaugh.com/think-small">
<strong>Think small</strong>
</a>. Of all the things I’ve written, this is what pops into my head most day to day, probably because it’s so
widely applicable

Val Town email subscriptions: send test email

Cousin Val to @petermillspaugh/emailSubscription — see docs there.

When you're writing up an email to send to subscribers, it's helpful to send it to yourself ahead of time to proofread and see how it looks in different email clients etc.

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
/** @jsxImportSource https://esm.sh/preact */
import { generateNewsletterJsx } from "https://esm.town/v/petermillspaugh/generateNewsletterJsx";
import { getJanuary2024Newsletter } from "https://esm.town/v/petermillspaugh/january2024";
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 } = getJanuary2024Newsletter();
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",
});
}

Val Town email subscriptions: send email newsletter

Cousin Val to @petermillspaugh/emailSubscription — see docs there.

This Val has a few layers of protection to avoid double sending. Those mechanisms feel pretty hacky, so any suggestions are welcome! Feel free to comment on the Val or submit a PR.

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/** @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],
});
}
}
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
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("");
}
await sqlite.execute({
sql: `
UPDATE students
SET subscribed_at = NULL
WHERE email = ?
`,
args: [emailAddress],
});
sendEmail({
subject: `Someone unsubscribed from Make It Stick (in 10 days, via email)`,
text: `${emailAddress} unsubscribed 😢`,
});
const responseHtml = `
<main>
<p>You've successfully unsubscribed.</p>
<p>If you have any feedback, shoot me an email at <a href="mailto:pete@petemillspaugh.com">pete@petemillspaugh.com</a>.</p>
</main>
`;
return new Response(responseHtml, { headers: { "Content-Type": "text/html" } });
}