Stalwart 邮件服务器完整部署
前言
环境:<SERVER_IP> (Ubuntu, Docker) / 域名
example.com/ 公网 IP<PUBLIC_IP>(动态,路由器 DDNS 自动更新 A 记录)/ DNS 托管于腾讯云 EdgeOne
版本:Stalwart v0.16.19 (Docker) / acme.sh 3.1.5
最终状态:SMTP+IMAP+JMAP 全功能,Let’s Encrypt TLS 证书,Spam 过滤已调优,外部邮件可正常收发
目录
- 架构概述
- 前置条件
- 部署 Stalwart 容器
- 首次配置(Web 后台)
- DNS 记录配置
- TLS 证书签发(Let’s Encrypt DNS-01)
- Spam 过滤器调优
- 邮件客户端配置
- 证书续期流程
- 数据备份与恢复
- JMAP API 速查手册
- 已知问题与排坑记录
- 诊断脚本索引
1. 架构概述
Internet │ ┌───────┴───────┐ │ Router (NAT) │ 公网 IP: <PUBLIC_IP> (动态) │ 端口映射: │ DDNS 自动更新 A 记录 │ 25/443/465/ │ 25→<SRV>:25, 443→<SRV>:443, │ 587/993 │ 465→<SRV>:465, 993→<SRV>:993 └───────┬───────┘ │ ┌───────┴───────┐ │ <SERVER_IP> │ │ (Ubuntu) │ │ │ │ ┌───────────┐ │ │ │ nginx │ │ :80 (HTTP, ACME 反代) │ │ :80 │ │ │ └─────┬─────┘ │ │ │ │ │ ┌─────┴─────┐ │ │ │ Stalwart │ │ :25 SMTP, :143 IMAP, │ │ (Docker) │ │ :465 SMTPS, :587 SMTP, │ │ v0.16.19 │ │ :993 IMAPS, :443 HTTPS, │ │ │ │ :8088 JMAP/API │ │ RocksDB │ │ (→容器内 :8080) │ └───────────┘ │ └────────────────┘关键设计决策:
- Stalwart 配置/数据存储在 RocksDB(容器内
/var/lib/stalwart/),不是文件系统的config.toml - 管理通过 JMAP API(HTTP Basic 认证),端点
/jmap和/api - TLS 证书用 acme.sh DNS-01 手动模式签发(因端口 80 外网不可达 + Stalwart v0.16.x 内置 ACME 有 bug)
- Spam 过滤阈值需手动调高(默认 5.0 对国内邮件过低)
- 公网 IP 为动态,路由器开启 DDNS 自动更新
mail.example.com的 A 记录;Stalwart 配置完全 IP 无关(域名证书 + Docker 监听 0.0.0.0),IP 变化无需任何手动操作
2. 前置条件
2.1 服务器
| 项目 | 要求 | 当前值 |
|---|---|---|
| OS | Linux (Ubuntu 推荐) | Ubuntu |
| Docker | 20.10+ + compose v2 | 已安装 |
| 公网 IP | 动态/固定均可(动态需路由器 DDNS),25 端口入站不被 ISP 封 | <PUBLIC_IP>(动态,路由器 DDNS 自动更新 A 记录) |
| 内存 | 1GB+ (Stalwart 约 100MB) | 充足 |
| 开放端口 | 25(SMTP), 443(HTTPS), 465(SMTPS), 587(Submission), 993(IMAPS) | 路由器已映射 |
2.2 域名与 DNS
| 项目 | 要求 | 当前值 |
|---|---|---|
| 域名 | 已注册 | example.com |
| DNS 托管 | 支持 TXT 记录管理 | 腾讯云 EdgeOne |
| 子域名 | mail.example.com → A 记录 → 公网 IP | ✅ |
2.3 工具
# 服务器上需要:sudo apt install -y curl openssl dnsutils
# acme.sh(证书签发工具)curl https://get.acme.sh | sh -s email=admin@example.comsource ~/.bashrc
# 设 Let's Encrypt 为默认 CA~/.acme.sh/acme.sh --set-default-ca --server letsencrypt3. 部署 Stalwart 容器
3.1 创建目录与 compose 文件
mkdir -p /opt/stalwart && cd /opt/stalwartcat > docker-compose.yml << 'EOF'services: stalwart: image: stalwartlabs/stalwart:v0.16.19 container_name: stalwart hostname: mail.example.com restart: unless-stopped ports: - "25:25" # SMTP — 接收外部邮件 - "143:143" # IMAP — STARTTLS - "465:465" # SMTPS — 隐式 TLS,客户端发信 - "587:587" # SMTP submission — STARTTLS - "993:993" # IMAPS — 隐式 TLS,客户端收信 - "8088:8080" # Web 管理后台 + JMAP(8088 避开 qBittorrent 的 8080) - "443:443" # HTTPS + ACME TLS-ALPN-01 volumes: - ./data:/opt/stalwart-mail environment: - STALWART_HOSTNAME=mail.example.comEOF镜像名注意
官方仓库名是
stalwartlabs/stalwart,不是stalwartlabs/mail-server(后者已废弃,拉取会报manifest unknown)。
3.2 启动并获取初始 admin 密码
docker compose up -d
# 查看首次生成的 admin 账号密码docker compose logs stalwart 2>&1 | grep -i "admin"# 输出形如:User 'admin' created with password 'xxxxxxxxx'3.3 验证服务运行
docker ps --filter name=stalwart --format '{{.Names}}: {{.Status}}'# 应显示: stalwart: Up X minutes
docker port stalwart# 应显示所有端口映射
ss -tlnp | grep -E ':(25|143|465|587|993|443|8088) '# 确认端口监听数据存储位置
- Stalwart 将所有数据(配置、邮箱、证书)存储在 RocksDB 中,位于容器内
/var/lib/stalwart/- compose 中的
./data:/opt/stalwart-mail挂载点实际未被容器使用;如需持久化,应挂载/var/lib/stalwart- 当前配置下数据在容器内,不能用
docker compose up -d重建容器(会丢数据),只能用docker restart stalwart
4. 首次配置(Web 后台)
4.1 登录
- 打开
http://<SERVER_IP>:8088 - 用日志中的 admin 账号密码登录
- 立即改 admin 密码:Settings → Accounts → admin → 修改
4.2 添加域名
- Settings → Domains → Add Domain
- 填入域名
example.com - 保存后,Stalwart 会自动生成 DKIM 密钥对和 DNS Zone File 参考
4.3 创建用户
- Settings → Accounts → New Account
- 填入邮箱地址(如
admin@example.com)和密码 - 保存
5. DNS 记录配置
在 DNS 服务商控制台(本例为腾讯云 EdgeOne)添加以下记录:
| 类型 | 主机记录 | 记录值 | 优先级 | TTL | 说明 |
|---|---|---|---|---|---|
| A | mail | <PUBLIC_IP> | - | 600 | 邮件服务器指向 |
| MX | @ | mail.example.com | 10 | 600 | 邮件交换记录 |
| TXT | @ | v=spf1 a mx ip4:<PUBLIC_IP> ~all | - | 600 | SPF 授权 |
| TXT | _dmarc | v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com | - | 600 | DMARC 策略 |
| TXT | v1-ed25519-20260824._domainkey | v=DKIM1; k=ed25519; h=sha256; p=<公钥> | - | 600 | DKIM ed25519 |
| TXT | v1-rsa-20260824._domainkey | v=DKIM1; k=rsa; h=sha256; p=<RSA公钥> | - | 600 | DKIM RSA |
DKIM 公钥获取:Stalwart 后台 Settings → Domains → example.com → DNS Zone File 会显示完整的 DKIM 记录值。
DMARC 策略:测试阶段用
p=quarantine,验证通过后可升p=reject。
PTR 反向解析:对外发信需公网 IP 的 PTR 指向
mail.example.com,否则 Gmail/QQ 会拒收或进垃圾箱。需联系 ISP 设置。
验证 DNS
dig MX example.com @8.8.8.8 +short# 应返回: 10 mail.example.com.
dig A mail.example.com @8.8.8.8 +short# 应返回: <PUBLIC_IP>
dig TXT example.com @8.8.8.8 +short# 应返回: "v=spf1 a mx ip4:<PUBLIC_IP> ~all"
dig TXT _dmarc.example.com @8.8.8.8 +short# 应返回: "v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com"
dig TXT v1-ed25519-20260824._domainkey.example.com @8.8.8.8 +short# 应返回 DKIM 公钥
dig TXT v1-rsa-20260824._domainkey.example.com @8.8.8.8 +short# 应返回 RSA 公钥6. TLS 证书签发(Let’s Encrypt DNS-01)
6.1 为什么用 DNS-01
| 验证方式 | 可行性 | 原因 |
|---|---|---|
| HTTP-01 (端口 80) | ❌ | 路由器未映射端口 80 入站 / ISP 封入站 80 |
| TLS-ALPN-01 (端口 443) | ❌ | acme.sh v3.1.5 的 --alpn flag 有 bug,实际仍走 HTTP-01;certbot standalone 不支持 |
| DNS-01 (TXT 记录) | ✅ | 只需 DNS 控制台添加 TXT 记录,不依赖任何端口 |
| Stalwart 内置 ACME | ❌ | v0.16.x 调度器 bug (GitHub #3059),任务卡 Pending |
6.2 签发证书
# Step 1: 生成 ACME 订单(获取 TXT 记录值)# 注意:每次执行 --issue 都会生成新的 TXT 值!sudo env HOME=$HOME \ ~/.acme.sh/acme.sh --issue -d mail.example.com --dns \ --yes-I-know-dns-manual-mode-enough-go-ahead-please \ --server letsencrypt --force输出会包含:
[...] Domain: _acme-challenge.mail.example.com[...] TXT value: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'# Step 2: 在 EdgeOne DNS 控制台添加 TXT 记录# 类型: TXT# 主机记录: _acme-challenge.mail# 记录值: <上面输出的 TXT value>
# Step 3: 验证 DNS 传播dig +short TXT _acme-challenge.mail.example.com @8.8.8.8# 应返回你添加的 TXT 值
# Step 4: 完成验证(注意是 --renew 不是 --issue!)sudo env HOME=$HOME \ ~/.acme.sh/acme.sh --renew -d mail.example.com --dns \ --yes-I-know-dns-manual-mode-enough-go-ahead-please \ --force成功输出:
[...] Cert success![...] Your cert is in: ~/.acme.sh/mail.example.com_ecc/fullchain.cer[...] Your cert key is in: ~/.acme.sh/mail.example.com_ecc/mail.example.com.key6.3 转换密钥格式
acme.sh 签发的 ECC 证书私钥是 SEC1 格式(BEGIN EC PRIVATE KEY),Stalwart 需要 PKCS#8 格式(BEGIN PRIVATE KEY):
openssl pkcs8 -topk8 -nocrypt \ -in ~/.acme.sh/mail.example.com_ecc/mail.example.com.key \ -out ~/.acme.sh/mail.example.com_ecc/mail.example.com_pkcs8.key
head -1 ~/.acme.sh/mail.example.com_ecc/mail.example.com_pkcs8.key# 应显示: -----BEGIN PRIVATE KEY-----6.4 上传证书到 Stalwart(JMAP API)
# 读取证书和私钥 PEMCERT_PEM=$(cat ~/.acme.sh/mail.example.com_ecc/fullchain.cer)KEY_PEM=$(cat ~/.acme.sh/mail.example.com_ecc/mail.example.com_pkcs8.key)
# 通过 JMAP 上传证书# 认证: HTTP Basic, 用户 admin@example.com# 端点: http://127.0.0.1:8088/jmap
# 方法一: 直接 curl(需要用文件传 PEM,命令行太长不便内联)# 写入 JSON 到文件再 POSTpython3 -c "import json, syscert = open('~/.acme.sh/mail.example.com_ecc/fullchain.cer').read()key = open('~/.acme.sh/mail.example.com_ecc/mail.example.com_pkcs8.key').read()payload = { 'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': [['x:Certificate/set', { 'create': { 'cert1': { 'certificate': {'@type': 'Text', 'value': cert}, 'privateKey': {'@type': 'Text', 'secret': key} } } }, 'a1']]}with open('/tmp/cert_upload.json', 'w') as f: json.dump(payload, f)print('JSON written')"
curl -s -u 'admin@example.com:<PASSWORD>' \ -X POST http://127.0.0.1:8088/jmap \ -H 'Content-Type: application/json' \ -d @/tmp/cert_upload.json
# 成功返回: {"methodResponses":[["x:Certificate/set",{"created":{"cert1":{"id":"jbmXXXXXXX"}},"accountId":"..."},"a1"]],...}# 记录返回的 cert id6.5 设置域名为 Manual 证书模式
# 将域名 b (example.com) 的证书管理设为 Manual 模式# 这样 Stalwart 就不会尝试自动 ACME(有 bug),而是使用手动上传的证书
curl -s -u 'admin@example.com:<PASSWORD>' \ -X POST http://127.0.0.1:8088/jmap \ -H 'Content-Type: application/json' \ -d '{ "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], "methodCalls": [["x:Domain/set", { "update": {"b": {"certificateManagement": {"@type": "Manual"}}} }, "a1"]] }'
# 成功返回: {"methodResponses":[["x:Domain/set",{"updated":{"b":null}},"a1"]],...}6.6 重启并验证
# 重启 Stalwart 加载新证书(用 docker restart,不要用 compose up)docker restart stalwartsleep 15
# 验证 443 端口证书echo | openssl s_client -connect 127.0.0.1:443 -servername mail.example.com 2>/dev/null \ | openssl x509 -noout -issuer -subject -dates
# 期望输出:# issuer=C=US, O=Let's Encrypt, CN=YE1# subject=CN=mail.example.com# notBefore=Aug 25 00:00:00 2026 GMT# notAfter=Nov 23 00:00:00 2026 GMT
# 验证 465 (SMTPS)echo | openssl s_client -connect 127.0.0.1:465 -servername mail.example.com 2>/dev/null \ | openssl x509 -noout -issuer -subject
# 验证 993 (IMAPS)echo | openssl s_client -connect 127.0.0.1:993 -servername mail.example.com 2>/dev/null \ | openssl x509 -noout -issuer -subject
# 外部可达性验证echo | timeout 10 openssl s_client -connect mail.example.com:443 -servername mail.example.com 2>/dev/null \ | openssl x509 -noout -issuer -subject7. Spam 过滤器调优
7.1 问题现象
邮件能发但”收不到”——实际是 Stalwart 的反垃圾过滤器将外部邮件(QQ 邮箱等)误判为垃圾邮件,丢进了 Junk Mail 文件夹。
7.2 根因分析
Stalwart 默认 scoreSpam=5.0(超过 5 分标记为垃圾)。QQ 邮件因以下”正常特征”得分 8-10:
| 规则 | 加分 | 说明 |
|---|---|---|
| DIRECT_TO_MX | +2.00 | 直连 MX(无中继)——正常行为 |
| HTML_SHORT_LINK_IMG_1 | +2.00 | HTML 含图片链接——QQ 邮件正常 |
| FROM_EXCESS_BASE64 | +1.50 | From 头 Base64 编码——中文发件人名 |
| TO_EXCESS_BASE64 | +1.50 | To 头 Base64 编码——中文收件人名 |
| MID_RHS_MATCH_FROM | +1.00 | Message-ID 域名匹配 From |
| CTE_CASE | +0.50 | Content-Transfer-Encoding 大小写怪癖 |
| MV_CASE | +0.50 | MIME-Version 大小写怪癖 |
| DMARC/DKIM/SPF 通过 | -0.90 | 认证全部通过 |
| 总计 | ~8.30 | 超过阈值 5.0 → 被判垃圾 |
7.3 修复:提高阈值
# 查看当前 spam 设置curl -s -u 'admin@example.com:<PASSWORD>' -X POST http://127.0.0.1:8088/jmap \ -H 'Content-Type: application/json' \ -d '{ "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], "methodCalls": [["x:SpamSettings/get", {"ids": ["singleton"]}, "c1"]] }'
# 修改阈值(scoreSpam 5.0 → 15.0)curl -s -u 'admin@example.com:<PASSWORD>' -X POST http://127.0.0.1:8088/jmap \ -H 'Content-Type: application/json' \ -d '{ "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], "methodCalls": [["x:SpamSettings/set", { "update": { "singleton": { "scoreSpam": 15.0, "scoreDiscard": 0, "scoreReject": 0, "trustContacts": true, "trustReplies": true, "enable": true } } }, "c1"]] }'
# 重启生效docker restart stalwart7.4 SpamSettings 字段说明
| 字段 | 默认值 | 推荐值 | 说明 |
|---|---|---|---|
scoreSpam | 5.0 | 15.0 | 超过此分数 → 标记为垃圾 |
scoreDiscard | 0 | 0 | 超过此分数 → 丢弃(0=禁用) |
scoreReject | 0 | 0 | 超过此分数 → SMTP 拒绝(0=禁用) |
enable | true | true | spam 过滤总开关 |
trustContacts | true | true | 信任联系人(不判垃圾) |
trustReplies | true | true | 信任回复邮件 |
7.5 将误判邮件从 Junk 移到 Inbox
# 获取 JMAP session(拿到 accountId)SESSION=$(curl -s -u 'admin@example.com:<PASSWORD>' \ http://127.0.0.1:8088/jmap/session)ACCOUNT_ID=$(echo $SESSION | python3 -c "import sys,json; print(json.load(sys.stdin)['primaryAccounts']['urn:ietf:params:jmap:mail'])")
# 查询 Junk 文件夹中的邮件curl -s -u 'admin@example.com:<PASSWORD>' -X POST http://127.0.0.1:8088/jmap \ -H 'Content-Type: application/json' \ -d "{ \"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"], \"methodCalls\": [[\"Email/query\", { \"accountId\": \"$ACCOUNT_ID\", \"filter\": {\"inMailbox\": \"c\"}, \"limit\": 50, \"calculateTotal\": true }, \"q1\"]] }"
# 将 Junk 邮件移到 Inbox(清除 $junk 关键字)# 需要替换 eid1, eid2... 为实际邮件 IDcurl -s -u 'admin@example.com:<PASSWORD>' -X POST http://127.0.0.1:8088/jmap \ -H 'Content-Type: application/json' \ -d '{ "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], "methodCalls": [["Email/set", { "accountId": "<ACCOUNT_ID>", "update": { "<eid1>": {"mailboxIds": {"a": true, "c": false}, "keywords": {"$junk": null}}, "<eid2>": {"mailboxIds": {"a": true, "c": false}, "keywords": {"$junk": null}} } }, "m1"]] }'Mailbox ID 说明
a= Inbox,c= Junk Mail。这是 Stalwart 的默认邮箱 ID。
8. 邮件客户端配置
| 协议 | 服务器 | 端口 | 加密 | 认证 |
|---|---|---|---|---|
| IMAP (收信) | mail.example.com | 993 | SSL/TLS | 密码 |
| SMTP (发信) | mail.example.com | 465 | SSL/TLS | 密码 |
| SMTP (备选) | mail.example.com | 587 | STARTTLS | 密码 |
| IMAP (备选) | mail.example.com | 143 | STARTTLS | 密码 |
| JMAP | http://<SERVER_IP>:8088 | 8088 | HTTP (内网) | HTTP Basic |
| Web 管理 | http://<SERVER_IP>:8088 | 8088 | HTTP (内网) | HTTP Basic |
内网访问提示
内网可使用
<SERVER_IP>代替mail.example.com。
9. 证书续期流程
证书有效期 90 天,建议每 60 天续期一次。acme.sh 的自动续期在 DNS 手动模式下会失败,需手动操作。
# 1. 生成新的 ACME 订单(获取新 TXT 值)# 注意:每次 --issue 生成新订单,TXT 值会变!sudo env HOME=$HOME \ ~/.acme.sh/acme.sh --issue -d mail.example.com --dns \ --yes-I-know-dns-manual-mode-enough-go-ahead-please \ --server letsencrypt --force
# 2. 在 EdgeOne DNS 控制台更新 TXT 记录:# 类型: TXT# 主机记录: _acme-challenge.mail# 记录值: <上面输出的新 TXT value># (删除旧 TXT 值,用新值替换)
# 3. 验证 DNS 生效dig +short TXT _acme-challenge.mail.example.com @8.8.8.8# 应返回新的 TXT 值
# 4. 完成验证(注意是 --renew 不是 --issue!)sudo env HOME=$HOME \ ~/.acme.sh/acme.sh --renew -d mail.example.com --dns \ --yes-I-know-dns-manual-mode-enough-go-ahead-please \ --force
# 5. 转换密钥格式openssl pkcs8 -topk8 -nocrypt \ -in ~/.acme.sh/mail.example.com_ecc/mail.example.com.key \ -out ~/.acme.sh/mail.example.com_ecc/mail.example.com_pkcs8.key
# 6. 上传新证书到 Stalwart(通过 JMAP API,见第 6.4 节)# 如果有旧证书,先 destroy 旧证书 id,再创建新的
# 7. 重启 Stalwartdocker restart stalwart
# 8. 验证echo | openssl s_client -connect 127.0.0.1:443 -servername mail.example.com 2>/dev/null \ | openssl x509 -noout -issuer -subject -dates# issuer 应为 Let's Encrypt自动续期(可选)
如果有腾讯云 API 密钥(SecretId/SecretKey),可用 acme.sh 的 dns_tencent 插件实现全自动续期:
# 设置腾讯云 API 密钥export Tencent_SecretId="your_secret_id"export Tencent_SecretKey="your_secret_key"
# 自动 DNS 模式签发(无需手动添加 TXT)sudo env HOME=$HOME \ Tencent_SecretId="$Tencent_SecretId" \ Tencent_SecretKey="$Tencent_SecretKey" \ ~/.acme.sh/acme.sh --issue -d mail.example.com --dns dns_tencent \ --server letsencrypt --force
# 之后 acme.sh cron 会自动续期(仍需手动上传到 Stalwart)10. 数据备份与恢复
10.1 备份
# Stalwart 数据在容器内 RocksDB (/var/lib/stalwart/)# 停止容器后备份整个容器数据卷
docker stop stalwart# 备份容器数据(使用 docker cp 从容器提取)docker cp stalwart:/var/lib/stalwart /opt/backup/stalwart-$(date +%F)docker start stalwart
# 或者备份 compose 挂载目录(如果有持久化配置)tar czf /opt/backup/stalwart-compose-$(date +%F).tar.gz -C /opt/stalwart data10.2 恢复
docker stop stalwart# 恢复数据docker cp /opt/backup/stalwart-2026-08-25 stalwart:/var/lib/stalwartdocker start stalwart警告
- 不要用
docker compose up -d重建容器——会创建新容器,丢失 RocksDB 数据- 只能用
docker restart stalwart或docker stop/start stalwart
11. JMAP API 速查手册
所有 API 调用通过 HTTP POST,端点 http://127.0.0.1:8088/jmap,认证 HTTP Basic。
11.1 获取 Session
curl -s -u 'admin@example.com:PASSWORD' http://127.0.0.1:8088/jmap/session# 返回 accountId 等信息11.2 Domain 管理
// 获取所有 Domain{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["Domain/get",{"accountId":"<account_id>","ids":null},"d1"]]}
// 获取特定 Domain (id="b" = example.com){"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:Domain/get",{"ids":["b"],"properties":["name","certificateManagement"]},"d1"]]}
// 设置 Domain 证书模式为 Manual{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:Domain/set",{ "update":{"b":{"certificateManagement":{"@type":"Manual"}}} },"d1"]]}11.3 Certificate 管理
// 查询所有证书{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:Certificate/query",{},"c1"]]}
// 创建证书(上传 PEM){"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:Certificate/set",{ "create":{ "cert1":{ "certificate":{"@type":"Text","value":"<fullchain PEM>"}, "privateKey":{"@type":"Text","secret":"<PKCS#8 key PEM>"} } } },"c1"]]}
// 删除证书{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:Certificate/set",{"destroy":["<cert_id>"]},"c1"]]}11.4 SpamSettings 管理
// 获取当前 Spam 设置{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:SpamSettings/get",{"ids":["singleton"]},"s1"]]}
// 更新 Spam 阈值{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], "methodCalls":[["x:SpamSettings/set",{ "update":{"singleton":{ "scoreSpam":15.0, "scoreDiscard":0, "scoreReject":0, "trustContacts":true, "trustReplies":true, "enable":true }} },"s1"]]}11.5 邮件操作
// 查询收件箱邮件数 (mailbox "a" = INBOX){"using":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"], "methodCalls":[["Email/query",{ "accountId":"<account_id>", "filter":{"inMailbox":"a"}, "limit":20, "calculateTotal":true },"q1"]]}
// 查询垃圾邮件 (mailbox "c" = JUNK){"using":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"], "methodCalls":[["Email/query",{ "accountId":"<account_id>", "filter":{"inMailbox":"c"}, "limit":20, "calculateTotal":true },"q1"]]}
// 获取邮件详情{"using":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"], "methodCalls":[["Email/get",{ "accountId":"<account_id>", "ids":["<email_id>"], "properties":["id","subject","from","receivedAt","keywords"] },"e1"]]}
// 将邮件从 Junk 移到 Inbox(清除 $junk 标记){"using":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"], "methodCalls":[["Email/set",{ "accountId":"<account_id>", "update":{ "<email_id>":{"mailboxIds":{"a":true,"c":false},"keywords":{"$junk":null}} } },"m1"]]}11.6 Python 调用模板
import paramiko, json, io
client = paramiko.SSHClient()client.set_missing_host_key_policy(paramiko.AutoAddPolicy())client.connect('<SERVER_IP>', username='<SSH_USER>', password='PASSWORD', timeout=15)auth = "admin@example.com:PASSWORD"
def jmap(payload): """通过 SSH 在服务器上执行 curl 调用 JMAP API""" sftp = client.open_sftp() sftp.putfo(io.BytesIO(json.dumps(payload).encode()), '/tmp/c.json') sftp.close() stdin, stdout, stderr = client.exec_command( f"curl -s -u '{auth}' -X POST http://127.0.0.1:8088/jmap " f"-H 'Content-Type: application/json' -d @/tmp/c.json" ) return stdout.read().decode()
# 示例:查询 Domainresult = jmap({ "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], "methodCalls": [["x:Domain/get", {"ids": ["b"], "properties": ["name"]}, "d1"]]})print(result)
client.close()12. 已知问题与排坑记录
12.1 Stalwart 内置 ACME 不工作 (v0.16.x)
问题:通过 JMAP x:Task/set 创建的 AcmeRenewal 任务一直卡在 Pending 状态,scheduler 不拾取执行。连 Stalwart 内部自动创建的任务也卡。
根因:Stalwart v0.16.x 的 ACME 调度存在 bug(GitHub Discussion #3059 证实)。任务 due 时间被设为 createdAt(过去时间),但 scheduler 仍不拾取。
解决方案:不使用 Stalwart 内置 ACME,改用 acme.sh DNS-01 手动模式签发证书,然后通过 JMAP API 上传。
12.2 端口 80 外网不可达
问题:Let’s Encrypt HTTP-01 验证失败,LE 报 Timeout during connect (likely firewall problem) 连接 <PUBLIC_IP>:80。
根因:路由器未映射端口 80 入站,或 ISP 封入站 80。
解决方案:使用 DNS-01 验证(不需要端口 80)。
12.3 acme.sh --alpn Flag 有 Bug
问题:acme.sh v3.1.5 的 --standalone --alpn 模式,本应使用 TLS-ALPN-01(端口 443),但实际仍走 HTTP-01(端口 80)。debug 日志显示 acme.sh 收到 LE 的三种挑战后仍选了 http-01。
根因:acme.sh standalone 模式即使指定 --alpn,仍会先检测端口 80 是否被占用,被占用就报错退出,不实际尝试绑定 443。
解决方案:使用 DNS-01 模式绕过所有端口依赖。
12.4 数据目录挂载陷阱
问题:compose 中 ./data:/opt/stalwart-mail 挂载点实际未被容器使用,数据存在容器内 /var/lib/stalwart/。
影响:docker compose up -d 会重建容器,丢失所有数据。只能用 docker restart stalwart。
解决方案:始终使用 docker restart stalwart / docker stop/start stalwart,避免 compose 重建。
12.5 EC 密钥格式不兼容
问题:acme.sh 签发的 ECC 证书私钥是 SEC1 格式(BEGIN EC PRIVATE KEY),直接上传到 Stalwart 会报 Failed to parse 错误。
解决方案:用 openssl pkcs8 -topk8 -nocrypt 转换为 PKCS#8 格式(BEGIN PRIVATE KEY)。
12.6 Spam 过滤器误判国内邮件
问题:QQ 邮箱等国内邮件因 Base64 编码发件人名、直连 MX 等正常特征得分 8-10,超过默认阈值 5.0 被误判为垃圾。
解决方案:将 scoreSpam 从 5.0 提高到 15.0。
12.7 nginx 端口 80 占用冲突
问题:acme.sh standalone 模式检测到 nginx 占用端口 80,报 “Please stop it first” 后退出。
解决方案:DNS-01 模式不需要端口 80。但需注意 acme.sh standalone 遗留的 python3 进程可能占用 80,需 kill 清理。
12.8 JMAP Certificate 对象格式
问题:Stalwart JMAP Certificate 对象的 certificate 和 privateKey 字段需要特定格式。
正确格式:
{ "certificate": {"@type": "Text", "value": "<PEM 字符串>"}, "privateKey": {"@type": "Text", "secret": "<PEM 字符串>"}}certificate用value字段privateKey用secret字段(不是value)- 两者都需要
@type: "Text"
12.9 每次 --issue 生成新 TXT 值
问题:acme.sh DNS 手动模式下,每次执行 --issue 都会创建新 ACME 订单,生成新 TXT 值,旧值立即失效。
解决方案:
- 只执行一次
--issue获取 TXT 值 - 在 DNS 控制台添加该 TXT 记录
- 等待 DNS 传播
- 用
--renew(不是--issue)完成验证——--renew复用已有订单
12.10 动态 IP 环境
说明:本环境公网 IP 为动态分配(非固定),路由器已开启 DDNS 自动更新 mail.example.com 的 A 记录。
为什么不影响邮件服务:
- DNS A 记录:路由器 DDNS 自动更新,IP 变化后 DNS 在数分钟内跟上
- TLS 证书:基于域名
mail.example.com签发,不绑定 IP - Stalwart 配置:Docker 容器监听
0.0.0.0,不关心公网 IP - 端口映射:路由器 NAT 将 25/443/465/587/993/8088 指向
<SERVER_IP>,只要映射规则不变即可
小概率风险:新 IP 段碰巧命中 DNSBL 黑名单 → 部分服务商可能拒收发信。概率极低,无法预防,遇到再处理。
替代方案(如果路由器不支持 DDNS):
# 在服务器上部署 cron 脚本,每 5 分钟检查 IP 变化并更新 DNS# 需要腾讯云 API 密钥(SecretId/SecretKey)# crontab -e# */5 * * * * /opt/stalwart/ddns.sh13. 诊断脚本索引
项目中所有 Python 诊断/修复脚本(通过 SSH + paramiko 远程操作):
证书签发阶段
| 脚本 | 功能 |
|---|---|
check.py | 基础 JMAP 调用模板(查询 Task、证书状态) |
stageBB.py | 综合诊断:JMAP session、Domain 配置、docker logs、端口状态 |
stageCC.py | 测试 Certificate 创建 API + 检查 nginx ACME 配置 |
stageDD.py | HTTP-01 webroot 模式尝试(失败:端口 80 不可达) |
stageEE.py | TLS-ALPN-01 standalone 尝试(失败:acme.sh bug) |
stageFF.py | 停 nginx + standalone —alpn 尝试(失败:acme.sh 仍走 HTTP-01) |
stageGG.py | certbot TLS-ALPN-01 尝试(失败:certbot 不支持) |
stageHH.py | DNS 诊断 + DNS-01 手动模式尝试 |
stageII.py | 修复 nginx + 检查 DNS 插件 + 验证 TXT 记录 |
stageJJ.py | 修复 nginx + 重新签发 DNS-01 获取 TXT 值 |
stageKK.py | 清理遗留 python3 进程 + 恢复 nginx |
stageLL.py | 验证 DNS TXT 传播 + 完成证书签发 |
stageMM.py | 多 DNS 解析器检查 TXT 记录 |
stageNN.py | 获取最终 TXT 值 + 检查 DNS 配置 |
stageOO.py | DNS-01 验证 + 证书签发(成功) |
stagePP.py | 上传证书到 Stalwart(EC 密钥格式问题) |
stageQQ.py | 上传 PKCS#8 密钥证书 + 重启 Stalwart + 验证(成功) |
收信诊断阶段
| 脚本 | 功能 |
|---|---|
diag_recv.py | DNS/MX/A/SPF/DMARC/DKIM 诊断 + 端口 25 可达性 |
diag_recv2.py | 检查 Inbox/Junk 邮件数 + SMTP 会话诊断 |
diag_recv3.py | 检查 Junk 文件夹内容 + 诊断 spam 判定原因 |
diag_recv4.py | 获取 QQ 邮件 spam 分析头 + 移至 Inbox |
diag_recv5.py | 获取完整 spam 头 + 找 Stalwart spam 配置 |
diag_recv6.py | 查找 Stalwart spam 过滤器配置方法 |
diag_recv7.py | 搜索 Stalwart spam 配置方式(文档) |
修复阶段
| 脚本 | 功能 |
|---|---|
fix_spam.py | 修改 SpamSettings 阈值 5.0→15.0 + 发测试邮件验证 |
verify_fix.py | 重启 Stalwart + 发测试邮件 + 验证邮件进 Inbox(成功) |
脚本通用模板
所有脚本共享相同结构:
import paramiko, json, io, time
client = paramiko.SSHClient()client.set_missing_host_key_policy(paramiko.AutoAddPolicy())client.connect('<SERVER_IP>', username='<SSH_USER>', password='PASSWORD', timeout=15)auth = "admin@example.com:PASSWORD"
def sh(cmd, timeout=30): """执行 SSH 命令""" stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) out = stdout.read().decode(errors='replace').rstrip() err = stderr.read().decode(errors='replace').rstrip() return out + (f"\n[stderr] {err}" if err else "")
def jmap(payload): """调用 JMAP API""" sftp = client.open_sftp() sftp.putfo(io.BytesIO(json.dumps(payload).encode()), '/tmp/c.json') sftp.close() return sh(f"curl -s -u '{auth}' -X POST http://127.0.0.1:8088/jmap " f"-H 'Content-Type: application/json' -d @/tmp/c.json")附录:docker-compose.yml 完整内容
services: stalwart: image: stalwartlabs/stalwart:v0.16.19 container_name: stalwart hostname: mail.example.com restart: unless-stopped ports: - "25:25" # SMTP — 接收外部邮件 - "143:143" # IMAP — STARTTLS - "465:465" # SMTPS — 隐式 TLS,客户端发信 - "587:587" # SMTP submission — STARTTLS - "993:993" # IMAPS — 隐式 TLS,客户端收信 - "8088:8080" # Web 管理后台 + JMAP - "443:443" # HTTPS volumes: - ./data:/opt/stalwart-mail environment: - STALWART_HOSTNAME=mail.example.com附录:nginx ACME 反代配置(可选,用于 HTTP-01 验证)
如果端口 80 外网可达,可配置 nginx 反代 ACME 验证到 Stalwart:
location /.well-known/acme-challenge/ { proxy_pass http://127.0.0.1:8088;}在 nginx 主配置中 include:
server { listen 80; server_name mail.example.com; include /etc/nginx/conf.d/acme-stalwart.inc;}配置说明
当前环境端口 80 外网不可达,此配置无法用于自动验证,仅作参考。
附录:环境清单
| 项目 | 值 |
|---|---|
| 服务器 IP | <SERVER_IP> |
| SSH | <SSH_USER> / |
| 公网 IP | <PUBLIC_IP>(动态,路由器 DDNS 自动更新) |
| 域名 | example.com |
| 邮件主机名 | mail.example.com |
| DNS 托管 | 腾讯云 EdgeOne |
| Stalwart 版本 | v0.16.19 (Docker) |
| acme.sh 版本 | 3.1.5 |
| 证书 CA | Let’s Encrypt (YE1) |
| 证书有效期 | 2026-08-25 → 2026-11-23 |
| Spam 阈值 | 15.0 (默认 5.0) |
| 证书模式 | Manual (手动上传) |
| JMAP 端点 | http://127.0.0.1:8088/jmap |
| JMAP 认证 | HTTP Basic, admin@example.com |
| Domain JMAP id | ”b” (name=example.com) |
| Mailbox: Inbox | ”a” |
| Mailbox: Junk | ”c” |
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!



