Docker自建邮件服务器
博客需要一个邮件服务器来发提醒邮件,遂调查常见方案。
一是使用托管服务。所谓托管,就是通过设置 MX SPF DKIM 等记录,将自己域名的邮件收发权力指派给托管方提供的服务器。而作为用户的我们,可以通过各种邮件交换协议及网页应用连接到该服务器来收发信。免费托管服务通常都有一定的限制,例如限制发信数量或账户数量等,其中部分免费服务还需要绑定信用卡才能使用。
二是自建邮件服务器。我了解到的可以装进 docker 的方案如下:
poste:可以免费搭建,同时提供付费支持。所有服务集成在同一个容器中,使用docker run启动。mailu:使用 python 编写的轻量化开源方案,一个服务一个容器,使用docker compose管理。mailcow:开源方案,更适合多用户,项目相当完善,但是资源消耗高。一个服务一个容器,使用docker compose管理。
一开始博主想选择 mailcow 搭建,但是最小 6GB RAM+1GB SWAP 直接强迫博主收回了这个想法。实际上即使是轻量化的 mailu,在关闭反病毒功能的情况下也需要 1GB RAM+1GB SWAP。我等小鸡还是不去挑战重量级服务比较好。
最终博主选择了 docker-mailserver来搭建。在关闭反病毒的情况下,仅 512MB 的 RAM 需求能无痛地塞进博主的服务器里。需要注意的是,这个方案并不提供网页应用。好在博主仅需要常规的邮件交换协议,用户界面交给邮件客户端来实现就好。
准备工作
docker-mailserver 也使用 docker compose 来管理,自然也需要相应的前置安装。如果你尚未安装 docker 且使用主流发行版,可以使用官方的安装脚本:
curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh
确认安装:
docker version docker compose version
确认端口未被占用:
netstat -unltp | grep -E -w '25|143|465|587|993'
无返回则代表端口均空闲。
确认 25 端口开放:
telnet mx1.qq.com 25
若显示包含 Connected to mx1.qq.com. 则未被封锁。按 Ctrl+],输入 quit 退出 telnet。
启动前配置
mkdir ~/mailserver && cd ~/mailserver DMS_GITHUB_URL='https://raw.githubusercontent.com/docker-mailserver/docker-mailserver/master' wget "${DMS_GITHUB_URL}/docker-compose.yml" wget "${DMS_GITHUB_URL}/mailserver.env" wget "${DMS_GITHUB_URL}/setup.sh" chmod a+x ./setup.sh
使用文本编辑器新建 docker-compose.override.yml,我们将在这里写入自定义配置。这里的设置值可以覆盖 docker-compose.yml 和 mailserver.env 内的配置,便于统一管理。
services: mailserver: hostname: mail domainname: xinalin.com ports: - "110:110" # POP3 - "995:995" # POP3 (with TLS) volumes: - mailserver_ssl:/etc/mailserver/ssl environment: - OVERRIDE_HOSTNAME=mail.xinalin.com - ENABLE_POP3=1 - SSL_TYPE=manual - SSL_CERT_PATH=/etc/mailserver/ssl/full.pem - SSL_KEY_PATH=/etc/mailserver/ssl/key.pem labels: - sh.acme.autoload.domain=mail.xinalin.com acme.sh: image: neilpang/acme.sh container_name: acme.sh_mail command: daemon volumes: - ./acme.sh:/acme.sh - /var/run/docker.sock:/var/run/docker.sock environment: - DEPLOY_DOCKER_CONTAINER_LABEL=sh.acme.autoload.domain=mail.xinalin.com - DEPLOY_DOCKER_CONTAINER_KEY_FILE=/etc/mailserver/ssl/key.pem - DEPLOY_DOCKER_CONTAINER_CERT_FILE="/etc/mailserver/ssl/cert.pem" - DEPLOY_DOCKER_CONTAINER_CA_FILE="/etc/mailserver/ssl/ca.pem" - DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/etc/mailserver/ssl/full.pem" - DEPLOY_DOCKER_CONTAINER_RELOAD_CMD="supervisorctl restart all" volumes: mailserver_ssl:
拉取镜像并启动。我们首先来配置 acme.sh,通过 docker 交互式执行打开容器内部 shell。
docker compose up -d docker exec -it acme.sh_mail sh
现在 acme.sh 默认采用的 CA 是 ZeroSSL,需要先注册一个账户。然后根据你所使用的 DNS 服务商对应 API 设置 DNS 挑战所需要的环境变量,可以参考 acme.sh 官方的教程。然后是签发与部署证书,静待流程结束。操作完毕后退出容器 shell。
acme.sh --register-account -m admin@xinalin.com export ... # 根据对应api要求填写 acme.sh --issue --dns dns_porkbun -d mail.xinalin.com acme.sh --deploy -d mail.xinalin.com --deploy-hook docker exit
接下来配置 mailserver 本身。首先需要添加一个邮件账户并设置密码,在未添加账户的情况下 mailserver 会反复重启。
./setup.sh email add admin@xinalin.com
到这里,我们已经配置好了一个最基础的邮件服务器了。
DNS 配置
但是,一家开在无人区的邮局是没有作用的。为了让其他人找到这台邮件服务器,还需要向 DNS 记录添加一些 “路标”。
A/AAAA 记录
在我的配置中,使用了 mail.xinalin.com 来指示邮件服务器。因此我们需要添加一条指向邮件服务器 IP 的 A 记录来实现解析。
MX 记录
MX 记录标记了对本域名负责的邮件服务器地址,以便想给你发信的人找到投递目标。在这里需要添加一条 Host 为 xinalin.com,指向 mail.xinalin.com 的 MX 记录。这条记录含义为 *@xinalin.com 的邮件由 mail.xinalin.com 负责。
测试收信
| TYPE | HOST | ANSWER |
|---|---|---|
| A | mail.xinalin.com | <MAIL_SERVER_IP> |
| MX | xinalin.com | mail.xinalin.com |
有了这两条记录,我们准备好接收信件了。任何人发往 *@xinalin.com 的邮件应当能被正确指引到我们刚刚启动的邮件服务器。向刚刚建立的邮件账户 admin@xinalin.com 发一封测试邮件,然后通过 docker logs mailserver -n 100 查看日志。
可以看到我们自己建立的邮件服务器收到了来自 gmail 的连接。此时,用邮件客户端登录到 admin@xinalin.com,就可以看到这封测试邮件了。
但是如果仔细查看日志,就会发现 mailserver 并不是来者不拒照单全收,而是会通过各种手段检查发信方的身份。当我们发信的时候,收信方往往也会进行同样的甚至更严格的检查。为了不让我们发出的信件被丢进垃圾桶,我们需要一条 rDNS 和一些特殊格式的 TXT 记录证明 “我就是我”。
rDNS
rDNS 的设置并不在你的域名管理处,而是在你的主机管理处。普通 DNS 查询域名返回 IP,rDNS 则是查询 IP 返回域名。当服务器收到邮件时,会通过 rDNS 查询来源服务器的 IP,比对返回结果与 HELO。不匹配的邮件会被认为是可疑的。
如果你的主机管理面板没有设置 rDNS 的地方,你可能需要咨询主机提供商客服。在这里,我将承载我邮件服务器的主机的 rDNS 设为 mail.xinalin.com,和上文设置的 A 记录遥相呼应。
SPF
SPF 记录用于指定哪些服务器是指定的发信服务器,以阻止伪造的信件。
向 xinalin.com 添加一条值为 v=spf1 mx ~all 的 TXT 记录,我们就完成了 SPF 配置。这条记录的含义是:
- 这是一条需要使用 spf1 语法解析的记录
- 允许 MX 记录指向的服务器发信(在这里是
mail.xinalin.com) - 对其余所有来源软拒绝(标记为可疑邮件)
在设置这条记录后,仅有来源于 MX 的发信能够通过 SPF 检查。如果你需要从多个服务器发信,可以按 SPF 记录语法加入所需的其他服务器。
DKIM
相较于 SPF,DKIM 是更进一步的身份验证。DKIM 使用了与 SSL 证书类似的非对称机制,但通过 TXT 记录取代了 CA 的位置。我们通过一条符合 DKIM 语法的 TXT 记录发布公钥,在发信时附上私钥的签名。收信人通过 DNS 查询获得公钥验签,以确认发信人权威性。
在设置记录前,需要先生成用于 DKIM 的密钥对。在这里指定使用 2048 位长度,因为默认的 4096 位可能存在兼容性问题 [1]。
./setup.sh config dkim keysize 2048 cat docker-data/dms/config/opendkim/keys/xinalin.com/mail.txt
此时你应该看到形如 mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; ...") 的输出,这就是我们需要添加的记录。考虑到部分 DNS 限制 TXT 记录长度,记录被拆成三行。如果你的 DNS 和博主一样不限制,建议将双引号内的值连接成一条后添加,避免出现问题。
根据文件指示,添加一条 host 为 mail._domainkey.xinalin.com,值为 v=DKIM1; h=sha256; k=rsa;p=XXXX 的记录。添加完毕后可以通过 MX Toolbox DKIM Lookup 来验证格式是否正确。
docker compose down && docker compose up -d
重启容器以应用 DKIM 密钥。
DMARC
DMARC 用于指导收件人应当如何处理未通过 SPF/DKIM 认证的邮件。当服务器收到来自本域名却未通过认证的邮件,会按照 DMARC 指示处理可疑邮件并汇报。如果有坏蛋在伪装我们发信,我们可以通过报告察觉到这种行为。
可以使用这个工具来生成适合你的 DMARC。或者直接使用如下片段,修改 ruf 和 rua 为自己的回报地址。
_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc.report@example.com; ruf=mailto:dmarc.report@example.com; fo=0; adkim=r; aspf=r; pct=100; rf=afrf; ri=86400; sp=quarantine"
将引号内的片段作为值,添加一条 host 为_dmarc.xinalin.com 的 TXT 记录。上述工具也可以用于验证你的 DMARC 设置是否正确。
测试发信
| TYPE | HOST | ANSWER |
|---|---|---|
| TXT | xinalin.com | v=spf1 mx include:_spf.porkbun.com ~all |
| TXT | _dmarc.xinalin.com | v=DMARC1; p=quarantine; rua=mailto:dmarc.report@xinalin.com; ruf=mailto:dmarc.report@xinalin.com; sp=quarantine; ri=86400 |
| TXT | mail._domainkey.xinalin.com | v=DKIM1; h=sha256; k=rsa;p=MII…LONG_PUBLIC_KEY…QAB |
加上这三条 TXT 记录和一条 rDNS 记录,我们应当已经有能力证明 “我是我” 这件事。是时候测试一下配置是否正确了。
在这里,博主采用 mail-tester 测试。按照网站的提示,向指定的地址发一封内容看起来很正常的邮件,如果你只发个 test 会因为内容原因被识别为垃圾邮件,博主这里直接找 ChatGPT 给我编了一篇。按下发送键,然后等待测试结果。

