博客文章通过邮箱推送,对于有后端的动态博客来说,算是很平常的事情。像是 Newsletter、WordPress、Ghost,或者其他的什么动态博客搭建系统。要么就是本身内置,就算不是内置的,市场里也有通用的插件。但对于静态博客来说,就是绝对的稀罕物了。为此,有各种各样的邮箱实现的推送方式,比如本地生成好完整后发送邮件,又或是通过 GitHub 仓库工单系统或者社区系统订阅推送。

我的朋友阿普修使用的就是 GitHub Issues 进行邮件推送,原理就是推送到博客仓库后,通过 GitHub Actions 进行构建。构建完成后会向该 Issues 发送新消息,如果你订阅了这个 Issues,那么它就会从邮件推送过来给你。个人觉得虽然很简单,但是也充满了奇技淫巧,就像是 Giscus 那种心态。如果我的目标读者没有 GitHub 账号,那它就无法通过邮件系统订阅我的博客更新。

况且,他的推送方式就没考虑过重复性。这意味着,如果他持续更新网站、而不更新文章的话。你是他的订阅者,你会被反复推送一个文章。个人认为即时性推送更适合说说,而不是文章推送。因为从心理学上讲,一个人如果他在我的博客网站上点击了订阅,说明他本来就没时间来看我的博客,希望偶尔接收到推送而已。那既然是这样的话,多篇文章多次推送,确实显得行不通——它太暴力了。

在我看来,既然使用了 GitHub Actions,那意味着有完整的环境。这完全可以运行一个 Python 脚本、安装一些环境依赖、进行一些基本的算法。因此对我来说,首要目的解决的就是这两项——真邮箱推送、不重复推送。GitHub Actions 存在一个 Cache 系统,这意味着我可以存储一个文件,专门记录该文章链接是否被推送过。该推送逻辑使得文章在中午 12 点左右被推送(可能会排队),就是你吃完饭之后。

想清楚了流程,那接下来要考虑的就是邮箱列表的存储方式了。为此,我头痛了一星期:如果是放在仓库,那就是明文的,泄露隐私;如果是放在网站上,那黑客迟早会根据 Actions 日志或者代码实现方式,扫到访客的邮箱作为轰炸对象。好在 GitHub 提供了一种更加隐私的方式——GitHub Secrets,我完全可以把那些变量信息、敏感信息,全部存在这里,同时还能被 Actions 读到:

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
name: 文章更新邮件通知

on:
schedule:
# 北京时间 12:00(中午)
- cron: "0 4 * * *"
workflow_dispatch: # 允许手动触发

jobs:
send:
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: 检出代码
uses: actions/checkout@v4

- name: 设置 Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: 恢复 link.txt 缓存
uses: actions/cache@v4
with:
path: link.txt
key: rss-sent-link-${{ github.run_id }}
restore-keys: |
rss-sent-link-

- name: 安装依赖
run: pip install -r requirements.txt

