listmonk 在 v7.0.0-beta.1 里加了一个 SMTP 开关 exclude_from_default。打开后,这台 SMTP 不再进默认的 email 发信池,走默认渠道的邮件用不到它,只有点名 email-tx 才会走它。我用 Spring Boot 4.1.1 调 listmonk 的事务邮件接口 /api/tx 跑了一遍,结果和这个说法一致。另外,SMTP 连不上时,/api/tx 照样回 {"data":true}。

listmonk 是 Kailash Nadh 写的开源邮件列表和群发工具,一个 Go 二进制加一个 PostgreSQL 就能跑,协议 AGPL-3.0,GitHub 上 2.3 万多星。对独立开发者来说,它能同时管订阅者、群发和验证码、收据这类事务邮件,自己部署不按封数收费,只付服务器和 SMTP 的钱。v7.0.0-beta.1 发布于 2026 年 10 月 20 日 18:25(北京时间),是 beta,官方说明里写明遇到问题请提 issue。

为什么要把事务邮件的 SMTP 单独拎出来

常见做法是群发走便宜的通道,收据和密码重置走信誉更好的通道。两类邮件混在一个池里轮询,一次群发就可能把事务通道的额度和信誉一起拖下去。之前算过 Resend、Postmark 和 SES 的事务邮件账单,事务通道一般更贵,更不该让群发占用。

listmonk 以前也能配多台 SMTP,但所有启用的服务器都在默认池里轮询,起了名字的只是额外多出一个能单独选择的渠道。按发件人地址分流(每台 SMTP 的 from_addresses 字段)在 v6.2.0 的代码里就已经有了,不是这次新加的。v7 的发布说明里和这件事相关的新内容有两条:一是 “Ability to exclude specific SMTP servers from the default email messenger pool”,二是 “Multiple From addresses configured in settings”。

跑通的环境

  • listmonk v7.0.0-beta.1 官方 linux/amd64 二进制,PostgreSQL 17.11
  • 两个 Mailpit v1.31.6 冒充两家 SMTP:1025 端口当群发通道,1026 端口当事务通道
  • Spring Boot 4.1.1,Java 21,只用了 spring-boot-starter-restclient

所有邮件都落在本机 Mailpit 里,没有连任何真实的邮件服务商,也没有对外发出一封信。

部署:两个二进制加一个 PostgreSQL

listmonk 和 Mailpit 都是单个二进制,下载解压就能跑,不需要 Docker。PostgreSQL 先建好 listmonk 用户和同名数据库,然后按下面的顺序启动:

./mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025
./mailpit --listen 127.0.0.1:8026 --smtp 127.0.0.1:1026 --database ""

./listmonk --new-config
LISTMONK_ADMIN_USER=admin LISTMONK_ADMIN_PASSWORD=admin12345 ./listmonk --install --idempotent --yes
./listmonk

--new-config 生成默认的 config.toml,里面只有监听地址和数据库连接。超级管理员的账号密码通过环境变量传给 --install,--idempotent 表示库已经初始化过就跳过,适合写进部署脚本。初始化的输出:

** first time (idempotent) installation **

2026/10/22 08:12:18.516534 install.go:95: creating superadmin user 'admin'
2026/10/22 08:12:18.522447 install.go:118: setup complete
2026/10/22 08:12:18.522490 install.go:119: run the program and access the dashboard at localhost:9000

初始化会顺带建两个示例订阅者 john@example.com 和 anon@example.com。SMTP、发件人这些都不在配置文件里,而是存在数据库,通过后台或设置接口改。

配置:一台 SMTP 移出默认池

在设置里给第二台 SMTP 起名 tx,打开排除开关。下面是通过设置接口写进去的字段:

{
  "name": "tx",
  "enabled": true,
  "host": "127.0.0.1",
  "port": 1026,
  "auth_protocol": "none",
  "tls_type": "none",
  "exclude_from_default": true
}

