🚀 运行: ...
IP: ...
ISP: 检测中...

自建邮箱全攻略:LanQin Email + Docker + Cloudflare Tunnel(Web 走隧道,邮件直连)

为什么写这篇

想自建一个邮箱,告别大厂邮箱的广告和限速。选了 LanQin Email:Go + React 全栈,单 Docker 容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd,默认 SQLite,开箱即用。

但我的服务器情况有点特殊:80/443 端口已经被宿主机上的 nginx 占了,LanQin 官方 compose 默认映射 80/443,直接 up 会报端口冲突。于是方案定为:

  • Web 页面:走 Cloudflare Tunnel(cloudflared 跑在同一个 compose 里),不占用宿主机 80/443
  • 邮件端口:25 / 465 / 587 / 993 / 995 直接从 VPS 公网映射出去
用户浏览器 ──HTTPS──▶ Cloudflare 边缘 ──隧道──▶ cloudflared ──HTTP──▶ lanqin-email:80(容器内 Nginx→API)
                                                                        ↕
邮件客户端 ──直连──▶ VPS 公网 IP: 25 / 465 / 587 / 993 / 995(容器内 Postfix / Dovecot / API)

一个关键架构决策:必须用两个主机名

这是全文最重要的一个坑,先讲清楚:

  • mail.example.com → 走 Cloudflare Tunnel,只承载 Web 页面
  • mx.example.com → DNS-only 的 A 记录指向 VPS 公网 IP,承载所有邮件协议 + MX 记录

为什么不能共用一个? Tunnel 建好后,mail.example.com 会变成一条指向 cfargotunnel.com 的 CNAME,解析到 Cloudflare 边缘节点。HTTP 流量没问题,但 25 端口打过去落到的是 CF 边缘——它不收 SMTP,收件会直接失败。所以邮件必须用独立的直连主机名。

对应到配置里:

LANQIN_PUBLIC_HOSTNAME=mx.example.com          # 邮件主机名(Postfix HELO、DNS 展示用)
LANQIN_PUBLIC_BASE_URL=https://mail.example.com  # Web 访问地址(走隧道)

前提

  • 一台 VPS,Docker + Compose v2 已装,有 root 权限
  • 一个域名,已托管在 Cloudflare(本文用 example.com 占位,请替换成你自己的)
  • 云控制台安全组放行入方向 TCP:25、465、587、993、995(Web 走隧道,80/443 不用从公网放行)
  • 确认云厂商没封 25 端口出方向:
timeout 5 bash -c '</dev/tcp/gmail-smtp-in.l.google.com/25' && echo OK

第 1 步:拉代码、建配置文件

cd ~
git clone https://github.com/LanQin996/LanQin-Email.git
cd ~/LanQin-Email/deploy
cp .env.example .env
chmod 600 .env

第 2 步:改 .env

nano ~/LanQin-Email/deploy/.env

至少改这 5 行(example.com 全换成你的真实域名):

LANQIN_PUBLIC_HOSTNAME=mx.example.com
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
LANQIN_ADMIN_EMAIL=[email protected]
LANQIN_ADMIN_PASSWORD=换成强密码
LANQIN_TRUSTED_PROXY_COUNT=1

注意 LANQIN_TRUSTED_PROXY_COUNT=1:这是上游文档定的值(compose 自带一层 Nginx 反代即设为 1)。Cloudflare 边缘会在 X-Forwarded-For 里追加客户端 IP,cloudflared 只是原样透传、不再追加,所以 API 看到的仍然是“客户端 IP, cloudflared 容器 IP”,count=1 正好取到真实 IP。设成 2 会取过头,后台审计日志里看到的全是内网 IP。

第 3 步:改 docker-compose.yml

nano ~/LanQin-Email/deploy/docker-compose.yml

① ports:Web 只留 8081 给本机健康检查用(443 映射删掉,80/443 本来就被占了),邮件 5 端口直接映射:

ports:
  - "8081:80"
  - "25:25"
  - "465:465"
  - "587:587"
  - "993:993"
  - "995:995"

② volumes / healthcheck / restart 必须缩进在 lanqin-email 服务下面,别挂到 cloudflared 下面——我实测踩过这个坑:挂错的话 lanqin-email 没挂载数据卷(SQLite 和邮件重建就丢),healthcheck 还会在 cloudflared 容器里永远失败。

③ 在 services: 下追加 cloudflared 服务(Token 稍后从 CF 控制台复制):

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared-lanqin
    command: tunnel --no-autoupdate run --token 你的TunnelToken粘这里
    restart: unless-stopped
    depends_on:
      - lanqin-email

注意 --token 后面带一个空格再跟 Token。我当时复制时把空格一起删掉了,变成 run --eyJh...,cloudflared 把它当未知参数,直接打印 help 退出,查了半天。

