Stalwart 邮件服务器完整部署

4980 字
25 分钟
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 过滤已调优,外部邮件可正常收发


目录#

  1. 架构概述
  2. 前置条件
  3. 部署 Stalwart 容器
  4. 首次配置(Web 后台)
  5. DNS 记录配置
  6. TLS 证书签发(Let’s Encrypt DNS-01)
  7. Spam 过滤器调优
  8. 邮件客户端配置
  9. 证书续期流程
  10. 数据备份与恢复
  11. JMAP API 速查手册
  12. 已知问题与排坑记录
  13. 诊断脚本索引

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 服务器#

项目要求当前值
OSLinux (Ubuntu 推荐)Ubuntu
Docker20.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 工具#

Terminal window
# 服务器上需要:
sudo apt install -y curl openssl dnsutils
# acme.sh(证书签发工具)
curl https://get.acme.sh | sh -s email=admin@example.com
source ~/.bashrc
# 设 Let's Encrypt 为默认 CA
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt

3. 部署 Stalwart 容器#

3.1 创建目录与 compose 文件#

Terminal window
mkdir -p /opt/stalwart && cd /opt/stalwart
cat > 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.com
EOF

镜像名注意

官方仓库名是 stalwartlabs/stalwart不是 stalwartlabs/mail-server(后者已废弃,拉取会报 manifest unknown)。

3.2 启动并获取初始 admin 密码#

Terminal window
docker compose up -d
# 查看首次生成的 admin 账号密码
docker compose logs stalwart 2>&1 | grep -i "admin"
# 输出形如:User 'admin' created with password 'xxxxxxxxx'

3.3 验证服务运行#

Terminal window
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 登录#

  1. 打开 http://<SERVER_IP>:8088
  2. 用日志中的 admin 账号密码登录
  3. 立即改 admin 密码:Settings → Accounts → admin → 修改

4.2 添加域名#

  1. Settings → Domains → Add Domain
  2. 填入域名 example.com
  3. 保存后,Stalwart 会自动生成 DKIM 密钥对和 DNS Zone File 参考

4.3 创建用户#

  1. Settings → Accounts → New Account
  2. 填入邮箱地址(如 admin@example.com)和密码
  3. 保存

5. DNS 记录配置#

在 DNS 服务商控制台(本例为腾讯云 EdgeOne)添加以下记录:

类型主机记录记录值优先级TTL说明
Amail<PUBLIC_IP>-600邮件服务器指向
MX@mail.example.com10600邮件交换记录
TXT@v=spf1 a mx ip4:<PUBLIC_IP> ~all-600SPF 授权
TXT_dmarcv=DMARC1; p=quarantine; rua=mailto:postmaster@example.com-600DMARC 策略
TXTv1-ed25519-20260824._domainkeyv=DKIM1; k=ed25519; h=sha256; p=<公钥>-600DKIM ed25519
TXTv1-rsa-20260824._domainkeyv=DKIM1; k=rsa; h=sha256; p=<RSA公钥>-600DKIM 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#

Terminal window
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 内置 ACMEv0.16.x 调度器 bug (GitHub #3059),任务卡 Pending

6.2 签发证书#

Terminal window
# 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'
Terminal window
# 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.key

6.3 转换密钥格式#

acme.sh 签发的 ECC 证书私钥是 SEC1 格式(BEGIN EC PRIVATE KEY),Stalwart 需要 PKCS#8 格式(BEGIN PRIVATE KEY):

Terminal window
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)#

Terminal window
# 读取证书和私钥 PEM
CERT_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 到文件再 POST
python3 -c "
import json, sys
cert = 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 id

6.5 设置域名为 Manual 证书模式#

Terminal window
# 将域名 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 重启并验证#

Terminal window
# 重启 Stalwart 加载新证书(用 docker restart,不要用 compose up)
docker restart stalwart
sleep 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 -subject

7. 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.00HTML 含图片链接——QQ 邮件正常
FROM_EXCESS_BASE64+1.50From 头 Base64 编码——中文发件人名
TO_EXCESS_BASE64+1.50To 头 Base64 编码——中文收件人名
MID_RHS_MATCH_FROM+1.00Message-ID 域名匹配 From
CTE_CASE+0.50Content-Transfer-Encoding 大小写怪癖
MV_CASE+0.50MIME-Version 大小写怪癖
DMARC/DKIM/SPF 通过-0.90认证全部通过
总计~8.30超过阈值 5.0 → 被判垃圾

7.3 修复:提高阈值#

Terminal window
# 查看当前 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 stalwart

7.4 SpamSettings 字段说明#

字段默认值推荐值说明
scoreSpam5.015.0超过此分数 → 标记为垃圾
scoreDiscard00超过此分数 → 丢弃(0=禁用)
scoreReject00超过此分数 → SMTP 拒绝(0=禁用)
enabletruetruespam 过滤总开关
trustContactstruetrue信任联系人(不判垃圾)
trustRepliestruetrue信任回复邮件

7.5 将误判邮件从 Junk 移到 Inbox#

Terminal window
# 获取 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... 为实际邮件 ID
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/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.com993SSL/TLS密码
SMTP (发信)mail.example.com465SSL/TLS密码
SMTP (备选)mail.example.com587STARTTLS密码
IMAP (备选)mail.example.com143STARTTLS密码
JMAPhttp://<SERVER_IP>:80888088HTTP (内网)HTTP Basic
Web 管理http://<SERVER_IP>:80888088HTTP (内网)HTTP Basic

内网访问提示

内网可使用 <SERVER_IP> 代替 mail.example.com


9. 证书续期流程#

证书有效期 90 天,建议每 60 天续期一次。acme.sh 的自动续期在 DNS 手动模式下会失败,需手动操作。

