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.

Readme
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.

Readme
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.

Readme
Fork
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
Readme
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
Readme
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
/** @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:

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.

Readme
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.

Readme
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
/** @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
Fork
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" } });
}