第 4 步:在 Cloudflare 建隧道

  1. 进 one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel → Cloudflared,起个名(如 lanqin-email),复制 Token,填进第 3 步 compose 文件的 command 行(替换掉中文占位符)
  2. 隧道配置页 → Public hostnames → Add public hostname:Subdomain 填 mail,Domain 选你的域名;Service Type 选 HTTP,URL 填 lanqin-email:80(同一 compose 网络内直接用服务名解析)
  3. Save。Cloudflare 会自动建好 mail.example.com 的 CNAME,不用手动加 DNS
  4. 给邮箱单独建一条隧道,别复用别的项目的 Token

第 5 步:启动

cd ~/LanQin-Email/deploy
docker compose pull
docker compose up -d
docker compose logs -f lanqin-email     # 看主容器启动,api/dovecot/rspamd/postfix/nginx 都 RUNNING 后 Ctrl+C
docker compose logs -f cloudflared      # 看到 "Registered tunnel connection" = 隧道通了
docker compose ps                        # lanqin-email 状态变 healthy(约 1~2 分钟)

pull 报 unauthorized 的话:去 GitHub 把作者 lanqin996 的 Package 可见性设为 Public,或先 docker login ghcr.io 再 pull。

第 6 步:验证

ss -tlnp | grep -E ':(25|465|587|993|995) '   # 5 个邮件端口都有 LISTEN = 正常
curl -s http://127.0.0.1:8081/readyz          # 返回 ok:true = 各组件正常

浏览器打开 https://mail.example.com,看到 LanQin 登录页 = 隧道 + Web 全通。

第 7 步:给邮件端口配真实证书

465 / 587 / 993 / 995 不走隧道,容器必须自己有公网客户端信任的证书,否则第三方客户端会报证书是 localhost。注意 Cloudflare Origin Certificate 不行——邮件客户端不认它。80 端口被占着,用 DNS-01 方式申请 Let’s Encrypt:

apt update && apt install -y certbot python3-certbot-dns-cloudflare
mkdir -p /root/.secrets && chmod 700 /root/.secrets

去 Cloudflare 后台 → 右上角头像 → My Profile → API Tokens → Create Token,用 Edit zone DNS 模板,Zone Resources 选你的域名,生成 Token(只显示一次,复制好):

nano /root/.secrets/cloudflare.ini
dns_cloudflare_api_token = 粘你的Token
chmod 600 /root/.secrets/cloudflare.ini
certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d mail.example.com -d mx.example.com \
  --non-interactive --agree-tos -m [email protected]

证书会落在 /etc/letsencrypt/live/mail.example.com/。两个域名都要加进证书:客户端连的是 mx.example.com,只含 mail.example.com 会报域名不匹配。

挂进容器。docker-compose.yml 的 volumes 段取消注释并改成(容器内路径必须是 /certs,和 .env 里对应;直接取消原注释会挂到 /etc/letsencrypt,容器内就没有 /certs,API 会回退到 snakeoil 自签证书):

      - /etc/letsencrypt:/certs:ro

.env 加四行(四个一起配,只配证书不加后两行、465/587 的提交监听可能起不来):

LANQIN_TLS_CERT_FILE=/certs/live/mail.example.com/fullchain.pem
LANQIN_TLS_KEY_FILE=/certs/live/mail.example.com/privkey.pem
LANQIN_SUBMISSION_ADDR=:587
LANQIN_SUBMISSION_TLS_ADDR=:465
cd ~/LanQin-Email/deploy
docker compose up -d --force-recreate
sleep 30
docker compose exec lanqin-email ls /certs/live/mail.example.com/
echo | openssl s_client -connect 127.0.0.1:465 2>/dev/null | openssl x509 -noout -subject -issuer

subject=CN = mail.example.com、issuer 是 Let’s Encrypt,且日志里不再出现 snakeoil / submission disabled = 证书生效。

自动续期(证书 90 天过期,certbot 自带续期定时器;加个 hook 让续期后重启容器):

mkdir -p /etc/letsencrypt/renewal-hooks/deploy
nano /etc/letsencrypt/renewal-hooks/deploy/restart-lanqin.sh
#!/bin/bash
cd /root/LanQin-Email/deploy && docker compose restart lanqin-email
chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-lanqin.sh

第 8 步:后台加域名、配 DNS

https://mail.example.com/admin?section=domains
  1. 点添加域名,输入 example.com
  2. 点进域名,先点 DKIM 生成密钥
  3. 点 DNS 按钮,面板会列出每条记录的确切值。去 Cloudflare 的 DNS 页照抄(mx 那条 A 记录必须是灰云 DNS-only,别开橙云):

A 记录:mx.example.com → 你的 VPS 公网 IP(灰云)
MX 记录:名称 example.com,值 mx.example.com,优先级 10(优先级填到“优先级”框,别把数字写进主机名框)
SPF:名称 example.com,TXT 值 v=spf1 mx -all
HELO SPF:名称 mx.example.com,TXT 值 v=spf1 a -all
DKIM:名称类似 lanqin._domainkey.example.com(以面板为准),TXT 值 v=DKIM1; k=rsa; p=…(完整粘贴)
DMARC:名称 _dmarc.example.com,TXT 值粘面板给的

  1. 等 3~5 分钟 DNS 生效,点面板里的检测,全绿再往下

