纵观提供托管 newsletter 的平台,比如 MailerLite,它们都提供看起来非常慷慨的免费额度。一个月几万封邮件,看起来确实很有吸引力。但注册之后才会发现,最核心的 RSS to Email 服务只是订阅计划可用。
Resend 为开发者提供了批量发送邮件的能力,它支持多种语言的 API, 而且可以自动管理订阅和退订逻辑。它的免费额度也不小,是一个理想选择。
Resend 功能虽然强大,但它只有发件和管理订阅者的功能,并不是一种我们想要的全功能托管平台。想以 Resend 为核心构建系统,还缺少以下组件:
- 一个表单,用于收集订阅用户的邮箱
- 转换 RSS 到电子邮件的实现
- 调用 Resend API 发送邮件的服务
- 负责添加订阅用户的服务
因为我的博客本身是完全静态的,我不想在博客内部引入任何无服务器函数。为了系统的可扩展性和可维护性考虑,每个服务之间和每个服务与博客之间应该没有任何关联。最终,我的方案如下:
- Tally 作为 Web 表单,通过 Webhook 发送信息
- 写一个 Python 脚本处理 RSS 转换和邮件发送
- Cloudflare Workers 充当 Webhook endpoint,调用 API 向 Resend 添加订阅者
- 事件驱动的 GitHub Action 运行 Python 脚本
实现细节
resend
这里利用了 Resend 的 Broadcasts 功能。
在注册 Resend 并添加域名后,记得添加一个 API Key,这个需要保留好,后面会用到。
同时管理订阅者也是利用了 Resend Audience 功能里的 Segment 功能实现的,下文会提到。
通过 Broadcasts API 的 Create Broadcasts 进行邮件的创建和批量发送。它的 Python API 大概长这样:
import resend
resend.api_key = "re_xxxxxxxxx"
// Create a draft broadcast
params: resend.Broadcasts.CreateParams = {
"segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
"from": "Acme <onboarding@resend.dev>",
"subject": "Hello, world!",
"html": "Hi {{{FIRST_NAME|there}}}, you can unsubscribe here: {{{RESEND_UNSUBSCRIBE_URL}}}",
}
resend.Broadcasts.create(params)
// Create and send immediately
params: resend.Broadcasts.CreateParams = {
"segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
"from": "Acme <onboarding@resend.dev>",
"subject": "Hello, world!",
"html": "Hi {{{FIRST_NAME|there}}}, you can unsubscribe here: {{{RESEND_UNSUBSCRIBE_URL}}}",
"send": true,
}
resend.Broadcasts.create(params)
// Create and schedule
params: resend.Broadcasts.CreateParams = {
"segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
"from": "Acme <onboarding@resend.dev>",
"subject": "Hello, world!",
"html": "Hi {{{FIRST_NAME|there}}}, you can unsubscribe here: {{{RESEND_UNSUBSCRIBE_URL}}}",
"send": true,
"scheduled_at": "in 1 hour",
}
resend.Broadcasts.create(params)这样就可以方便地使用 Python 去调用这些 API 发送邮件了。
Python 脚本
Python 脚本需要做的事情大概有三件:
- 提取 RSS 的第一个 Item
- 编写电子邮件内容
- 通过 Resend Broadcasts API 发送
这些代码是我学习了半个小时 Python 之后手搓的,看起来可能比较简陋,但还是可以用的。
import html
import requests
import xml.etree.ElementTree as ET
import resend
import os
def getRss(url):
headers = {
"User-Agent": "Nalanyinyun RSS and email service/1.0, +https://nalanyinyun.work"
}
response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()
return response.text
def generateEmailContent(rss):
root = ET.fromstring(rss)
items = root.findall("./channel/item")
if not items:
return "No posts found in RSS feed."
first = items[0]
title = first.findtext("title", default="Untitled")
description = first.findtext("description", default="No description.")
pubDate = first.findtext("pubDate", default="Unknown date")
formatted_str = (
f"<pre style='white-space: pre-wrap; font-family: sans-serif; font-size: 14px;'>"
f"Nalanyinyun's Library 已更新,以下是摘要:\n\n"
f"Title: {title}\n"
f"Date: {pubDate}\n"
f"{'-'*20}\n"
f"Description: {description}\n\n"
f"退订见:<a href=\"{{{{{{ resend_unsubscribe_url }}}}}}\">点击此处退订</a>"
f"</pre>"
)
return formatted_str
def publishLatest(apiKey, segmentID, fromID, subject, content):
resend.api_key = apiKey
resend.Broadcasts.create({
"segment_id": segmentID,
"from": fromID,
"subject": subject,
"html": content,
"headers": {
"List-Unsubscribe": "<{{{{ resend_unsubscribe_url }}}}>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
},
"send": True
})
url = "https://nalanyinyun.work/rss.xml"
content = generateEmailContent(getRss(url))
publishLatest(
apiKey = os.getenv("RESEND_API_KEY"),
segmentID="76654bf7-fd97-45e9-81a6-e38cda6391fc",
fromID="Nalanyinyun <nalanyinyun@nalanyinyun.work>",
subject="Nalanyinyun's Library Content Delivered",
content=content
)在使用时,替换 url 和 apiKey、segmentID 之类的变量就可以了。小心别把机密信息硬编码进去了。
值得注意的是,Resend 已经替我们处理好了所有的退订逻辑,但请在的正文和 Headers 里标识出来,不然很可能第一次发邮件就被拒信了。
Cloudflare Workers
需要Resend Audience。这个只需要创建好 Segment 并记住 ID 就可以了。
Cloudflare Workers 主要负责解析 Webhook 传入的数据,因为我实在是不懂 JavaScript,所以找 Gemini 生成一个勉强能用的后端。
export default {
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
const body = await request.json();
const emailField = body.data.fields.find(f => f.type === "INPUT_EMAIL");
const userEmail = emailField ? emailField.value : null;
if (!userEmail) {
return new Response("No email found in webhook data", { status: 400 });
}
const segmentId = env.RESEND_SEGMENT_ID;
const url = `https://api.resend.com/segments/${segmentId}/contacts`;
const resendResponse = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
},
body: JSON.stringify({
email: userEmail,
}),
});
const result = await resendResponse.json();
if (resendResponse.ok) {
console.log(`Success: Added ${userEmail} to segment`);
return new Response("Subscribed to segment successfully!", { status: 200 });
} else {
console.error("Resend API Error:", result);
return new Response(JSON.stringify(result), { status: resendResponse.status });
}
} catch (err) {
return new Response("Internal Server Error: " + err.message, { status: 500 });
}
},
};使用时需要添加机密 RESEND_API_KEY 和RESEND_SEGMENT_ID。
如果你的 Tally Webhook 传入和我不一样,可能需要稍微改一改解析的逻辑,调用 API 那部分应该是没有问题的。
Github Action
这部分就很简单了
name: RSS to Email on File Change
on:
push:
branches:
- main
paths:
- src/content/posts/**
workflow_dispatch:
jobs:
email_notification:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Dependencies
run: pip install requests resend
- name: Run Script
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: python blogutils/emailutils.py根据需要,你可能要 sleep 一会儿。当然不是因为你困了,是因为我们要等站点构建完成之后才能获取到新的 RSS 内容。
Tally
Tally 的 Web 表单是开箱即用的,我的表单只有这么一个输入框。在编辑之后点击 Integrities,添加一个 Webhook endpoint 就可以了。
Webhook endpoint 的实现下文会提到。
Tally 的自定义域名需要付费计划,不过我觉得这个是不是自己的域名应该无关紧要。记得在设置里开启禁止重复填写。
Source
文章也发表在独立博客 Nalanyinyun's Library 上。
文中涉及到的源代码以及博客源代码在 Github 中可用:
感谢支持。
