个人博客邮件订阅系统实战:MailerLite API 集成与避坑指南
前言
本博客(栏轩阁)的邮件订阅系统基于 MailerLite 构建,主要实现两个功能:
- 欢迎邮件 —— 用户订阅后自动发送,由 MailerLite Automation 触发
- 每周精选推送 —— 定时抓取最新文章,通过 Campaign 推送给所有订阅者
选择 MailerLite 的原因很简单:它在免费计划内功能完整,API 设计清晰,且支持 Automation、分组、Campaign 等核心能力。
但这篇文章不是官方文档的翻译,而是基于实际项目踩坑后的经验总结。你不仅能看到每个 API 怎么调,还能看到 v2/v3 混用的坑、为什么选择 API 方案而非原生 RSS Campaign、以及一套完整的"双写 + 审计 + 一致性校验"的工程实践。
如果你也在给个人博客或小项目集成邮件订阅,这篇文章应该能帮你省不少时间。
前置阅读
- MailerLite 完全指南(入门配置) —— 后台配置的基础操作
- Dashboard
- 官方 API 文档
一、认证方式
MailerLite API 使用 JWT 格式的 API Key 进行认证,在 Dashboard 的 Integrations → Developer API 页面生成:

📝 NOTE: MailerLite 的 API Key 是标准的 JWT Token(以
eyJ开头),不是简单的随机字符串。JWT 中编码了用户 ID、权限范围和过期时间。
请求头
API v3 使用 Authorization: Bearer 标准方式:
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
Content-Type: application/json
Accept: application/json
API v2 使用 X-MailerLite-ApiKey 自定义头(Campaign 发送接口仍用 v2):
X-MailerLite-ApiKey: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
Content-Type: application/json
两个版本的混合使用
实际项目中遇到的情况:MailerLite 目前处于 v2→v3 过渡期,部分新功能只有 v3 有,但个别 v2 接口还没有对应的 v3 替代。
| 用途 | API 版本 | 端点 |
|---|---|---|
| 订阅者管理 | v3 | connect.mailerlite.com/api/subscribers |
| 分组查询 | v3 | connect.mailerlite.com/api/groups |
| Campaign 创建 | v3 | connect.mailerlite.com/api/campaigns |
| Campaign 发送 | v2 | api.mailerlite.com/api/v2/campaigns/{id}/actions/send |
| Campaign 删除 | v3 | connect.mailerlite.com/api/campaigns/{id} |
⚠️ WARNING: 同一个 API Key 在两个版本中都能用,但请求头格式不同:v3 用
Authorization: Bearer,v2 用X-MailerLite-ApiKey。如果发错了头会返回 401。
二、订阅者管理(Subscribers)
2.1 添加订阅者
POST https://connect.mailerlite.com/api/subscribers
Authorization: Bearer {API_KEY}
Content-Type: application/json
{
"email": "user@example.com",
"groups": ["191576374630155327"],
"status": "active"
}
代码实现:
const mlResp = await fetch("https://connect.mailerlite.com/api/subscribers", {
method: "POST",
headers: {
Authorization: `Bearer ${env.MAILERLITE_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
email,
groups: [MAILERLITE_GROUP_ID],
status: "active",
}),
});
请求字段说明:
| 字段 | 必填 | 说明 |
|---|---|---|
email |
✅ | 订阅者邮箱,会自动转小写 |
groups |
❌ | 要加入的分组 ID 数组。不传则加入"无分组" |
status |
❌ | active / unsubscribed / unconfirmed / junk / bounced |
subscribed_at |
❌ | 自定义订阅时间(ISO 8601) |
fields |
❌ | 自定义字段(需先在后台定义) |
响应示例:
{
"data": {
"id": "123456789012345678",
"email": "user@example.com",
"status": "active",
"subscribed_at": "2026-06-15T08:30:00.000000Z",
"fields": {},
"groups": ["191576374630155327"]
}
}
💡 TIP: 如果邮箱已存在,MailerLite 会返回 HTTP 409 Conflict,不会覆盖已有数据。设计上可以忽略 409,把 D1 作为主存储,MailerLite 作为邮件发送层,两侧各自持久化。
2.2 查询订阅者
按邮箱精确查找:
GET https://connect.mailerlite.com/api/subscribers/{email}
Authorization: Bearer {API_KEY}
响应(存在时):
{
"data": {
"id": "123456789012345678",
"email": "user@example.com",
"status": "active"
}
}
不存在时返回 HTTP 404。
2.3 列出所有订阅者(游标分页)
GET https://connect.mailerlite.com/api/subscribers?limit=100
Authorization: Bearer {API_KEY}
MailerLite v3 使用 cursor-based pagination,不是传统的 page/pageSize:
{
"data": [
{ "id": "...", "email": "user1@example.com", "status": "active", "subscribed_at": "..." },
{ "id": "...", "email": "user2@example.com", "status": "active", "subscribed_at": "..." }
],
"meta": {
"next_cursor": "eyJpZCI6IjE0NDc5ODQ4NzUiLCJwb3MiOjEwMH0="
}
}
游标分页的遍历实现:
async function fetchAllMLSubscribers(apiKey: string): Promise<Map<string, SubscriberInfo>> {
const subscribers = new Map();
let cursor: string | null = null;
do {
const url = new URL("https://connect.mailerlite.com/api/subscribers");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const resp = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (!resp.ok) throw new Error(`MailerLite list failed: ${resp.status}`);
const result = await resp.json();
for (const sub of result.data) {
subscribers.set(sub.email, {
id: sub.id,
status: sub.status,
subscribed_at: sub.subscribed_at,
});
}
cursor = result.meta?.next_cursor ?? null;
} while (cursor);
return subscribers;
}
📝 NOTE: MailerLite v3 统一使用游标分页。游标是一个无意义的不透明字符串,用
meta.next_cursor获取,null表示已到最后一页。每次最多limit=100,没有 page/total 的概念。游标分页的好处:在数据频繁变动时不会出现"翻页重复/遗漏"的问题;缺点是无法直接跳到某一页。
2.4 删除订阅者
分两步:先按邮箱查找 ID,再按 ID 删除
# Step 1: 查找
GET https://connect.mailerlite.com/api/subscribers/{email}
# Step 2: 删除(用上一步返回的 id)
DELETE https://connect.mailerlite.com/api/subscribers/{subscriber_id}
// Step 1: 按邮箱查找
const lookupResp = await fetch(
`https://connect.mailerlite.com/api/subscribers/${encodeURIComponent(email)}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
// 404 → 不存在,视为已删除
if (lookupResp.status === 404) return { success: true };
const subData = await lookupResp.json();
const subscriberId = subData.data.id;
// Step 2: 按 ID 删除
const deleteResp = await fetch(
`https://connect.mailerlite.com/api/subscribers/${subscriberId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${apiKey}` },
}
);
// 成功返回 204 No Content
const success = deleteResp.status === 204;
💡 TIP: 实际项目中,删除订阅者是 D1 和 MailerLite 双删:D1 的
DELETE即使行不存在也不会报错,MailerLite 的 404 也当作成功处理。这样可以保证幂等性——无论重复调用多少次,最终状态一致。
三、分组管理(Groups)
MailerLite 的 Group 用于对订阅者进行分段,是实现"不同订阅类型"的基础。
3.1 查询分组信息
GET https://connect.mailerlite.com/api/groups/{group_id}
Authorization: Bearer {API_KEY}
响应:
{
"data": {
"id": "191576374630155327",
"name": "article",
"active_count": 42,
"subscribed_count": 45,
"unsubscribed_count": 3
}
}
active_count 是实际可送达的活跃订阅者数。
3.2 分组在订阅流程中的角色
订阅者在创建时通过 groups 字段加入分组:
const MAILERLITE_GROUP_ID = "191576374630155327";
await fetch("https://connect.mailerlite.com/api/subscribers", {
method: "POST",
body: JSON.stringify({
email,
groups: [MAILERLITE_GROUP_ID],
status: "active",
}),
});
数据库中对应的 subscribers.group_name = 'article' 与 MailerLite Group ID 191576374630155327 形成映射。这种设计让博客可以未来扩展多组订阅,比如:
| group_name | MailerLite Group ID | 说明 |
|---|---|---|
article |
191576374630155327 |
每周文章推送(当前) |
weekly |
(待创建) | 周刊 |
newsletter |
(待创建) | 月度通讯 |
四、对比:为什么用 API 方案,而不是 MailerLite 原生功能?
在深入 API 调用细节之前,先宏观对比一下自己用 API 实现订阅推送和直接用 MailerLite 原生 RSS Campaign 的差异。
费用
| 对比项 | MailerLite 原生 RSS Campaign | API 方案 |
|---|---|---|
| RSS 自动推送 | ❌ 付费计划才有(Comfort $12/mo+) | ✅ 免费(仅耗 Worker 执行时间) |
| Custom HTML 编辑器 | ❌ Advanced 计划($25/mo+) | ✅ 任意计划,自己写 HTML |
| 免费计划影响 | ⚠️ 2026.06 新规:250 订阅者 / 2500 封月,超量停发 | ✅ 不受 MailerLite 计划限制影响 |
文章筛选与去重
| 能力 | MailerLite 原生 RSS Campaign | API 方案 |
|---|---|---|
| 按文章 ID 去重 | ❌ 只能按 guid 去重,无法跨期跳过已推送文章 | ✅ push_logs.article_ids 精确控制每篇只推一次 |
| 限制每期数量 | ❌ feed 有几篇新文章就推几篇 | ✅ RSS_MAX_ARTICLES=3 可控 |
| 按分类/标签筛选 | ❌ 不支持 | ✅ 已解析 category/tags,可扩展筛选逻辑 |
| 推送时机 | ⏳ feed 刷新延迟约 1 小时 | ✅ 主动 fetch,发布即推 |
数据主权
| 能力 | MailerLite 原生 | API 方案 |
|---|---|---|
| 订阅者数据 | 只存在 MailerLite 后台 | ✅ D1 + MailerLite 双写,本地有完整副本 |
| 推送历史审计 | ❌ 无本地审计日志 | ✅ push_logs 表记录每次推送的文章、数量、状态 |
| 邮件归档 | ❌ 仅保存在 MailerLite 后台 | ✅ emails 表完整归档(含转发 QQ 邮箱) |
可移植性
| 对比项 | MailerLite 原生 | API 方案 |
|---|---|---|
| 更换 ESP | ❌ 所有逻辑锁死在 MailerLite | ✅ 只替换 API 调用层(Resend / SendGrid / Brevo 等) |
| 自建邮件 | ❌ 无法脱离平台 | ✅ 数据都在 D1,模板都是文件,随时可切 |
| 供应商锁定 | 🔒 高度锁定 | ✅ 低耦合,抽象层在代码中 |
总结
MailerLite 原生 RSS Campaign 是 "开箱即用" 的方案
→ 但你得为开箱付费,且开箱后不能定制
API 方案是 "自己搭积木" 的方案
→ 初始工作量大一点,但费用、灵活度、数据主权全部自己掌控
对个人博客而言,后者显然是更划算、更长远的选择。
唯一的缺点: API 方案需要自己维护后端服务(Worker、数据库、定时任务)。MailerLite 原生 RSS Campaign 开箱即用零运维,而 API 方案省了订阅费,但多了开发和维护成本。如果没有自己的后端基础设施,原生方案反而是更省事的选择。
五、Automation — 欢迎邮件自动发送
以下内容解释:为什么代码中只调用了
POST /api/subscribers,没有写任何发送欢迎邮件的逻辑,用户却收到了欢迎邮件。
5.1 问题背景
在设计订阅流程时,一个自然的想法是:
代码 POST /api/subscribers → 创建订阅者 → 代码再调 API 发送欢迎邮件
但 MailerLite 的 Campaign API 不能直接发送给单个订阅者。创建 Campaign 时,需要通过顶层 groups 参数指定收件人分组:
| 参数 | 说明 |
|---|---|
groups |
收件人分组 ID 数组,不传则发送给所有活跃订阅者 |
segments |
动态分段 ID 数组(与 groups 互斥,同时传时仅 segments 生效) |
没有 subscribers、email 或 recipients 这种字段。
5.2 如果非要用代码给单人发邮件?
理论上可以走这条链路:
POST /api/v2/groups → ① 创建临时单人组
POST /api/v2/groups/{id}/subscribers → ② 把订阅者加进去
POST /api/v2/campaigns → ③ 创建 Campaign,指定 groups = [临时组]
POST /api/v2/campaigns/{id}/actions/send → ④ 发送
DELETE /api/v2/campaigns/{id} → ⑤ 删除 Campaign
DELETE /api/v2/groups/{id} → ⑥ 删除临时组(订阅者本身不会删)
存在的问题:
- 每发一封欢迎邮件要 6 个 API 调用,极度繁琐
- 消耗 API 速率配额(rate limit:5 次 import 请求/分钟)
- MailerLite 后台会残留一堆已删除的 Campaign 记录
- 组本身不是设计给临时用途的,数量多了可能触及上限
所以通过代码手动实现"单人发邮件"既不优雅也不高效。
5.3 MailerLite Automation 的正确用法
MailerLite 提供了 Automation(自动化工作流) 功能,可以在后台配置可视化流程,完全不需要代码参与:
触发条件: Joins a group(订阅者加入指定分组时触发)
执行动作: Send email(发送邮件,可多封、可延迟、可条件分支)
配置流程(在 Dashboard 操作,不需要写代码):
Automation → Create workflow
→ 选择触发器: "Joins a group"
→ 选择分组: "article"(或其他分组名)
→ 添加动作: "Send email"
→ 设计邮件内容(拖拽编辑器或自定义 HTML)
→ 可选: 添加延迟 / 条件分支 / 多封邮件序列
→ 激活
5.4 最终效果:代码 + Automation 的分工
┌── 代码层 ─────────────────────────────┐
│ POST /api/subscribers │
│ email: user@example.com │
│ groups: ["191576374630155327"] │
│ status: "active" │
└─────────────┬───────────────────────────┘
│
▼
┌── MailerLite 平台层 ───────────────────┐
│ │
│ ① 订阅者被添加到 "article" 组 │
│ │
│ ② Automation 检测到 "Joins a group" │
│ ↓ │
│ 触发欢迎邮件工作流 │
│ ↓ │
│ 发送欢迎邮件到订阅者邮箱 │
│ │
│ ③ 退订链接包含 {$unsubscribe} 宏 │
│ 用户点击 → MailerLite 自动处理退订 │
└────────────────────────────────────────┘
总结:
| 职责 | 谁负责 |
|---|---|
| 用户注册、数据持久化 | 代码(API) |
| 欢迎邮件触发与发送 | MailerLite Automation |
| 每周文章推送(批量) | 代码创建 Campaign + MailerLite 发送 |
| 退订管理 | MailerLite 自动处理 |
这也是为什么源码里完全找不到欢迎邮件相关代码的原因——它根本不需要代码参与。
六、Campaign 创建与发送
Campaign 是 MailerLite 的核心概念——一封发送给特定群体的邮件活动。
6.1 创建 Campaign
POST https://connect.mailerlite.com/api/campaigns
Authorization: Bearer {API_KEY}
Content-Type: application/json
{
"name": "栏轩·阁|本周技术速递",
"type": "regular",
"groups": ["191576374630155327"],
"emails": [
{
"subject": "栏轩·阁|本周技术速递",
"from": "notify@lxpavilion.top",
"from_name": "ppc",
"content": "<!doctype html>..."
}
]
}
关键参数:
| 参数 | 说明 |
|---|---|
name |
Campaign 名称(用于后台管理) |
type |
"regular" = 常规邮件,"a/b" = A/B 测试,"rss" = RSS 触发 |
groups |
收件人分组 ID 数组。不传则发送给所有活跃订阅者 |
emails[].content |
邮件 HTML 正文,必须是完整的 HTML 文档(含 <html><body>) |
emails[].subject |
邮件主题 |
响应:
{
"data": {
"id": "9876543210987654321",
"name": "栏轩·阁|本周技术速递",
"type": "regular",
"status": "draft"
}
}
6.2 发送 Campaign
Campaign 创建后处于 draft 状态,需要显式触发发送。有趣的是,这个接口还在用 v2 API:
POST https://api.mailerlite.com/api/v2/campaigns/{campaign_id}/actions/send
X-MailerLite-ApiKey: {API_KEY}
Content-Type: application/json
const sendResp = await fetch(
`https://api.mailerlite.com/api/v2/campaigns/${campaignId}/actions/send`,
{
method: "POST",
headers: {
"X-MailerLite-ApiKey": env.MAILERLITE_API_KEY,
"Content-Type": "application/json",
},
}
);
🚨 CAUTION: 这个接口的坑在于:v3 用
Authorization: Bearer,但 v2 用X-MailerLite-ApiKey。如果用 v3 的 header 发 v2 请求,会收到 401 Unauthorized。解决方案是同一个 API Key 在两个版本中都能用,只需要切换请求头格式。
6.3 查询 Campaign 信息
GET https://connect.mailerlite.com/api/campaigns/{campaign_id}
Authorization: Bearer {API_KEY}
返回包含发送统计的详细信息:已发送数、打开率、点击率等。
6.4 删除 Campaign
DELETE https://connect.mailerlite.com/api/campaigns/{campaign_id}
Authorization: Bearer {API_KEY}
成功返回 HTTP 204 No Content。批量删除时需要先遍历列表再逐个删除:
// 1. 获取所有 campaign
const listResp = await fetch(`${ML_API_BASE}/campaigns`, {
headers: { Authorization: `Bearer ${env.MAILERLITE_API_KEY}` },
});
const { data: campaigns } = await listResp.json();
// 2. 逐个删除
let deletedCount = 0;
for (const c of campaigns) {
const delResp = await fetch(`${ML_API_BASE}/campaigns/${c.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${env.MAILERLITE_API_KEY}` },
});
if (delResp.status === 204) deletedCount++;
}
七、完整订阅流程串联
这是博客中从用户输入邮箱到收到邮件的完整链路,重点关注 MailerLite 参与的部分:
┌──────────┐ ┌──────────────┐ ┌──────────────────────────┐
│ 用户输入 │ │ Worker 后端 │ │ MailerLite │
│ 邮箱 │ ──→ │ │ ──→ │ │
└──────────┘ │ │ │ │
│ ① 本地写 D1 │ │ POST /api/subscribers │
│ ② 添加订阅者 │ ──→ │ → 加入 article 组 │
│ │ │ → 触发 Automation │
│ │ │ → 发送欢迎邮件 │
│ ③ 通知管理员 │ │ │
│ 创建 Campaign │ ──→ │ POST /api/campaigns │
│ → main 组 │ │ → 创建并发送通知 Campaign │
│ │ │ │
│ ④ 每天 00:00 │ │ │
│ Cron 触发 │ ──→ │ POST /api/campaigns │
│ Newsletter │ │ → 创建并发送每周精选 │
└──────────────┘ └──────────────────────────┘
7.1 用户订阅流程
用户 → 前端表单 → Worker API → D1 数据库 + MailerLite
流程步骤:
① 用户输入邮箱,点击订阅
② 前端 POST → Worker: /api/subscribe { email }
③ Worker → D1: INSERT OR IGNORE INTO subscribers
④ Worker → MailerLite: POST /api/subscribers
├─ 新用户 → 201 Created → 触发 Automation → 发送欢迎邮件
└─ 已存在 → 409 Conflict(静默忽略)
⑤ Worker → MailerLite: 创建通知 Campaign → main 组
→ 内容:订阅者邮箱、服务、时间、总订阅数
→ v2 API 发送 → 通知邮件送达管理员
⑥ Worker → 前端: { code: 1, msg: "订阅成功 🎉" }
技术要点:
- D1 先写,MailerLite 后写 —— 即使 MailerLite API 宕机,本地数据也不丢,后续可以用一致性校验 API 来同步修复
- 409 容错 —— 重复订阅时不视为错误,用户无感知
- 欢迎邮件由 Automation 自动触发 —— 不需要手动调用 API 发送,MailerLite 后台配置好 Automation 规则即可
- 管理员通知 —— 订阅成功后立即创建 Campaign 发送到 main 组(仅含管理员邮箱),通知内容包括订阅者邮箱、服务类型、时间和总订阅数。通知失败不阻断主订阅流程
7.2 定时推送流程(每期 Newsletter)
每天 00:00 UTC → Cron 触发 → Worker → RSS Feed + D1 + MailerLite
流程步骤:
① Cron 触发 handleRssPush()
② Worker → 抓取 feed.xml → 解析最新文章列表
③ Worker → D1: 查询已推送文章 ID(汇总所有成功的 push_logs)
④ Worker: 去重对比 → 取前 3 篇新文章
⑤ 判断:
├─ 有新文章 → 渲染 HTML 模板
│ → MailerLite: POST /api/campaigns (创建)
│ → MailerLite: POST v2 /campaigns/{id}/actions/send
│ → D1: INSERT push_logs (success)
└─ 无新文章 → D1: INSERT push_logs (skipped)
八、为什么不用 MailerLite 的编辑器,而是自己写 HTML 模板?
在深入模板细节之前,有必要解释一个架构决策:为什么每周 Newsletter 不用 MailerLite 自带的编辑器,而是用代码拼接完整 HTML 然后通过 API 注入?
这涉及到 MailerLite 两种编辑器的"二选一"困境:
8.1 MailerLite 的两种编辑器
拖拽编辑器(Drag & Drop Builder)
✅ 有 RSS 组件 —— 拖一个 RSS Block 到画布,填上 feed URL,自动拉取最新文章
❌ 样式自定义极其有限 —— 只能选预设的布局、颜色、字体,改不了底层的 HTML 结构
典型用户反馈:"The block system limits the level of creativity and customization."(Capterra 用户评论)
自定义 HTML 编辑器(Custom HTML Editor)
✅ 完全自定义样式 —— 完整的 HTML 控制,想写什么写什么
❌ 没有 RSS 组件 —— 不提供 RSS 相关的代码片段,需要手动写代码从 feed 拉内容
🔒 锁在付费计划之后 —— 只有 Advanced(现 Power)计划($25/mo+)才提供这个编辑器
8.2 矛盾的本质
你想要 RSS 自动化能力 → 只能用拖拽编辑器(有 RSS 组件)
你想要完全自定义样式 → 只能用 Custom HTML(但没有 RSS 组件)
↓
两者不可兼得!!!
8.3 API 方案的破解方式
MailerLite 原生方案(二选一):
拖拽编辑器 → ✅ RSS 组件 ❌ 样式受限
Custom HTML 编辑器 → ✅ 完全自定义 ❌ 无 RSS 组件 🔒 $25/mo
API 方案(全都要):
template.html → ✅ 完全自定义 HTML
template.ts → ✅ 代码注入 RSS 内容 + 去重/筛选逻辑
POST /api/campaigns → ✅ 无需付费计划
你的方案等价于:
| 能力来源 | 相当于 |
|---|---|
template.html 自由编写 HTML |
Custom HTML 编辑器的自由度 |
template.ts 解析 feed 并注入文章 |
拖拽编辑器 RSS 组件的自动化能力 |
| 通过 API 直接创建 Campaign | 跳过计划限制,免费计划也能用 |
总结:MailerLite 官方把这两个能力拆分到了两种编辑器里,还把一个锁在付费墙后面。API 方案把它们重新组合起来了——既有 RSS 自动化的便利,又有完整 HTML 的自由度,还不受计划限制。
九、邮件模板要点
发送 Campaign 时,emails[].content 需要是完整的 HTML 文档,包含 DOCTYPE、head、body。与普通网页开发不同,邮件 HTML 有几个关键约束:
9.1 邮件兼容性技巧
实际使用的模板结构:
<!doctype html>
<html lang="zh-CN">
<head>
<!-- Outlook 兼容 -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no, date=no, email=no, url=no">
<!-- 响应式 -->
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">
<style>
/* 重置 */
body { margin: 0; padding: 0; width: 100%; }
table { border-collapse: collapse; }
img { border: 0; -ms-interpolation-mode: bicubic; }
/* Outlook 修复 */
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
</style>
</head>
<body>
<!-- 内容 -->
</body>
</html>
9.2 模板的动态内容插入
在 Worker 中使用字符串模板引擎,将文章列表注入到 HTML 框架中:
// template.html 存放完整邮件框架,用 {{POSTS}} 标记插入位
// template.ts 负责渲染每篇文章卡片
const CARD = `
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<h3><a href="{{LINK}}" target="_blank">{{TITLE}}</a></h3>
<p>{{DATE}}{{CATEGORY}}</p>
<p>{{TAGS}}</p>
<p>{{SUMMARY}}</p>
<p><a href="{{LINK}}" target="_blank">Read more →</a></p>
</td>
</tr>
</table>`;
export function renderRssEmail(articles: Article[]): string {
const cardsHtml = articles
.map((article, i) => {
const card = renderCard(article);
return i === articles.length - 1 ? card : card + SEPARATOR;
})
.join("\n");
return tpl.replace("{{POSTS}}", cardsHtml);
}
9.3 退订链接
MailerLite 要求每封邮件必须包含退订链接,使用自动化宏:
<a href="{$unsubscribe}" style="color: #ffffff;">Unsubscribe</a>
{$unsubscribe} 是 MailerLite 的内置宏,发送时会自动替换为带退订令牌的 URL,用户点击后一键退订,MailerLite 会自动处理。
十、数据一致性校验
由于采用了双写策略(D1 + MailerLite),难免出现数据不一致的情况。项目实现了一个验证 API 来对比两侧数据:
GET /api/subscribe/verify
返回示例:
{
"data": {
"d1_total": 42,
"mailerlite_total": 40,
"matched": 39,
"only_in_d1": ["user1@example.com", "user2@example.com"],
"only_in_mailerlite": [
{ "email": "user3@example.com", "id": "...", "status": "active" }
],
"only_in_d1_count": 2,
"only_in_mailerlite_count": 1,
"is_consistent": false
}
}
only_in_d1 的常见原因:MailerLite API 请求失败时,D1 已经写入了但 MailerLite 侧没加上。
十一、踩坑记录
11.1 v2/v3 API 断裂
同一功能在不同版本中可能使用完全不同的端点,且官方文档有时没有明确标注哪个接口对应哪个版本。实际的解决方式是抓包对比:
| 功能 | v3 端点 | v2 端点 |
|---|---|---|
| 订阅者 CRUD | connect.mailerlite.com/api/subscribers |
api.mailerlite.com/api/v2/subscribers |
| Campaign 管理 | connect.mailerlite.com/api/campaigns |
api.mailerlite.com/api/v2/campaigns |
| 发送 Campaign | ❌ 不存在 | api.mailerlite.com/api/v2/campaigns/{id}/actions/send |
11.2 请求头不统一
同一个 API Key,v3 用 Authorization: Bearer,v2 用 X-MailerLite-ApiKey。混用报 401。
11.3 API Key 的权限范围
Dashboard 里生成的 API Key 没有可选的 scope 配置项——所有能调的 API 都能调。不存在细粒度权限控制。
11.4 Group ID 的获取方式
Group ID 不是手动填写的整数,是 MailerLite 分配的字符串 ID。需要在 Dashboard 的分组页面 URL 中获取,或者通过 API 列出所有分组来查找。
十二、项目中的环境变量配置
实际生产环境在 wrangler.json 中注入:
{
"vars": {
"MAILERLITE_API_KEY": "eyJ0eXAi...",
"RSS_MAX_ARTICLES": "3",
"FORWARD_EMAIL": "admin@example.com"
}
}
Worker 的 TypeScript 类型定义:
interface Env {
MAILERLITE_API_KEY: string; // MailerLite v3/v2 JWT API Key
RSS_MAX_ARTICLES?: string; // 每期推送文章数上限(默认 3)
FORWARD_EMAIL?: string; // 邮件转发目标地址
}
总结
至此,整个博客的订阅系统已完整构建:
用户订阅 → D1 持久化 + MailerLite 入组 + Automation 欢迎邮件
→ 通知管理员(Campaign → main 组)
定时推送 → 抓取 RSS → 去重筛选 → 渲染模板 → Campaign → article 组
→ 记录推送日志
邮件归档 → Email Routing 接收 → D1 存储 → 转发到私人邮箱
管理维护 → 数据一致性校验 → 双删订阅者 → Campaign 管理
这套方案的核心思路是:MailerLite 只做邮件发送通道,数据和逻辑自己管。带来的好处:
- 费用可控 —— 免费计划就能用,不受原生 RSS Campaign 的付费限制
- 灵活定制 —— 去重逻辑、推送频率、邮件模板全部自己掌控
- 数据主权 —— D1 双写 + 审计日志 + 邮件归档,不绑定任何 ESP
- 易于迁移 —— 抽象层在代码中,替换 ESP 只需改 API 调用层
代价是需要维护自己的后端服务(Worker + D1 + 定时任务),但对于已经有后端基础设施的个人项目来说,这比订阅付费计划更划算、更可控。

浙公网安备 33010602011771号