有条件的话,去 VPS 商家后台把公网 IP 的 PTR 反向解析设为 mx.example.com,对发信信誉加分很大(见第 10 步)。

第 9 步:建邮箱、实测收发

先理解 LanQin 的账号模型(我在这儿翻过源码才搞明白):登录只查 users 表,mailboxes 表里的邮箱不能单独登录。新建的邮箱都有个“归属用户”,谁是归属用户,谁就用自己的账号密码登录,进 Webmail 后左上角多邮箱切换到新邮箱。想让别人单独用:先在“用户管理”建用户(设密码),再把邮箱的归属用户改成他。

  1. 管理后台 → 邮箱账号 → 新建测试邮箱(如 [email protected],归属用户选你自己)
  2. 后台 SMTP 标签页 → 测试发送,发到你的 Gmail/QQ(用内置 Postfix 的话中继用户名/密码留空)
  3. Webmail 切到 [email protected] 给 Gmail 发一封,再从 Gmail 回一封,确认双向都通
  4. 第三方客户端(手机/Thunderbird):IMAP 服务器 mx.example.com:993(SSL),SMTP mx.example.com:465(SSL)或 587(STARTTLS),用户名填完整邮箱地址,密码填创建邮箱时设的邮箱密码
  5. 手机登一次 Webmail,回后台看审计日志里的登录 IP:是真实公网 IP 说明 TRUSTED_PROXY_COUNT=1 生效;是 172.x 开头说明配错了

第 10 步:测分

用测试邮箱给 mail-tester.com 显示的随机地址发一封,看报告。我的首次成绩 8.9/10,唯一扣分是 RDNS_NONE -1.274(PTR 没设);在 VPS 商家后台把 PTR 设为 mx.example.com 后复测 10/10。

另外去 mxtoolbox.com 查下你的 VPS IP 在不在黑名单里。

收尾:备份与日常命令

定期备份(data 是 SQLite 库,mail 是所有邮件,dkim 是签名私钥):

cd ~/LanQin-Email/deploy
tar -czf ~/lanqin-backup-$(date +%F).tgz data mail dkim .env docker-compose.yml

日常:

cd ~/LanQin-Email/deploy
docker compose logs -f lanqin-email          # 看日志
docker compose pull && docker compose up -d  # 更新版本
docker compose down                          # 停止

附:快速搬家(换 VPS)

整机迁移就四样东西:deploy/ 目录(SQLite 数据库 + Maildir 邮件 + 配置)、证书、cloudflared。挑个低峰时段操作,旧机 down 到新机 up 之间是停机窗口,对方发来的邮件会在队列里重试,丢不了。

1. 旧 VPS 打包

cd ~/LanQin-Email/deploy && docker compose down
cd ~ && tar -czf lanqin-migrate.tgz LanQin-Email/ /etc/letsencrypt
scp lanqin-migrate.tgz root@新VPS_IP:~/

2. 新 VPS 恢复

tar -xzf ~/lanqin-migrate.tgz -C /
cd ~/LanQin-Email/deploy && docker compose up -d

证书是打包带过去的,省得重申请;certbot 续期 hook 记得在新机上配好。

3. 收尾

  1. cloudflared 在新机上用同一个 token启动(mail.example.com → lanqin-email:80)
  2. DNS 把 mx.example.com 的 A 记录改成新 IP(保持灰云/DNS-only)
  3. 新厂商后台把新 IP 的 PTR 设成 mx.example.com
  4. 检查 SPF:如果写死了旧 IP(ip4:192.0.2.1)就改成新的;DKIM/DMARC 不用动,密钥跟着数据库走

4. 验证

等 DNS 生效后,mail-tester 再跑一遍,确认 10/10。

踩坑总结

  1. 一个主机名不能既走隧道又做邮件:mail.example.com 被隧道 CNAME 占了,MX 必须指独立的 mx.example.com(A 记录灰云直连)
  2. TRUSTED_PROXY_COUNT=1 不是 2:cloudflared 透传 XFF 不追加,上游文档定值就是 1
  3. 465/587 要四个变量一起配:TLS_CERT_FILE + TLS_KEY_FILE + SUBMISSION_ADDR=:587 + SUBMISSION_TLS_ADDR=:465,缺一不可
  4. compose 里 volumes 缩进:必须挂在 lanqin-email 下,挂错到 cloudflared 会导致数据卷丢失 + healthcheck 永失败
  5. 证书挂载路径:取消注释时容器内路径改成 /certs,和 .env 的 /certs/live/... 对应
  6. cloudflared 的 --token 后面有空格:误删会变成未知参数,容器打 help 退出
  7. MX 记录填法:Cloudflare 表单里“邮件服务器”只填主机名,优先级填到“优先级”框,别把 10 写进主机名
  8. 邮箱不能单独登录:登录的是用户账号,邮箱靠归属用户 + 多邮箱切换使用