Terminal window
# 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. 重启 Stalwart
docker 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 插件实现全自动续期:

Terminal window
# 设置腾讯云 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 备份#

Terminal window
# 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 data

10.2 恢复#

Terminal window
docker stop stalwart
# 恢复数据
docker cp /opt/backup/stalwart-2026-08-25 stalwart:/var/lib/stalwart
docker start stalwart

警告

  • 不要用 docker compose up -d 重建容器——会创建新容器,丢失 RocksDB 数据
  • 只能用 docker restart stalwartdocker stop/start stalwart

11. JMAP API 速查手册#

所有 API 调用通过 HTTP POST,端点 http://127.0.0.1:8088/jmap,认证 HTTP Basic。

11.1 获取 Session#

Terminal window
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()
# 示例:查询 Domain
result = 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 对象的 certificateprivateKey 字段需要特定格式。

正确格式

{
"certificate": {"@type": "Text", "value": "<PEM 字符串>"},
"privateKey": {"@type": "Text", "secret": "<PEM 字符串>"}
}
  • certificatevalue 字段
  • privateKeysecret 字段(不是 value
  • 两者都需要 @type: "Text"

12.9 每次 --issue 生成新 TXT 值#

问题:acme.sh DNS 手动模式下,每次执行 --issue 都会创建新 ACME 订单,生成新 TXT 值,旧值立即失效。

解决方案

  1. 只执行一次 --issue 获取 TXT 值
  2. 在 DNS 控制台添加该 TXT 记录
  3. 等待 DNS 传播
  4. --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):

Terminal window
# 在服务器上部署 cron 脚本,每 5 分钟检查 IP 变化并更新 DNS
# 需要腾讯云 API 密钥(SecretId/SecretKey)
# crontab -e
# */5 * * * * /opt/stalwart/ddns.sh

13. 诊断脚本索引#

项目中所有 Python 诊断/修复脚本(通过 SSH + paramiko 远程操作):

证书签发阶段#

脚本功能
check.py基础 JMAP 调用模板(查询 Task、证书状态)
stageBB.py综合诊断:JMAP session、Domain 配置、docker logs、端口状态
stageCC.py测试 Certificate 创建 API + 检查 nginx ACME 配置
stageDD.pyHTTP-01 webroot 模式尝试(失败:端口 80 不可达)
stageEE.pyTLS-ALPN-01 standalone 尝试(失败:acme.sh bug)
stageFF.py停 nginx + standalone —alpn 尝试(失败:acme.sh 仍走 HTTP-01)
stageGG.pycertbot TLS-ALPN-01 尝试(失败:certbot 不支持)
stageHH.pyDNS 诊断 + 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.pyDNS-01 验证 + 证书签发(成功)
stagePP.py上传证书到 Stalwart(EC 密钥格式问题)
stageQQ.py上传 PKCS#8 密钥证书 + 重启 Stalwart + 验证(成功)

收信诊断阶段#

脚本功能
diag_recv.pyDNS/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:

/etc/nginx/conf.d/acme-stalwart.inc
location /.well-known/acme-challenge/ {
proxy_pass http://127.0.0.1:8088;
}

在 nginx 主配置中 include:

/etc/nginx/conf.d/stalwart.conf
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
证书 CALet’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”

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Stalwart 邮件服务器完整部署
https://vtdd.vip/posts/stalwart-邮件服务器完整部署/
作者
Zero
发布于
2026-08-26
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Zero
有三件事人类都要经历:出生、生活和死亡。他们出生时无知无觉,死到临头,痛不欲生,活着的时候却又怠慢了人生。——拉布吕耶尔
分类
标签
最新动态
站点统计
文章
28
动态
7
分类
9
标签
34
总字数
18,563
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.14.3
文章许可
CC BY-NC-SA 4.0
1
前言
目录
1. 架构概述
2. 前置条件
2.1 服务器
2.2 域名与 DNS
2.3 工具
3. 部署 Stalwart 容器
3.1 创建目录与 compose 文件
3.2 启动并获取初始 admin 密码
3.3 验证服务运行
4. 首次配置(Web 后台)
4.1 登录
4.2 添加域名
4.3 创建用户
5. DNS 记录配置
验证 DNS
6. TLS 证书签发(Let’s Encrypt DNS-01)
6.1 为什么用 DNS-01
6.2 签发证书
6.3 转换密钥格式
6.4 上传证书到 Stalwart(JMAP API)
6.5 设置域名为 Manual 证书模式
6.6 重启并验证
7. Spam 过滤器调优
7.1 问题现象
7.2 根因分析
7.3 修复:提高阈值
7.4 SpamSettings 字段说明
7.5 将误判邮件从 Junk 移到 Inbox
8. 邮件客户端配置
9. 证书续期流程
自动续期(可选)
10. 数据备份与恢复
10.1 备份
10.2 恢复
11. JMAP API 速查手册
11.1 获取 Session
11.2 Domain 管理
11.3 Certificate 管理
11.4 SpamSettings 管理
11.5 邮件操作
11.6 Python 调用模板
12. 已知问题与排坑记录
12.1 Stalwart 内置 ACME 不工作 (v0.16.x)
12.2 端口 80 外网不可达
12.3 acme.sh --alpn Flag 有 Bug
12.4 数据目录挂载陷阱
12.5 EC 密钥格式不兼容
12.6 Spam 过滤器误判国内邮件
12.7 nginx 端口 80 占用冲突
12.8 JMAP Certificate 对象格式
12.9 每次 --issue 生成新 TXT 值
12.10 动态 IP 环境
13. 诊断脚本索引
证书签发阶段
收信诊断阶段
修复阶段
脚本通用模板
附录:docker-compose.yml 完整内容
附录:nginx ACME 反代配置(可选,用于 HTTP-01 验证)
附录:环境清单