listmonk:Go 邮件营销平台,单二进制如何支撑百万级订阅者
邮件营销是一个古老的互联网业务——1998 年的 Mailchimp 到今天还在做。但当你想自托管一个邮件列表管理系统时,选择并不多:Mailchimp 按订阅者收费(10 万订阅者每月 $500+),SendGrid 按邮件量收费,Brevo(原 Sendinblue)类似。更关键的是,你的订阅者数据、打开率、点击率全部存在别人的服务器上。
listmonk 解决的是自托管 newsletter + 邮件列表管理的问题。Go 单二进制 + PostgreSQL,支持百万级订阅者、并发发送、模板引擎、退信处理、多用户权限、双确认订阅、实时分析。19,600+ Star,AGPL v3 协议,Zerodha(印度最大券商)内部孵化。
安装
# Docker Compose 一键部署
curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml
docker compose up -d
# 访问 http://localhost:9000
# 或直接下载二进制
./listmonk --new-config # 生成 config.toml
# 编辑 PostgreSQL 连接信息
./listmonk --install # 初始化数据库
./listmonk # 启动
零依赖——只要一个 PostgreSQL 数据库。
五层架构
listmonk 的架构是清晰的分层设计:
┌──────────────────────────────────┐
│ Frontend (Vue + Buefy) │ 管理界面
├──────────────────────────────────┤
│ HTTP API (Echo v4) │ REST 端点
├──────────────────────────────────┤
│ Business Logic (core.Core) │ CRUD + 查询
├──────────────────────────────────┤
│ Campaign Engine (manager) │ 异步发送引擎
├──────────────────────────────────┤
│ Data (PostgreSQL + SQL) │ 持久化
└──────────────────────────────────┘
核心组件:
|
|
|
|---|---|
core.Core |
|
manager.Manager |
|
bounce.Manager |
|
subimporter.Importer |
|
auth.Auth |
|
media.Store |
|
models.Queries |
|
邮件发送引擎(Campaign Pipeline)
这是 listmonk 的技术核心——如何高效地把一封邮件发给百万级订阅者。
执行流水线
Manager 每 5 秒轮询 DB
│
├── 发现 status=running 的 campaign
│
├── 创建 pipe(处理管线)
│ ├── 按 batch_size(默认 1000)分批取订阅者
│ ├── 用 last_subscriber_id 做游标(不跳不重)
│ ├── 模板渲染(Sprig 模板函数 + 个性化替换)
│ └── 推入发送队列
│
└── Worker goroutine 池(默认 10 个)
├── 从队列取消息
├── 通过 SMTP 连接池发送
├── 滑动窗口限流(默认 10000/hour)
└── 错误计数,超过阈值自动暂停
关键参数
|
|
|
|
|---|---|---|
app.batch_size |
|
|
app.concurrency |
|
|
app.message_rate |
|
|
app.max_send_errors |
|
|
app.message_sliding_window |
|
|
app.message_sliding_window_rate |
|
|
app.message_sliding_window_duration |
|
|
SMTP 连接池
// knadh/smtppool/v2 — 作者自己的 SMTP 连接池库
type Pool struct {
servers []*Server
// 多 SMTP 服务器轮询
// 连接复用、自动重连
// TLS/SSL 支持
}
knadh/smtppool/v2 是作者专门写的 SMTP 连接池——支持多个 SMTP 服务器轮询、连接复用、自动重连。配置多个 SMTP 服务器时,listmonk 会自动在它们之间做负载均衡。
游标分页
-- 不用 OFFSET(百万级 OFFSET 性能灾难)
-- 用 last_subscriber_id 做游标
SELECT * FROM subscribers
WHEREid > :last_subscriber_id
ORDERBYidASC
LIMIT :batch_size
OFFSET 1000000 在 PostgreSQL 里需要扫描前 100 万行再丢弃。游标分页通过 WHERE id > last_id 直接定位,无论数据量多大都是 O(1)。
模板引擎
listmonk 使用 Go 标准库的 text/template + Masterminds/sprig/v3(150+ 模板函数):
// 邮件模板示例
Subject: {{ .Data.Subject }}
Hi {{ .Subscriber.Name }},
{{ .Content }}
Unsubscribe: {{ .UnsubscribeURL }}
可用变量
|
|
|
|---|---|
.Subscriber.Email |
|
.Subscriber.Name |
|
.Subscriber.Attribs |
|
.Campaign.Name |
|
.UnsubscribeURL |
|
.ViewURL |
|
Sprig 函数
// 条件逻辑
{{ if gt .Subscriber.Attribs.score 80 }}
VIP 用户专属内容
{{ end }}
// 字符串操作
{{ .Subscriber.Name | upper | trunc 20 }}
// 日期格式化
{{ now | date "2006-01-02" }}
// 数学运算
{{ add .Data.Price .Data.Tax }}
150+ 函数覆盖字符串、日期、数学、加密、正则、JSON 处理——大部分邮件个性化需求不需要写代码。
数据库设计
11 个 ENUM 类型
CREATETYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blocklisted');
CREATETYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
CREATETYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
CREATETYPE campaign_type AS ENUM ('regular', 'optin');
CREATETYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown', 'visual');
CREATETYPE list_type AS ENUM ('public', 'private', 'temporary');
CREATETYPE list_optin AS ENUM ('single', 'double');
PostgreSQL ENUM 保证类型安全——活动状态只能是 draft/running/scheduled/paused/cancelled/finished 之一,不可能写入非法状态。
订阅者-列表多对多
-- 多对多关系表
CREATETABLE subscriber_lists (
subscriber_id INTREFERENCES subscribers(id),
list_id INTREFERENCES lists(id),
status subscription_status NOTNULLDEFAULT'unconfirmed',
PRIMARY KEY (subscriber_id, list_id)
);
双层状态设计:
-
订阅者全局状态: enabled/disabled/blocklisted -
列表级别状态: unconfirmed/confirmed/unsubscribed
一个订阅者可以退订列表 A,但仍在列表 B 中。这种细粒度控制在邮件营销中很重要——你不能因为用户退订了产品更新,就把他从小技巧周刊里也删掉。
物化视图
-- 仪表盘缓存
CREATEMATERIALIZEDVIEW mat_dashboard_counts AS
SELECTCOUNT(*) FROM subscribers WHEREstatus = 'enabled';
CREATEMATERIALIZEDVIEW mat_dashboard_charts AS
-- 30 天点击和打开趋势
SELECTdate, SUM(clicks), SUM(views) FROM campaign_views
GROUPBYdate;
CREATEMATERIALIZEDVIEW mat_list_subscriber_stats AS
-- 每个列表的订阅者统计
SELECT list_id, status, COUNT(*) FROM subscriber_lists GROUPBY list_id, status;
三个物化视图缓存昂贵的聚合查询。百万级订阅者的仪表盘不需要每次都 COUNT——刷新物化视图就行。
退信处理(Bounce Management)
邮件发送中最难处理的部分不是”发出去”,而是”被退回来”。
双通道退信
通道 1:Webhook(实时)
├── AWS SES SNS → POST /webhooks/bounce
├── SendGrid → POST /webhooks/bounce
├── Postmark → POST /webhooks/bounce
└── Mailgun → POST /webhooks/bounce
通道 2:POP3(延迟)
├── 定期检查退信邮箱(POP3 协议)
├── knadh/go-pop3 库连接邮箱
├── emersion/go-message 解析 MIME 邮件
└── 正则匹配退信内容
退信分类
// 启发式分类:分析退信邮件内容中的关键词
// Hard bounce → 永久性错误(邮箱不存在、域名无效)
// Soft bounce → 临时性错误(邮箱满了、服务器拒收)
硬退信(Hard Bounce):自动将订阅者标记为 blocklisted,后续活动不再发送。软退信(Soft Bounce):记录但不断供,等待下次重试。
权限系统
角色 + 权限
// permissions.json
{
"roles": [
{
"id": 1,
"name": "Super Admin",
"permissions": ["*"]
},
{
"id": 2,
"name": "Manager",
"permissions": [
"subscribers:manage",
"campaigns:manage",
"lists:manage"
]
},
{
"id": 3,
"name": "Viewer",
"permissions": [
"subscribers:view",
"campaigns:view",
"lists:view"
]
}
]
}
双层权限:
-
全局角色( user_role_id):控制用户能访问哪些功能模块 -
列表角色( list_role_id):控制用户能访问哪些具体的邮件列表
支持角色继承(parent_id)——”编辑”角色继承”查看者”的所有权限,再加上编辑权限。
认证方式
|
|
|
|---|---|
|
|
|
|
|
coreos/go-oidc/v3
|
|
|
pquerna/otp
|
|
|
|
|
|
zerodha/simplesessions/v3
|
Go 技术亮点
jmoiron/sqlx + lib/pq
// 不是 ORM,是 sqlx(在 database/sql 上的薄封装)
// SQL 写在 queries/ 目录的 .sql 文件里
// goyesql/v2 将 SQL 文件解析为 named queries
-- queries/subscribers.sql
-- name: get-subscriber
SELECT * FROM subscribers WHERE id = $1;
-- name: query-subscribers
SELECT * FROM subscribers
WHERE id > :offset
ORDER BY id ASC
LIMIT :limit;
SQL 和 Go 代码分离——.sql 文件专注 SQL,Go 代码专注业务逻辑。goyesql/v2 解析 SQL 文件,sqlx 执行参数化查询。没有 ORM 的抽象泄漏问题。
koanf/v2 配置
// 作者自己的配置库(knadh/koanf)
// 支持 TOML + 环境变量 + 命令行参数 + JSON
// 统一合并,优先级清晰
// config.toml
[app]
batch_size = 1000
concurrency = 10
# 环境变量覆盖
LISTMONK_APP__BATCH_SIZE=2000
koanf 支持 8+ 种配置源,按优先级合并。环境变量用双下划线表示嵌套(APP__BATCH_SIZE → app.batch_size)。
smtppool/v2 — SMTP 连接池
// 核心设计
type Pool struct {
servers []*Server // 多服务器
index uint64// 原子计数器轮询
}
func(p *Pool)Send(m *Message)error {
server := p.next() // Round-robin
conn := server.get() // 复用连接
return conn.Send(m)
}
多 SMTP 服务器轮询、连接复用、自动重连。配置 AWS SES + Mailgun + 自建 SMTP 时,listmonk 自动在三者的连接池之间做负载均衡。
easyjson 高性能 JSON
// zerodha/easyjson — 编译时 JSON 序列化代码生成
// 比标准库 encoding/json 快 3-5 倍
// 用于 API 响应的高频序列化场景
Zerodha(listmonk 的孵化公司)自己的 easyjson 库——通过代码生成避免运行时反射,JSON 序列化性能接近手动编写。
Echo v4 HTTP 框架
// labstack/echo/v4 — 高性能 HTTP 框架
// 路由、中间件、参数绑定、错误处理
e := echo.New()
e.GET("/api/subscribers", handlers.QuerySubscribers)
e.POST("/api/campaigns", handlers.CreateCampaign)
goldmark Markdown
// yuin/goldmark — Go 标准 Markdown 解析器
// 邮件内容支持 Markdown 格式
// 自动转换为 HTML
邮件内容支持四种格式:RichText(WYSIWYG 编辑器)、HTML、Markdown、纯文本。
和其他方案对比
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Mailchimp 最省心但最贵(10 万订阅者月费 $500+)。Brevo 功能全但锁定服务商。Mailtrain 也是开源但 Node.js 技术栈、单用户、功能较少。listmonk 在”自托管 + 功能全面 + 高性能”这个组合上优势明显。
实战场景
场景 1:技术博客 Newsletter
# config.toml
[app]
batch_size=1000
concurrency=5
message_rate=5# 不想打爆 SMTP
[smtp]
host="email-smtp.us-east-1.amazonaws.com"
port=587
username="SES_USER"
password="SES_PASS"
1 万订阅者的周报——每封个性化,5 秒发完。
场景 2:电商用户分层
列表 A:VIP 用户(过去 12 个月消费 > 500)
列表 B:活跃用户(30 天内有登录)
列表 C:沉睡用户(90 天无登录)
活动 1 → 列表 A:新品预览 + 专属折扣
活动 2 → 列表 B:常规促销
活动 3 → 列表 C:召回优惠
订阅者可以同时在多个列表中,退订一个不影响其他。
场景 3:多团队共享
市场部 → 管理"产品更新"和"促销"列表
技术部 → 管理"API 变更通知"列表
客服部 → 只能查看订阅者数据,不能发送
通过全局角色 + 列表角色控制各团队的访问权限。
注意事项
-
AGPL v3:如果你修改 listmonk 代码并通过网络提供服务,需要开源你的修改。内部使用或自托管不在此限,但 SaaS 产品需要留意传染性 -
PostgreSQL 必需:不支持 MySQL/SQLite。PostgreSQL 的 ENUM、物化视图、窗口函数是核心依赖 -
SMTP 依赖:listmonk 不内置邮件发送——你需要配置外部 SMTP(AWS SES、Mailgun、自建 Postfix 等) -
单实例:不支持多实例水平扩展。百万级订阅者单实例可以应对,更大规模需要数据库分片 -
前端编译:Vue 前端编译后通过 embed嵌入二进制,修改前端需要重新编译 -
退信延迟:POP3 方式的退信有延迟(取决于轮询间隔),Webhook 方式实时性更好
总结
listmonk 的价值在于它回答了一个问题:”邮件营销系统到底有多复杂?”答案是:如果选对技术栈,一个 Go 二进制 + PostgreSQL 就够了。Zerodha 用它管理印度最大的券商用户群——这是真实的百万级生产验证。
从 Go 项目的角度看,listmonk 是”SQL-first 架构”的工程典范:
-
sqlx + goyesql:SQL 和 Go 代码分离,没有 ORM 的抽象泄漏 -
PostgreSQL ENUM:11 个自定义类型,数据库层保证数据一致性 -
游标分页: last_subscriber_id避免 OFFSET 性能灾难 -
物化视图:缓存昂贵聚合,百万级仪表盘秒开 -
smtppool/v2:自研 SMTP 连接池,多服务器负载均衡 -
Campaign Pipeline:批量取订阅者 + 并发 worker + 滑动窗口限流 + 自动错误暂停 -
koanf/v2:作者自己的配置库,8 种配置源统一合并 -
easyjson:编译时 JSON 代码生成,避免运行时反射
如果你需要一个自托管的邮件营销系统,listmonk 是目前 Go 生态中最成熟的选择。
GitHub 仓库:github.com/knadh/listmonk
推荐阅读