- name: 运行发送脚本
env:
SMTP_SERVER: ${{ secrets.SMTP_SERVER }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
SMTP_FROM_NAME: ${{ secrets.SMTP_FROM_NAME }}
SMTP_FROM_ADDR: ${{ secrets.SMTP_FROM_ADDR }}
SMTP_SUBJECT: ${{ secrets.SMTP_SUBJECT }}
RSS_URL: ${{ secrets.RSS_URL }}
EMAIL_LIST: ${{ secrets.EMAIL_LIST }}
TAG_TITLE: ${{ secrets.TAG_TITLE }}
TAG_SUMMARY: ${{ secrets.TAG_SUMMARY }}
TAG_LINK: ${{ secrets.TAG_LINK }}
LINK_TEXT: ${{ secrets.LINK_TEXT }}
run: python send_update.py

代码方面,因为我需要用网络库获取 atom.xml 这个文件。这是一个典型 ATOM 格式的 RSS 文件,存在几个重要标签:<title>(文章标题)、<summary>(文章简介)、<link>(文章链接)。因此,我只需要解析 XML,提取这几个部分的值,放入 HTML 中排序即可。获取了值后和 link.txt 进行比对:如果已经存在,则跳过这次运行;如果不存在,则发送邮件。例如:

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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#!/usr/bin/env python3
"""
RSS 文章更新邮件通知脚本。

功能:
1. 从 RSS 地址抓取最新文章
2. 检查是否已发送过(与 link.txt 比对)
3. 如未发送,生成 HTML 邮件并通过 SMTP 密送订阅者
4. 记录本次发送的文章链接

所有敏感配置均从环境变量读取,适用于 GitHub Actions Secrets。
"""

import os
import sys
import re
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr

import feedparser

# ── 配置:全部来自环境变量 ──────────────────────────────────
def _env(key, fallback=None):
"""读取环境变量,必须存在则 fallback 为 None。"""
val = os.environ.get(key, fallback)
if val is None:
print(f"错误: 缺少环境变量 {key}")
sys.exit(1)
return val


SMTP_SERVER = _env("SMTP_SERVER") # smtp.resend.com
SMTP_PORT = int(_env("SMTP_PORT", "465")) # 465
SMTP_USER = _env("SMTP_USER") # resend
SMTP_PASS = _env("SMTP_PASS") # API Key / 密码
SMTP_FROM_NAME = _env("SMTP_FROM_NAME") # 他说
SMTP_FROM_ADDR = _env("SMTP_FROM_ADDR") # [email protected]
SMTP_SUBJECT = _env("SMTP_SUBJECT") # 他说,你收到了新的订阅

RSS_URL = _env("RSS_URL") # https://090909.top/atom.xml
EMAIL_LIST = _env("EMAIL_LIST") # 空格分隔的密送邮箱列表

TAG_TITLE = _env("TAG_TITLE", "h1") # 标题标签名
TAG_SUMMARY = _env("TAG_SUMMARY", "p") # 摘要标签名
TAG_LINK = _env("TAG_LINK", "a") # 链接标签名
LINK_TEXT = _env("LINK_TEXT", "阅读详情") # 链接显示文字

LINK_FILE = "link.txt" # 缓存已发送链接的文件


# ── 工具函数 ────────────────────────────────────────────────

def load_last_link():
"""读取上次发送的文章链接,如果文件不存在或为空则返回 None。"""
if not os.path.exists(LINK_FILE):
return None
with open(LINK_FILE, "r", encoding="utf-8") as f:
content = f.read().strip()
return content if content else None


def save_last_link(link):
"""将本次发送的文章链接写入缓存文件。"""
with open(LINK_FILE, "w", encoding="utf-8") as f:
f.write(link.strip() + "\n")
print(f"已记录发送链接: {link}")


def strip_html_tags(text):
"""移除 HTML 标签,保留纯文本。用于生成纯文本版邮件。"""
clean = re.sub(r"<[^>]+>", "", text)
return clean.strip()


def build_html_content(title, summary, link):
"""
根据模板生成 HTML 邮件正文。
格式与 demo.html 一致:
<TAG_TITLE>《标题》</TAG_TITLE>
<TAG_SUMMARY>摘要</TAG_SUMMARY>
<TAG_LINK href="链接">LINK_TEXT</TAG_LINK>
"""
parts = [
f'<{TAG_TITLE}>《{title}》</{TAG_TITLE}>',
f'<{TAG_SUMMARY}>{summary}</{TAG_SUMMARY}>',
f'<{TAG_LINK} href="{link}">{LINK_TEXT}</{TAG_LINK}>',
]
return "\n".join(parts)


def build_plain_content(title, summary, link):
"""纯文本版邮件。"""
clean_summary = strip_html_tags(summary)
lines = [
f"《{title}》",
"",
clean_summary,
"",
f"{LINK_TEXT}: {link}",
]
return "\n".join(lines)


def send_email(html_body, plain_body, bcc_list):
"""
通过 SMTP_SSL 发送邮件。
收件人设为发件人自己,实际订阅者放在密送中。
"""
msg = MIMEMultipart("alternative")
msg["From"] = formataddr((SMTP_FROM_NAME, SMTP_FROM_ADDR))
msg["To"] = formataddr((SMTP_FROM_NAME, SMTP_FROM_ADDR))
msg["Subject"] = SMTP_SUBJECT

msg.attach(MIMEText(plain_body, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))

# 收件人列表:发件人 + 所有密送地址
recipients = [SMTP_FROM_ADDR] + bcc_list

context = ssl.create_default_context()
try:
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, context=context) as server:
server.login(SMTP_USER, SMTP_PASS)
server.sendmail(SMTP_FROM_ADDR, recipients, msg.as_string())
except smtplib.SMTPException as e:
print(f"SMTP 发送失败: {e}")
sys.exit(1)

