Skip to content

Commit 7f5c090

Browse files
committed
✨ feat: Final revise of the newsletter
1 parent 34f18f0 commit 7f5c090

5 files changed

Lines changed: 215 additions & 26 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
- 🏠 Hosting: [GitHub Pages](https://pages.github.com/)
1111
- 💬 Comments: Self-hosted [Remark42](https://remark42.com/)
1212
- 📈 Analytics: Self-hosted [Plausible Analytics](https://plausible.io/)
13-
- 📨 Newsletter: [Mailchimp](https://mailchimp.com/)
13+
- 📨 Newsletter: [Resend](https://resend.com/)

emails/newsletter.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from "@react-email/components"
1515
import React from "react"
1616

17-
interface NewsletterProps {
17+
export interface NewsletterProps {
1818
title: string
1919
subtitle: string
2020
summary: string
@@ -46,8 +46,8 @@ export default function Newsletter(props: Readonly<NewsletterProps>) {
4646
<Html>
4747
<Head />
4848
<Preview>
49-
{props.title}
5049
{props.subtitle}
50+
{"\n"}
5151
{props.summary}
5252
</Preview>
5353
<Tailwind>
@@ -76,6 +76,13 @@ export default function Newsletter(props: Readonly<NewsletterProps>) {
7676
>
7777
{props.title}
7878
</Heading>
79+
<Heading
80+
as="h2"
81+
className="text-[18px] font-semibold leading-[28px]"
82+
style={grayText}
83+
>
84+
{props.subtitle}
85+
</Heading>
7986
<Text className="text-[16px] leading-[24px]">
8087
{props.summary}
8188
</Text>
@@ -123,6 +130,7 @@ const body = { background: baseBackground }
123130
const container = { background: containerBackground, color: foreground }
124131
const purpleText = { color: purple }
125132
const pinkText = { color: pink }
133+
const grayText = { color: gray }
126134
const redText = { color: red }
127135
const purpleButton = { background: purple, color: foreground }
128136
const footer = { color: gray }

emails/sendEmail.tsx

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,139 @@
11
import { Temporal } from "@js-temporal/polyfill"
22
import { Resend } from "resend"
3-
import Newsletter from "./newsletter"
3+
import Newsletter, { NewsletterProps } from "./newsletter"
44
import * as React from "react"
5+
import * as fs from "fs"
6+
import * as path from "path"
7+
import matter from "gray-matter"
58

6-
const resend = new Resend(process.env.RESEND_API_KEY)
7-
const sender = "Tomy Hsieh <newsletter@tomy.me>"
9+
const SENDER = "Tomy Hsieh <newsletter@tomy.me>"
10+
const JSON_INDEX = "public/zh-tw/index.json"
11+
const POST_SECTION = "寫作"
12+
const POST_DIR = "content/posts"
13+
const POST_MARKDOWN = "index.zh-tw.md"
814

9-
const title = "2025 年 5 月報報"
10-
const subtitle = "The moment that defines the ones who do."
11-
const summary =
12-
"發現寫部落格有一個有趣現象:這個月沒什麼大事,會因為沒有題材,所以不好寫。但反過來,太多行程,太多東西可以寫,也會很頭疼。另一方面,太多事要做的時候,要抽出時間和心情來寫,其實也不是一件容易的事。"
13-
const postUrl = "https://blog.tomy.me/zh-tw/posts/2025-may-dump/"
14-
const imageUrl =
15-
"https://blog.tomy.me/zh-tw/posts/2025-may-dump/cmu-commencement-with-taiwanese_hu_8681a2eabc9f99f8.webp"
16-
const imageAlt =
17-
"一排畢業生在現代建築外牆前排隊合照,穿著學士袍與紅黃飾帶,氣氛歡樂。"
15+
async function main() {
16+
// Get the latest blog post
17+
const rootDir = path.dirname(__dirname)
18+
const post = getLatestPost(rootDir)
19+
console.info("Sending post", post)
1820

19-
async function sendEmail() {
21+
// Create Resend client
22+
const RESEND_API_KEY = process.env.RESEND_API_KEY
23+
if (!RESEND_API_KEY) {
24+
throw new Error("RESEND_API_KEY not defined")
25+
}
26+
const resend = new Resend(RESEND_API_KEY)
27+
28+
// Send a preview email
29+
await sendEmail(post, resend, SENDER, "Tomy Hsieh <tomy0000000@gmail.com>")
30+
31+
// Schedule official email
32+
const RESEND_AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID
33+
if (!RESEND_AUDIENCE_ID) {
34+
throw new Error("RESEND_AUDIENCE_ID not defined")
35+
}
36+
await sendEmailToAudience(post, resend, SENDER, RESEND_AUDIENCE_ID as string)
37+
}
38+
39+
function getLatestPost(rootPath: string): NewsletterProps {
40+
// Read index from json
41+
const indexPath = path.join(rootPath, JSON_INDEX)
42+
const indexJson = JSON.parse(fs.readFileSync(indexPath, "utf-8"))
43+
44+
// Find the latest post slug
45+
const latestPost = indexJson.find(
46+
(item: { section: string }) => item.section === POST_SECTION
47+
)
48+
if (!latestPost) {
49+
throw new Error(`No post found in section ${POST_SECTION}`)
50+
}
51+
const BLOG_HOST = process.env.BLOG_HOST
52+
if (!BLOG_HOST) {
53+
throw new Error("BLOG_HOST not defined")
54+
}
55+
const permalink = new URL(latestPost.permalink, BLOG_HOST)
56+
const slug = permalink.pathname.split("/").filter(Boolean).pop()
57+
if (!slug) {
58+
throw new Error("Could not extract slug from permalink")
59+
}
60+
61+
// Parse post markdown
62+
const markdownPath = path.join(rootPath, POST_DIR, slug, POST_MARKDOWN)
63+
const fileContent = fs.readFileSync(markdownPath, "utf-8")
64+
const { data, content } = matter(fileContent)
65+
const summary = content.slice(
66+
content.indexOf("\n") + 1,
67+
content.indexOf("\n", content.indexOf("\n") + 1)
68+
)
69+
70+
return {
71+
title: data.title,
72+
subtitle: data.description,
73+
summary,
74+
postUrl: permalink.toString(),
75+
imageUrl: buildImageURL(slug, data.feature).toString(),
76+
imageAlt: data.featureAlt,
77+
}
78+
}
79+
80+
function buildImageURL(postSlug: string, imageSlug: string): URL {
81+
const baseUrl = process.env.IMG_HOST
82+
if (!baseUrl) {
83+
throw new Error("IMG_HOST not defined")
84+
}
85+
return new URL(`${postSlug}/${imageSlug}`, baseUrl)
86+
}
87+
88+
async function sendEmail(
89+
post: NewsletterProps,
90+
resend: Resend,
91+
sender: string,
92+
testingEmail: string
93+
) {
94+
const createResponse = await resend.emails.send({
95+
from: sender,
96+
to: testingEmail,
97+
subject: `Tomy's Blog - ${post.title}`,
98+
react: (
99+
<Newsletter
100+
title={post.title}
101+
subtitle={post.subtitle}
102+
summary={post.summary}
103+
postUrl={post.postUrl}
104+
imageUrl={post.imageUrl}
105+
imageAlt={post.imageAlt}
106+
/>
107+
),
108+
})
109+
if (createResponse.error !== null || createResponse.data === null) {
110+
console.error(`Failed to send email: ${createResponse.error?.message}`)
111+
return
112+
}
113+
const sendId = createResponse.data.id
114+
console.log(`Preview Email sent: ${sendId}`)
115+
}
116+
117+
async function sendEmailToAudience(
118+
post: NewsletterProps,
119+
resend: Resend,
120+
sender: string,
121+
audienceId: string
122+
) {
20123
// Create broadcast
21124
const createResponse = await resend.broadcasts.create({
22-
name: title,
23-
audienceId: process.env.RESEND_AUDIENCE_ID ?? "",
125+
name: post.title,
126+
audienceId,
24127
from: sender,
25-
subject: `Tomy's Blog - ${title}`,
128+
subject: `Tomy's Blog - ${post.title}`,
26129
react: (
27130
<Newsletter
28-
title={title}
29-
subtitle={subtitle}
30-
summary={summary}
31-
postUrl={postUrl}
32-
imageUrl={imageUrl}
33-
imageAlt={imageAlt}
131+
title={post.title}
132+
subtitle={post.subtitle}
133+
summary={post.summary}
134+
postUrl={post.postUrl}
135+
imageUrl={post.imageUrl}
136+
imageAlt={post.imageAlt}
34137
/>
35138
),
36139
})
@@ -61,4 +164,4 @@ async function sendEmail() {
61164
console.log(`Send is scheduled at ${localSendTime.toString()}: ${sendId}`)
62165
}
63166

64-
sendEmail()
167+
main()

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"dependencies": {
3333
"@js-temporal/polyfill": "^0.5.1",
3434
"@react-email/components": "^0.0.42",
35+
"gray-matter": "^4.0.3",
3536
"react": "^19.1.0",
3637
"react-dom": "^19.1.0",
3738
"react-email": "^4.0.16",

0 commit comments

Comments
 (0)