在VPS上自建一次性邮箱收件箱
用Docker Compose在VPS部署Mailpit,捕获预发布应用发往任意地址的测试邮件,通过Web界面查看,并阻止误发给真实客户。
一次性电子邮件收件箱是什么
一次性电子邮件收件箱是一个小型 SMTP(简单邮件传输协议)服务器,可接受发往任意地址的邮件,但不会投递任何邮件。您的预发布应用会将邮件发送到该服务器,而不是真实的邮件服务商,所有邮件都会停留在那里。您可以在 Web 界面中查看收到的邮件。因此,即使收件人列表错误或模板损坏,也不会产生任何成本,因为邮件不会离开该收件箱。
本指南使用 Docker Compose,在一台 VPS 上部署一个此类收件箱。Mailpit 负责接收所有邮件。其 SMTP 监听器绑定到只有您的应用可以访问的位置;其 Web 界面通过 nginx、传输层安全协议(TLS)和密码进行保护;保留上限可防止邮箱占满磁盘。如果您还不熟悉 Compose,请参阅 VPS 的 Compose 基础知识,其中介绍了本指南所使用的文件布局。
最终得到的是测试工具,而不是邮件服务器。它没有账户,不会投递邮件,也不提供垃圾邮件过滤。面向真实用户的真实邮箱需要使用 完整的邮件服务器,例如 Mailcow,实现起来要复杂得多。
Mailpit、Inbucket 与 MailHog:该运行哪种邮件接收器
有三款工具可完成此任务。它们的主要区别在于维护状态、监听端口以及接收邮件后的处理能力。以下版本均于 2026 年 10 月核实。
MailHog (mailhog/mailhog) 使用 1025 端口接收 SMTP 流量,并在 8025 端口提供 Web 界面。它目前仍可运行。其默认分支自 2022 年 8 月起未再提交代码,且问题追踪器中仍有超过 200 个未解决的议题,这意味着在测试路径中运行该工具将涉及未修复的依赖项。请勿在其上开展新工作。
Inbucket (inbucket/inbucket) 使用 2500 端口接收 SMTP 流量,9000 端口提供 Web 界面,1100 端口提供 POP3 (邮局协议第 3 版) 服务。3.2.0 版本于 2026 年 10 月 10 日发布。它将邮件存储为 /storage 下的文件并自动清理:镜像默认设置了 INBUCKET_STORAGE_RETENTIONPERIOD=72h 和 INBUCKET_STORAGE_MAILBOXMSGCAP=300。当测试需要使用 POP3 客户端库而非 HTTP 调用来获取邮件时,请选择此工具。
Mailpit (axllent/mailpit) 使用与 MailHog 相同的 1025 和 8025 端口,因此无需修改应用配置即可替换 MailHog。1.31.5 版本于 2026 年 10 月 11 日发布;1.30 系列已于 8 月在 1.30.7 版本结束。它在二进制文件中集成了本指南所需的功能:用于 Web 界面和 API (应用程序编程接口) 的密码文件、邮件数量上限、邮件保留时长上限以及收件人过滤器。本指南后续内容将使用 Mailpit。
通配接收的工作原理,以及为什么不涉及 DNS
您的应用不会在此处查询邮件的投递位置。您向它提供主机和端口,它建立 TCP 连接,然后声明 RCPT TO:<anyone@example.test>。Mailpit 会接受该收件人地址,不论其内容是什么,存储邮件,但不会转发邮件。系统不会解析该域名,因此即使 .test 是域名系统(DNS)中不存在的保留名称,example.test 仍然可以正常工作。
这就是完整的工作机制,也是收件箱默认安全的原因。整个过程不涉及 MX(邮件交换)记录,不会尝试投递,也不会有任何邮件到达真实用户。
将预发布应用指向邮件接收器
当应用作为容器运行在同一个 Compose 项目中时,将应用的 SMTP 主机设置为 mailpit;当应用运行在宿主机上时,设置为 127.0.0.1。将端口设置为 1025,关闭 TLS,并将用户名和密码留空。Mailpit 接受匿名邮件。
某些框架在没有凭据时不会发送邮件。MP_SMTP_AUTH_ACCEPT_ANY=1 使 Mailpit 接受任意用户名和密码,MP_SMTP_AUTH_ALLOW_INSECURE=1 允许在未加密连接上使用 PLAIN 和 LOGIN 机制。这两个设置在此处是安全的,原因是监听器无法从互联网访问,下面的部署配置会强制实现这一点。
建议从第一天起就设置 MP_SMTP_ALLOWED_RECIPIENTS。它接受一个正则表达式,并拒绝所有不匹配的收件人。将其指向测试域名。这样,即使预发布数据库中仍保存真实客户地址,应用日志中也会显示明确的失败,而不是让邮件静默进入接收器。
Docker Compose 文件
首先创建目录并为 Web 界面创建密码文件。htpasswd -B 会写入 bcrypt 哈希值,Mailpit 同时支持读取 bcrypt 和明文密码。
mkdir -p ~/mailpit/data
cd ~/mailpit
sudo apt update && sudo apt install -y apache2-utils
htpasswd -B -c data/ui-auth qa写入 compose.yaml:
services:
mailpit:
image: axllent/mailpit:v1.31
container_name: mailpit
restart: unless-stopped
ports:
- "127.0.0.1:8025:8025"
- "127.0.0.1:1025:1025"
volumes:
- ./data:/data
environment:
MP_DATABASE: /data/mailpit.db
MP_MAX_MESSAGES: 2000
MP_MAX_AGE: 14d
MP_UI_AUTH_FILE: /data/ui-auth
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
MP_SMTP_ALLOWED_RECIPIENTS: '@example\.test$$'双美元符号并非笔误。Compose 会将单个 $ 视为变量展开的起始符,因此 $$ 是将字面量美元符号传递给容器的方法。正则表达式到达 Mailpit 时即为 @example\.test$。
启动服务并检查健康状态:
docker compose up -d
docker compose psSTATUS 列应显示 Up ... (healthy)。该镜像内置了健康检查,每 15 秒运行一次 /mailpit readyz,因此如果容器状态保持为 starting 或变为 unhealthy,通常意味着容器内的 8025 端口未提供服务。该检查会从 MP_ 环境变量中读取设置,但不会读取命令行标志,这也是本指南将所有配置置于 environment: 下的原因。如果改用标志传递选项,检查可能会在 Mailpit 正常运行的情况下报告 unhealthy。在进行其他更改前,请阅读 docker compose logs mailpit。
两个已发布的端口都带有地址,该地址即为安全控制手段。容器内部 Mailpit 监听在 0.0.0.0,这是正常的,因为容器拥有独立的网络命名空间。映射的左侧决定了外部访问者是谁。写入 8025:8025 后,Docker 会绑定宿主机上的所有地址,包括公网地址。
如果你的暂存应用与此服务位于同一个文件中,请完全删除 1025 映射,并将应用指向主机名 mailpit 的 1025 端口。共享 Compose 网络的容器可以直接相互访问,因此 SMTP 端口完全无需暴露在宿主机上。Compose 网络如何解析服务名称 涵盖了此查找机制。
发送一条消息并确认其已存储
python3 - <<'EOF'
import smtplib
from email.message import EmailMessage
m = EmailMessage()
m["From"] = "staging@example.test"
m["To"] = "anyone@example.test"
m["Subject"] = "Mailpit smoke test"
m.set_content("If this appears in the web interface, the sink works.")
with smtplib.SMTP("127.0.0.1", 1025) as s:
s.send_message(m)
EOF脚本成功时不会输出任何内容。通过 API 确认消息已存储:
curl -s -u qa:yourpassword http://127.0.0.1:8025/api/v1/messages该命令会返回包含已存储消息的 JSON。去掉 -u 标志后,同一个请求会被拒绝,因为 MP_UI_AUTH_FILE 同时保护 API 和 Web 界面。任何读取收件箱的测试也必须发送这些凭据。
Python 脚本返回 ConnectionRefusedError,表示 127.0.0.1:1025 上没有进程监听。如果移除了 SMTP 映射,这是预期结果。此时必须从同一 Compose 网络中的容器运行检查。
通过 nginx 发布带有密码保护的 Web 界面
该界面目前仅在回环地址上响应。nginx 负责终止 TLS 并要求输入密码,之后请求才会到达后端。
sudo htpasswd -B -c /etc/nginx/mailpit.htpasswd qaserver {
listen 443 ssl;
server_name mail-test.example.com;
ssl_certificate /etc/letsencrypt/live/mail-test.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail-test.example.com/privkey.pem;
auth_basic "mailpit";
auth_basic_user_file /etc/nginx/mailpit.htpasswd;
location / {
proxy_pass http://127.0.0.1:8025;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}执行语法检查后,使用 sudo nginx -t && sudo systemctl reload nginx 重新加载配置。如果是首次配置代理,建议阅读 反向代理配置块中各指令的作用。
请在 nginx 配置文件和 data/ui-auth 中使用相同的用户名和密码。nginx 会将浏览器的 Authorization 标头转发给上游,因此匹配的凭据只需一次提示即可通过两层验证。如果凭据不一致,浏览器保存的一组凭据将无法通过第二次验证。
Upgrade 和 Connection 标头并非装饰。Mailpit 通过 WebSocket 将新邮件推送到已打开的页面,如果代理运行在 HTTP/1.1 且缺少这些标头,则无法升级连接。此时页面虽然能正常加载,但不会更新:即使邮件已到达且 API 已显示,列表界面仍会保持静止,直到你手动刷新。
请保留两道防线。nginx 密码保护公网地址,而 MP_UI_AUTH_FILE 则保护 8025 端口本身。这一点至关重要,因为只要保留策略未过期,该界面中就包含测试应用生成的所有密码重置链接,这些链接均可被读取。
切勿让接收端成为开放中继
开放中继是指一种 SMTP 服务器,它接收来自任何人的邮件并将其转发到任何目的地。垃圾邮件发送者会持续扫描此类服务器,一旦发现您的地址存在开放中继,将导致滥用投诉并引发账户停用。
Mailpit 默认并非开放中继,因为它默认不会向外发送任何邮件。它具备两项出站功能,但在您配置之前,这两项功能均处于关闭状态。MP_SMTP_RELAY_CONFIG 指向中继配置文件并启用界面中的“释放”(release)操作,该操作会将您选定的邮件通过该服务器发送;自动中继功能需要额外开关 MP_SMTP_RELAY_ALL(默认为 false)或 MP_SMTP_RELAY_MATCHING。通过 MP_SMTP_FORWARD_CONFIG 或 MP_SMTP_FORWARD_* 变量设置的转发功能,会将每封收到的邮件复制到您指定的固定地址。保持这些选项未设置是出于安全考虑的有意选择。
有两种方式会丧失这一特性。开启 MP_SMTP_RELAY_ALL 会将每封收到的邮件发送给原始收件人并忽略 MP_SMTP_ALLOWED_RECIPIENTS,此时若将 SMTP 端口暴露给公网,您就构建了一个可用的开放中继。MP_SMTP_RELAY_MATCHING 对于任何匹配其模式的收件人也会执行同样操作。若在未配置中继的情况下暴露端口,外部人员虽然无法通过您的服务器发送邮件,但他们可以填满您的存储空间,并将内容注入到您的团队所信任的界面中。如果开启了转发功能,他们的邮件还会进入接收副本的任何收件箱。
Docker 主机上的陷阱在于防火墙。发布端口会使 Docker 将其自身的规则写入 nat 表,发往容器的流量会在 ufw (uncomplicated firewall) 规则生效前被匹配。sudo ufw deny 1025/tcp 会报告成功,但实际上并未做出任何更改。为什么 Docker 发布端口会绕过 ufw 详细介绍了链的顺序。
解决方法是在映射中指定地址,而非依赖防火墙规则。请检查实际绑定的地址:
sudo ss -ltnp | grep -E ':(1025|8025)'正常的输出应显示 127.0.0.1:1025 和 127.0.0.1:8025。如果某行显示 0.0.0.0:1025,则意味着映射丢失了地址,接收端正在监听公网。从另一台机器测试时,nc -vz mail-test.example.com 1025 应当超时或被拒绝。
当应用程序位于不同的服务器上时,请勿通过开放 1025 端口来连接两者。应将两台机器置于私有网络或 VPN 隧道中,并将映射绑定到该接口地址。
仅在需要接收真实邮件时发布 MX 记录
MX(邮件交换)记录用于告知其他邮件服务器,哪个主机负责接收该域名的邮件。不设置 MX 记录并不能完全阻止邮件进入:根据 RFC 5321 第 5.1 节的规定,当域名没有 MX 记录时,发送方服务器会回退到该域名的地址(A 或 AAAA)记录,而你用于 Web 界面的主机名通常已配置了此类记录。真正阻止互联网邮件进入的原因是,服务器上没有任何进程在监听 25 端口。收件箱中仅包含你自己的应用程序提交的邮件,这正是测试邮箱的用途。
接收真实邮件意味着需要满足以下条件:DNS 记录将发送方服务器引导至该主机(MX 记录或回退到的地址记录)、Mailpit 正在监听 25 端口(MP_SMTP_BIND_ADDR=0.0.0.0:25),且该端口处于开放状态。一旦满足这些条件,你实际上就在为该域名的所有地址运行一个公共的“全收”(catch-all)邮箱。请务必明确以下后果:
- 记录发布后的几天内就会出现垃圾邮件,因为采集器会扫描 DNS。字典攻击随后会尝试各种常见用户名,并为每次尝试存储一封邮件。
- 来自陌生人的附件会落入你的磁盘并长期留存。没有任何过滤机制,因此来自未知发件人的归档文件会与你自己的测试邮件混在一起。
- 任何获知该域名的人都可以使用该域名的地址注册第三方服务,确认邮件会被投递到你的服务器。如果密码防线失守,这些账户将归属于任何能读取收件箱的人。
- 保留期限限制将不再是简单的清理工作,而成为必须承担的负载,因为邮件量已不再由你控制。
如果你需要接收真实邮件以进行投递能力测试,请使用专门的子域名,保持 MP_MAX_AGE 的配置简短,并将其中所有内容视为公开信息。如果你需要人们依赖的正式邮箱,请改用具备过滤和备份功能的专业邮件服务器。
保留策略:无限制的收集器如何填满磁盘
Mailpit 默认保留 500 封邮件,并定期删除超出此数量的最旧邮件。MP_MAX_MESSAGES: 0 会关闭此上限,若未设置 MP_MAX_AGE,则不会自动删除任何邮件,这就是收集器在无人察觉的情况下填满磁盘的原因。MP_MAX_AGE 可增加时间限制,该设置可与邮件数量上限配合使用或单独使用,时间单位为小时或天,格式为 36h 或 14d。
MP_DATABASE 决定数据是否持久化。若不启用该选项,Mailpit 会将数据写入临时文件,进程退出时文件即被删除,因此每次重启都会清空收件箱。启用后,邮件在重启后依然存在,且文件会持续增长。
附件是占用空间的主要原因。如果每晚有一个作业向 300 个测试地址发送 2 MB 的 PDF 报告,每晚就会产生 600 MB 的数据,仅靠邮件数量上限无法及时清理。请根据磁盘上其他服务的使用情况规划空间,因为像 PhotoPrism 或 Immich 这样占用大量媒体文件的邻居应用,通常已经占用了小型 VPS 磁盘的大部分空间。
du -h ~/mailpit/data/mailpit.db
df -h /在 CI 运行间隙清空存储,而不是等待上限触发:
curl -s -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messagesInbucket 通过 INBUCKET_STORAGE_RETENTIONPERIOD(镜像中默认为 72h)和 INBUCKET_STORAGE_MAILBOXMSGCAP(300)处理相同的问题。无论运行哪种服务,请在首个测试套件指向它之前设定好限制。
从测试套件读取收件箱
GET /api/v1/messages列出当前存储的内容,GET /api/v1/message/{ID}返回一封邮件及其各个部分和邮件头,GET /api/v1/search用于筛选,DELETE /api/v1/messages清空存储。您所运行版本的交互式文档位于 http://127.0.0.1:8025/api/v1/。
实用的测试流程是:发送一封邮件,循环轮询直到邮件出现,检查主题和其中的链接,然后删除全部内容。应使用短时间重试循环进行轮询,而不是只发送一次请求。应用通过后台 worker 将邮件加入队列时,send 调用可能已经返回,但 Mailpit 尚未收到邮件。自托管 API 测试和模拟工具中也常见相同模式;这通常是完全不接触生产环境的 staging 环境的另一部分。
FAQ
自托管的一次性邮箱收件箱是开放中继吗?
只要不开启中继功能,就不是。默认情况下,Mailpit 不会转发任何邮件:MP_SMTP_RELAY_CONFIG 仅允许手动释放您选定的邮件,自动中继还需要额外配置 MP_SMTP_RELAY_ALL 或 MP_SMTP_RELAY_MATCHING。保持这些选项未设置,即使陌生人连接到 1025 端口,也无法通过您的服务器发送邮件。但他们仍可能填满您的存储空间,因此请将 SMTP 端口绑定到仅您的应用可访问的地址。在 Compose 中将其发布为 1025:1025 会绑定所有主机地址,而 sudo ufw deny 1025/tcp 无法将其关闭,因为 Docker 自身的 NAT 规则优先级更高。
我的测试域名需要 MX 记录吗?
仅当您希望接收来自公网的邮件时才需要。不配置 MX 记录本身并不能起到锁定作用。如果没有 MX 记录,发送服务器会回退到域名的 A 或 AAAA 记录,因此真正阻止公网邮件进入的原因是 25 端口上没有服务在监听。此时收件箱仅保存您自己的应用通过 SMTP 提交的邮件。如果将 DNS 指向该服务器并开放 25 端口,您就运行了一个公开的“全收”邮箱:几天内就会收到垃圾邮件,字典攻击会为每次尝试存储一条消息,陌生人发送的附件也会在没有过滤的情况下存入您的磁盘。
为什么消息列表只有在刷新页面时才会更新?
Mailpit 通过 WebSocket 将新邮件推送到打开的页面。如果 Nginx 的 location 块缺少 proxy_http_version 1.1 以及 Upgrade 和 Connection 请求头,连接就无法升级,导致页面正常加载后即停止响应。邮件实际上已经到达,API 也能返回数据,这就是收件箱看起来陈旧而非彻底损坏的原因。添加这些配置行,重载 Nginx,然后刷新页面即可。
如何防止收件箱填满磁盘?
将 MP_MAX_MESSAGES 保持为一个具体数值并添加 MP_MAX_AGE。默认上限是 500 条消息,将其设置为 0 会禁用基于数量的删除,因此除非设置了 MP_MAX_AGE,否则不会清理任何内容,这就是带有附件的“全收”邮箱空间悄悄耗尽的原因。MP_MAX_AGE 接受小时或天数作为单位,例如 36h 或 14d。在 CI 环境销毁时使用 curl -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messages 清除存储,因为该 API 使用与 Web 界面相同的凭据。Inbucket 通过 INBUCKET_STORAGE_RETENTIONPERIOD(72小时)和 INBUCKET_STORAGE_MAILBOXMSGCAP(300条)实现相同功能。
我应该运行 Mailpit、Inbucket 还是 MailHog?
截至 2026 年 10 月,新项目建议使用 Mailpit。MailHog 虽然仍可运行,但其默认分支自 2022 年 8 月起就没有任何提交,因此它包含未修复依赖项的软件版本。Inbucket 处于活跃维护状态(3.2.0,2026 年 10 月),当测试需要 POP3 时它是更好的选择,因为 Mailpit 的 POP3 服务器仅在您提供凭据后才会启动,凭据可通过密码文件或 MP_POP3_AUTH 变量设置。Mailpit 使用与 MailHog 相同的 1025 和 8025 端口,因此替换 MailHog 只需在 Compose 文件中修改镜像名称即可。