print(f"邮件已发送 → 密送 {len(bcc_list)} 位订阅者")


# ── 主流程 ──────────────────────────────────────────────────

def main():
# 1. 解析密送列表
bcc_list = [addr.strip() for addr in EMAIL_LIST.split() if addr.strip()]
if not bcc_list:
print("错误: EMAIL_LIST 为空")
sys.exit(1)
print(f"订阅者数量: {len(bcc_list)}")

# 2. 抓取并解析 RSS
print(f"正在抓取 RSS: {RSS_URL}")
feed = feedparser.parse(RSS_URL)

if feed.bozo and not feed.entries:
print(f"RSS 解析失败: {feed.bozo_exception}")
sys.exit(1)

if not feed.entries:
print("RSS 中没有文章,退出")
sys.exit(0)

# 3. 获取最新文章
latest = feed.entries[0]
title = latest.get("title", "").strip()
summary = latest.get("summary", "").strip()
link = latest.get("link", "").strip()

if not title or not link:
print("错误: 最新文章缺少标题或链接")
sys.exit(1)

print(f"最新文章: 《{title}》")
print(f"链接: {link}")

# 4. 检查是否已发送
last_link = load_last_link()
if last_link == link:
print("该文章已发送过,跳过。")
sys.exit(0)

# 5. 构建邮件内容
html_body = build_html_content(title, summary, link)
plain_body = build_plain_content(title, summary, link)

# 6. 发送邮件
send_email(html_body, plain_body, bcc_list)

# 7. 记录已发送
save_last_link(link)

print("完成。")


if __name__ == "__main__":
main()

得益于 Resend 提供的域名邮箱的免费服务,我可以使用自己的域名邮箱——[email protected] 发送这些文章,同时不需要自己搭建任何的邮件服务。如果你没有 Resend 域名邮箱,你可以去注册一个,或者 QQ 邮箱、163 邮箱,因为脚本使用的是 SMTP 协议。并且上次讲过的这些东西都不需要你自己搭建,你只需要 Fork 我的仓库,并创建几个 Secrets:

  • SMTP_SERVER:指定一个 SMTP 服务商,例如 smtp.resend.com
  • SMTP_PORT:指定服务商的端口,例如 465
  • SMTP_USER:指定 SMTP 用户名,例如 resend
  • SMTP_PASS:指定 SMTP 密码,例如 re_xxxxx
  • SMTP_FROM_NAME:指定发件人名称,这里一般是写网站,比如我的 他说
  • SMTP_FROM_ADDR:发件邮箱地址,例如 [email protected]
  • SMTP_SUBJECT:指定邮件主题,例如 他说,你收到了新的订阅
  • RSS_URL:网站的 RSS 订阅地址,例如 https://example.com/atom.xml
  • EMAIL_LIST:指定访客密送列表,用空格间隔,例如 [email protected] [email protected]
  • TAG_TITLE:文章标题标签,例如 h1h2
  • TAG_SUMMARY:文章摘要标签,例如:p
  • TAG_LINK:详情链接标签,例如:a
  • LINK_TEXT:链接显示文字,例如 阅读详情阅读更多

这些 Secrets 全部都是要手动创建的,而且要注意的一点是,Serects 是每次打开都会空白的。因此你需要把这个变量保存在本地某个文件,每次添加新成员的时候,在那个文件里面加,还好后复制全部到后台那里。Serects 的字数是有上限的,但是我猜测大部分个人博客到不了上限。如果你想使用邮箱订阅我的后续文章,现在就可以点击网站上的订阅按钮,向我发送订阅申请。