博客需要一个邮件服务器来发提醒邮件,遂调查常见方案。
一是使用托管服务。所谓托管,就是通过设置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记录添加一些“路标”。
在我的配置中,使用了mail.xinalin.com来指示邮件服务器。因此我们需要添加一条指向邮件服务器IP的A记录来实现解析。
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的设置并不在你的域名管理处,而是在你的主机管理处。普通DNS查询域名返回IP,rDNS则是查询IP返回域名。当服务器收到邮件时,会通过rDNS查询来源服务器的IP,比对返回结果与HELO。不匹配的邮件会被认为是可疑的。
如果你的主机管理面板没有设置rDNS的地方,你可能需要咨询主机提供商客服。在这里,我将承载我邮件服务器的主机的rDNS设为mail.xinalin.com,和上文设置的A记录遥相呼应。
SPF记录用于指定哪些服务器是指定的发信服务器,以阻止伪造的信件。
向xinalin.com添加一条值为v=spf1 mx ~all的TXT记录,我们就完成了SPF配置。这条记录的含义是:
- 这是一条需要使用spf1语法解析的记录
- 允许MX记录指向的服务器发信(在这里是
mail.xinalin.com) - 对其余所有来源软拒绝(标记为可疑邮件)
在设置这条记录后,仅有来源于MX的发信能够通过SPF检查。如果你需要从多个服务器发信,可以按SPF记录语法加入所需的其他服务器。
相较于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用于指导收件人应当如何处理未通过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吧~
如果你的分数并非满分,则可以展开有扣分的项目查看具体原因,解决了对应的问题后再试一次。
千辛万苦设置完了基础部分,不更进一步岂不是白辛苦了。接下来的部分都是博主根据自身需求配置的,如果你有这里未提到的需求也可以去翻翻官方FAQ。
./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 问题解决,虽然不太优雅,但是简单高效。考虑到这个邮件服务器并不会有多少账户,手动添加也是可以接受的。
刚刚吃过了真实地址优先级过低的亏,接下来我们占它点便宜。
./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,符合预期。
docker-data/dms/config/postfix-regexp.cf可以配置正则表达式别名,从而实现更复杂的规则。
邮件加密可以实现邮件本地存储的透明加解密,在多用户情况下保护隐私。
fail2ban可以自动ban掉试图暴力破解密码的IP。
但这些功能博主并不需要,也就限于篇幅未在此提及,有需要可以自行探索。
这一整套折腾下来,确实让我对电子邮件系统的了解深入了不少。
这套方案还有相当多的地方属于能用就行,例如autodiscover和SRV记录之类的都没有涉及。
至于改进嘛,大概是不会有了。