我们一直以来的努力没有白费!发出的邮件已经几乎不会被错认为垃圾邮件了,赶快给你的其他邮箱发封 Hello World 吧~
如果你的分数并非满分,则可以展开有扣分的项目查看具体原因,解决了对应的问题后再试一次。例如博主第一次测试就因为犯蠢忘记重启导致 DKIM 签名缺失,被扣了大分。
个性化设置
千辛万苦设置完了基础部分,不更进一步岂不是白辛苦了。接下来的部分都是博主根据自身需求配置的,如果你有这里未提到的需求也可以去翻翻官方 FAQ。
Catch-All
./setup.sh email add info@xinalin.com echo "@xinalin.com info@xinalin.com" >> docker-data/dms/config/postfix-virtual.cf
博主新建了一个邮件账户,然后将所有未匹配到收件人的邮件转投给这个账户。这样在注册一些不得不填个邮箱收验证码的网站的时候,就可以现场编一个以该网站域名为用户名的地址了。万一哪天数据泄露,好歹能死个明白,知道是哪个倒霉蛋的数据库又让人脱了。
给不存在的账户发一封邮件,查看日志,应当能看到:
Mar 25 17:38:42 mail dovecot: auth: passwd-file(nobody@xinalin.com): unknown user ... Mar 25 17:38:42 mail postfix/smtp-amavis/smtp[3452]: A588F404EE: to=<info@xinalin.com>, orig_to=<nobody@xinalin.com>, relay=127.0.0.1[127.0.0.1]:10024, delay=0.37, delays=0.27/0.01/0.01/0.08, dsn=2.0.0, status=sent (250 2.0.0 from MTA(smtp:[127.0.0.1]:10025): 250 2.0.0 Ok: queued as C529740533)
我们发给不存在用户的邮件没有被退信,而是被转给指定的账户了。
但是,事情并没有这么简单。此时尝试给存在的账户发信,也会被转发。这是由于 postfix 具有虚拟高于真实的查找优先级,发向真实账户的邮件没来及匹配真实账户就被 Catch All 规则匹配走了。
解决方法也很简单:既然被优先级抢走了,就用更高的优先级抢回来。对于任何不想被 Catch All 捕获的地址,添加一条指向自己的别名。至于文件内规则的顺序并不重要,别名拥有高于 Catch All 的优先级。例如,我希望 admin@xinalin.com 不被捕获,可以添加别名:
echo "admin@xinalin.com admin@xinalin.com" >> docker-data/dms/config/postfix-virtual.cf
问题解决,虽然不太优雅,但是简单高效。考虑到这个邮件服务器并不会有多少账户,手动添加也是可以接受的。
no-reply
刚刚吃过了真实地址优先级过低的亏,接下来我们占它点便宜。
./setup.sh email add no-reply@xinalin.com echo "devnull: /dev/null" >> docker-data/dms/config/postfix-aliases.cf echo "no-reply@xinalin.com devnull\ndevnull@xinalin.com devnull" >> docker-data/dms/config/postfix-virtual.cf
注册一个别名 devnull 指向 /dev/null,然后将 no-reply 账户的邮件全部转到 devnull。此时此刻我们又要感谢虚拟的高优先级,否则我们无法将发往 no-reply 这个真实账户的信件转发。
devnull@xinalin.com devnull 规则。给 no-reply 发一封邮件,查看日志可以看到:
Mar 25 17:42:36 mail postfix/local[2469]: E0AD040533: to=<devnull@mail.xinalin.com>, relay=local, delay=0.02, delays=0.01/0.01/0/0, dsn=2.0.0, status=sent (delivered to file: /dev/null)
邮件被投递到 /dev/null,符合预期。
…and More?
docker-data/dms/config/postfix-regexp.cf 可以配置正则表达式别名,从而实现更复杂的规则。
邮件加密可以实现邮件本地存储的透明加解密,在多用户情况下保护隐私。
fail2ban 可以自动 ban 掉试图暴力破解密码的 IP。
但这些功能博主并不需要,也就限于篇幅未在此提及,有需要可以自行探索。
结语
这一整套折腾下来,确实让我对电子邮件系统的了解深入了不少。
这套方案还有相当多的地方属于能用就行,例如 autodiscover 和 SRV 记录之类的都没有涉及。
至于改进嘛,大概是不会有了。
评论
发表评论