MailHog:遗留邮件捕获服务如何安全退出
旧项目保留 MailHog 时要固定哪些行为
MailHog 接收 SMTP 邮件,在 Web UI 和 HTTP API 中提供查询、删除与转发操作。默认 SMTP 端口是 1025,UI/API 端口是 8025;存储可以选内存、maildir 或 MongoDB。官方仓库列出了这些能力,正式发布页仍把 v1.0.1 标为最新正式版本。它可以维持旧项目的测试,但这条发行线不足以让新项目假定后续安全修复、多架构镜像和新客户端适配会持续交付。
旧项目需要先查清依赖对象:应用只把 SMTP 指向 MailHog,还是测试代码解析 /api/v2/messages、使用 UI/API 的 Basic Auth、调用转发到真实 SMTP 的 release 功能,或把邮件保存在 maildir/MongoDB。前一种通常只需切换捕获端点;后几种会改变测试断言、数据与权限。新项目可以从 Mailpit开始,保留 MailHog 的项目则应明确维护人和退出条件。
在隔离环境完成一次 SMTP 捕获
以下实验在 Linux 主机的 Bash 中由能使用 Docker Compose 的普通用户执行。需要 Docker Engine、Compose v2、Python 3、curl 和 jq;1025、8025 两个宿主端口须空闲。Docker 访问权限本身接近宿主高权限,不要在不受信的共享主机上把这个实验交给任意用户运行。只使用 .invalid 虚构地址,不连接真实邮件出口。
在空目录建立独立实验;保持同一个终端,以便后续命令复用 LAB_PROJECT:
LAB_DIR="mailhog-compat-${RANDOM}${RANDOM}"
mkdir "$LAB_DIR" && cd "$LAB_DIR"
LAB_PROJECT="$LAB_DIR"把配置保存为当前目录的 compose.yaml。镜像固定到已核对的 v1.0.1;该镜像是 Linux amd64,其他 CPU 架构运行前要先确认仿真能力与实际成本。宿主端口只绑定回环地址,不向局域网公开捕获到的邮件:
services:
mailhog:
image: mailhog/mailhog:v1.0.1
platform: linux/amd64
ports:
- "127.0.0.1:1025:1025"
- "127.0.0.1:8025:8025"
environment:
MH_STORAGE: memory镜像内进程以 mailhog 用户运行;此处无宿主数据目录挂载,因此不会出现 maildir 目录的 UID/GID 写权限问题。若旧项目使用持久化挂载,要另行核对该运行身份对目标目录的读写权限。
先核对 Compose 实际配置及本项目是否已有容器,再启动:
docker compose -p "$LAB_PROJECT" config -q
docker compose -p "$LAB_PROJECT" ps -a
docker compose -p "$LAB_PROJECT" up -d
docker compose -p "$LAB_PROJECT" ps第一次 ps -a 应为空;若已有同名项目,换一个 LAB_PROJECT,不要覆盖。启动后应看到 mailhog 为 Up,127.0.0.1:1025 映射 SMTP,127.0.0.1:8025 映射 UI/API。端口占用或拉取失败时,先看 docker compose -p "$LAB_PROJECT" ps 与 logs mailhog,再核对宿主监听、镜像源、代理和平台;不要改用浮动 latest 绕过版本问题。
从宿主回环地址发送一封只有假数据的邮件;LAB_SUBJECT 使本次消息与旧邮件可区分:
export LAB_SUBJECT="mailhog-${LAB_PROJECT}"
python3 - <<'PY'
import os
import smtplib
from email.message import EmailMessage
message = EmailMessage()
message["From"] = "sender@example.invalid"
message["To"] = "reader@example.invalid"
message["Subject"] = os.environ["LAB_SUBJECT"]
message.set_content("compatibility probe only")
with smtplib.SMTP("127.0.0.1", 1025, timeout=5) as smtp:
refused = smtp.send_message(message)
if refused:
raise RuntimeError(f"SMTP rejected recipients: {refused}")
print(message["Subject"])
PY命令应打印刚设置的 Subject。SMTP 客户端返回成功只表示目标 SMTP 服务器接受了这封邮件,不表示 UI/API 已能检索。用同一标识查询实际消息:
(
set -o pipefail
curl -q --noproxy '*' --fail-with-body -sS \
'http://127.0.0.1:8025/api/v2/messages?limit=50' |
jq -e --arg subject "$LAB_SUBJECT" \
'[.items[]? | select(.Content.Headers.Subject[0] == $subject)] | length == 1'
)正常输出为 true,管道退出码为 0;false 或非零都不能算通过。-q 禁用用户 curl 配置,--noproxy '*' 排除代理,--fail-with-body 阻断 HTTP 错误。固定镜像的 API v2 返回 total、count、start、items,邮件主题位于 Content.Headers.Subject[0]。官方 API v2 Swagger将列表字段写为 messages,但服务端实现和 v1.0.1 实际响应使用 items;维护旧客户端时应以锁定镜像的响应与契约测试为准。/api/v1/messages 则直接返回数组,v1 API 文档还列出单封下载和删除端点,两个版本的响应不能共用同一个反序列化模型。
本例使用内存存储。重启当前项目中的 MailHog 后,待 API 恢复响应,再检查消息已消失:
docker compose -p "$LAB_PROJECT" restart mailhog
ready=0
for _ in {1..10}; do
if curl -q --noproxy '*' --fail-with-body -sS \
'http://127.0.0.1:8025/api/v2/messages' >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1
(
set -o pipefail
curl -q --noproxy '*' --fail-with-body -sS \
'http://127.0.0.1:8025/api/v2/messages' |
jq -e '.total == 0 and (.items | length) == 0'
)这里也应输出 true。若 ready 仍为 0,先看容器状态与日志,不能把连接错误解释为“内存已经清空”;若重启后仍有邮件,应检查是否连到了别的实例,或实际配置已经切成持久化存储。完成后只停止本次项目:
docker compose -p "$LAB_PROJECT" downdown 不会删除目录里的 compose.yaml;本例没有挂载数据卷。保留这个文件作为旧项目回退时的配置输入,或在确认不再需要后按项目文件管理规则处理,不要对共享 Docker 环境执行全局容器或卷清理。
应用接入、存储和权限是三条不同的路径
运行在宿主机的应用使用 127.0.0.1:1025;同一 Compose project 的其他容器使用服务名 mailhog:1025,因为容器内的 localhost 指向应用容器自己。不同 Compose project 默认不共享服务名解析,需要显式设计网络,不能复制 mailhog:1025 就假定可达。邮件测试应触发真实业务发送入口,再按测试独有的 Subject 或收件人轮询 API,断言收件人、主题和必要内容;UI 打开或 .total > 0 都可能读到别的任务留下的邮件。
官方配置表给出 MH_STORAGE=memory|maildir|mongodb、MH_MAILDIR_PATH、MH_MONGO_URI 和 MH_AUTH_FILE。内存模式适合一次性测试,重启即清空;maildir 需要可写目录与清理规则;MongoDB 引入独立数据库、凭证、备份和恢复。切换模式前先决定哪些历史邮件真的要保留,验证重启、磁盘占用和敏感数据保留周期。不要把旧 maildir/MongoDB 目录直接挂给 Mailpit:它使用自己的数据库格式,文件复制不会迁移 API 行为。
UI/API 能读取邮件内容、验证码和重置链接;MH_AUTH_FILE 是 UI/API 的 Basic Auth 文件,不是 SMTP 入站鉴权。回环绑定只能限制本机以外的直连,同机其他进程仍能访问。共享测试环境需要另设网络隔离、身份验证和数据脱敏;不要向它投递真实客户邮件。MailHog 的 release-to-SMTP 会把已捕获邮件发往外部服务器,本地兼容实验不配置真实出口,也不把“可以 release”当成测试必须执行的步骤。
迁到 Mailpit 时逐项替换测试合同
| 旧项目依赖 | 迁移时实际核对 |
|---|---|
| SMTP 地址 | 同一业务发送动作在新端点被接受;宿主与容器分别使用正确地址,失败时区分连接拒绝和 SMTP 拒收 |
| API 查询和删除 | MailHog /api/v2/messages 的 items 不能直接映射为 Mailpit /api/v1/messages 或 /api/v1/search;重写响应解析、唯一标识过滤和删除断言 |
| 认证与转发 | 分别重测 UI/API 身份、SMTP 入站限制和外发 relay;不要把旧 MH_AUTH_FILE 当作新产品配置 |
| 存储与保留 | 旧 MH_STORAGE、maildir/MongoDB 与 Mailpit 的 MP_DATABASE、MP_MAX_MESSAGES、MP_MAX_AGE 分开设计,不共享卷 |
Mailpit API和存储文档给出了新接口、持久化文件与数量/年龄保留选项;具体安装与发送实验见 Mailpit 主文。迁移演练应让两套服务使用不同端口、不同存储,用相同结构的假邮件各发一封,分别核对 SMTP 接受、API 精确匹配、删除、错误路径和重启。不要在同一封测试邮件上同时向两个服务投递后再用模糊的总数判断。
切换应用配置后,保留可回退的旧镜像标签和配置;若新端 API 适配失败,回退地址和适配层,并先确认失败期间的新测试结果是否仍要保留。不要把 Mailpit 数据库回拷到 MailHog,或反过来复制旧 maildir。退出旧服务前确认代码、CI、代理路由都不再请求 MailHog API,旧持久化邮件已按数据规则处理,相关端口和凭证已撤销。历史邮件若有法定或审计留存要求,应先由数据负责人确定导出与保留方式,不能用“测试数据”名义直接删除。
出错时先按链路定位
| 现象 | 第一证据与下一步 |
|---|---|
up -d 失败或端口未发布 | 查看 docker compose -p "$LAB_PROJECT" ps 和 logs mailhog,核对端口占用、镜像架构和 Docker 权限;不要继续发信 |
| Python 报连接拒绝或 SMTP 异常 | 核对宿主 127.0.0.1:1025、容器映射和 MailHog SMTP 日志;若应用在容器内,改查它与 mailhog:1025 是否处于同一网络 |
SMTP 已接受但 API 匹配为 false | 先查当前项目实例和 LAB_SUBJECT,再看 API 的 total 与 items[].Content.Headers.Subject;检查测试是否请求了旧实例或已被重启清空 |
| API 返回 401/403、空响应或解析失败 | 核对是否启用 MH_AUTH_FILE、curl 代理与配置、请求版本以及实际 JSON 字段;/api/v1/messages 与 /api/v2/messages 不能混用解析方式 |
| 重启后历史邮件与预期不符 | 读实际 MH_STORAGE 和存储挂载;内存模式应清空,持久化模式需检查写权限和数据目录,不能先删卷再找原因 |
权威资料与规范地址
MailHog 版本、配置与 API
- 官方仓库:https://github.com/mailhog/MailHog
- 正式发布页:https://github.com/mailhog/MailHog/releases
- 配置表:https://github.com/mailhog/MailHog/blob/master/docs/CONFIG.md
- v1 API:https://github.com/mailhog/MailHog/blob/master/docs/APIv1.md
- v2 API Swagger:https://github.com/mailhog/MailHog/blob/master/docs/APIv2/swagger-2.0.yaml
- v2 服务端实现:https://github.com/mailhog/MailHog-Server/blob/master/api/v2.go