保存后 listmonk 会自动重启,/api/config 里的发信渠道变成 email 和 email-tx 两个。起名为 tx 的服务器,对外的渠道名是 email-tx。源码里也写了,被排除又没起名字的服务器会打一条警告,因为没人能选中它。

发件人这次是数组。我配了 Acme <hello@acme.test> 和 Acme Billing <billing@acme.test> 两个,事务邮件不传 from_email 时用的是第一个。

权限:给 Spring Boot 一个只能发事务邮件的账号

listmonk 的 API 要用 API 用户,请求头格式是 Authorization: token 用户名:令牌。我新建了一个角色 tx-only,权限只勾 tx:send,再建 API 用户 springapp 挂在这个角色下。这个令牌只能调事务邮件接口,读订阅者列表会被拒,见下面第 6 步。

事务模板用的是 listmonk 的 Go 模板语法,主题是 Receipt for order {{ .Tx.Data.order_id }},正文里取 {{ .Tx.Data.amount }}。测试收件人 alice@customer.test 事先加成了订阅者。

Spring Boot 客户端

就是一个 RestClient,messenger、发件人和订阅者模式都留成可选参数:

@Component
public class ListmonkTxClient {

    private final RestClient http;
    private final int templateId;

    public ListmonkTxClient(RestClient.Builder builder,
                            @Value("${listmonk.base-url}") String baseUrl,
                            @Value("${listmonk.api-user}") String apiUser,
                            @Value("${LISTMONK_TOKEN}") String token,
                            @Value("${listmonk.template-id}") int templateId) {
        this.http = builder.baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "token " + apiUser + ":" + token)
                .build();
        this.templateId = templateId;
    }

    /** messenger / from / mode 传 null 就交给 listmonk 用默认值 */
    public String sendReceipt(String to, String orderId, String amount,
                              String messenger, String from, String mode) {
        Map<String, Object> body = new HashMap<>();
        body.put("subscriber_emails", List.of(to));
        body.put("template_id", templateId);
        body.put("data", Map.of("order_id", orderId, "amount", amount));
        if (messenger != null) body.put("messenger", messenger);
        if (from != null) body.put("from_email", from);
        if (mode != null) body.put("subscriber_mode", mode);

        return http.post().uri("/api/tx")
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(String.class);
    }

    public String listSubscribers() {
        return http.get().uri("/api/subscribers").retrieve().body(String.class);
    }
}

启动后按顺序跑 6 个场景,每步把返回值或异常打到日志里。下面是这次运行的原始输出:

2026-10-22T08:18:00.076+08:00  INFO 926858 --- [           main] com.example.txdemo.TxDemoApplication     : [1 默认 messenger + 默认发件人] -> {"data":true}

2026-10-22T08:18:00.080+08:00  INFO 926858 --- [           main] com.example.txdemo.TxDemoApplication     : [2 email-tx + billing 发件人] -> {"data":true}

2026-10-22T08:18:00.084+08:00  INFO 926858 --- [           main] com.example.txdemo.TxDemoApplication     : [3 默认 email + billing 发件人] -> {"data":true}

2026-10-22T08:18:00.090+08:00  WARN 926858 --- [           main] com.example.txdemo.TxDemoApplication     : [4 库里没有的收件人, default 模式] -> 400 BadRequest {"message":"Subscriber (0: bob@customer.test) not found"}
2026-10-22T08:18:00.092+08:00  INFO 926858 --- [           main] com.example.txdemo.TxDemoApplication     : [5 库里没有的收件人, external 模式] -> {"data":true}

2026-10-22T08:18:00.094+08:00  WARN 926858 --- [           main] com.example.txdemo.TxDemoApplication     : [6 tx-only 角色去读订阅者列表] -> 403 Forbidden {"message":"permission denied: subscribers:get"}

两个 Mailpit 收到的信:

== mailpit :8025
2026-10-22T08:18:00.068+08:00 | Acme <hello@acme.test> -> alice@customer.test | Receipt for order A-1001
2026-10-22T08:18:00.084+08:00 | Acme Billing <billing@acme.test> -> alice@customer.test | Receipt for order A-1003
== mailpit :8026
2026-10-22T08:18:00.081+08:00 | Acme Billing <billing@acme.test> -> alice@customer.test | Receipt for order A-1002
2026-10-22T08:18:00.093+08:00 | Acme <hello@acme.test> -> bob@customer.test | Receipt for order B-2002

结果逐条看

第 1 步什么都不传,信落在 1025 的群发通道,发件人是数组里的第一个。第 2 步点名 email-tx,信落在 1026。

第 3 步走默认 email,发件人用 billing,信还是落在 1025。上面这次运行里 tx 服务器没有配 from_addresses,所以我又把 billing@acme.test 写进 tx 的 from_addresses,重启后重跑,第 3 步的信仍然落在 1025。源码里被排除的服务器根本不会交给默认的 email 渠道,按发件人分流也就找不到它。想走事务通道,代码里必须写明 email-tx。

第 4 步是默认模式,,bob@customer.test 不是订阅者,接口回 400,RestClient 抛 BadRequest。注册验证码这种场景,收件人本来就不在库里,要传 "subscriber_mode": "external",第 5 步这样传了就发出去了,也不会顺手把 bob 写进订阅者表。源码里还有一个 fallback 模式,先查库,查不到再当临时收件人。subscriber_mode 这三种模式在 v6.2.0 就有,不是 v7 新增的。

第 6 步用同一个令牌读订阅者列表,回 403,提示 permission denied: subscribers:get。

返回成功不等于发出去了

我把 1026 的 Mailpit 停掉,再用 Spring Boot 发一封走 email-tx 的收据:

2026-10-22T08:18:25.407+08:00  INFO 928463 --- [           main] com.example.txdemo.TxDemoApplication     : [7 email-tx 的 SMTP 已停] -> {"data":true}

listmonk 自己的日志里是这一行:

2026/10/22 08:18:25.397531 manager.go:586: error sending message 'Receipt for order A-1004': dial tcp 127.0.0.1:1026: connect: connection refused

源码里 PushMessage 只是把消息塞进内存队列,塞进去就返回成功,SMTP 的报错只写日志,不回给调用方。所以 Spring Boot 这边拿到的 {"data":true} 只表示消息进了队列。v6.2.0 的 PushMessage 也是同样的写法,这个行为不是 v7 带来的。

升级前要注意的两件事

第一,app.from_email 从字符串改成了数组,这是发布说明里列的破坏性变更。我用旧格式(字符串)调设置接口,直接拿到 HTTP 400 和 {"message":"Bad Request"},原来的两个发件人没被改掉。按旧格式写设置的脚本会在这一步失败。

第二,这是 beta。每次启动日志里都会出现这一行,接着重跑一遍 v7.0.0 的迁移:

2026/10/22 08:17:08.398502 main.go:183: auto-running pending migrations for non-final build v7.0.0-beta.1

源码注释说明,beta、RC 这类非正式版每次启动都会自动跑待执行的迁移,且不在库里记录版本。官方升级说明要求先备份数据库,新界面可以在官方的 beta 演示站看到。

Spring Boot 这一侧

客户端没有用任何 SDK,依赖只有 spring-boot-starter-restclient,认证靠一个只有 tx:send 权限的 API 用户。如果改用 Spring 自带的 JavaMailSender 直连 SMTP,发件人显示别名的写法站内之前有一篇 Spring Boot 中发送邮件时如何让发件人显示别名。

参考:listmonk v7.0.0-beta.1 发布说明(2026-10-20),init.go,email.go,manager.go,tx.go,v6.2.0 到 v7.0.0-beta.1 的变更对比,listmonk GitHub 仓库。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。