本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年01月04日 统计字数: 10484字 阅读时间: 21分钟阅读 本文链接: https://soulteary.com/2026/01/04/maildev-compatible-test-mailbox-owlmail.html ----- # OwlMail:兼容 MailDev 的工程化测试邮箱 在开发与私有化部署环境里,用 OwlMail 把“发出去的邮件”拦下来,让它从一次性联调变成可验证的工程能力。 ## 写在前面 在开发和测试阶段,我们经常需要一个“能收邮件的黑洞”:应用照常发信,但邮件不进入公网、不进真实邮箱,而是被本地或内网服务拦下来,供我们查看内容、点链接、验证流程。你可以把它当作邮件世界里的“抓包工具”。 OwlMail([https://github.com/soulteary/owlmail](https://github.com/soulteary/owlmail))就是为这个目的准备的,欢迎“**一键三连**”。 ![OwlMail,使用 MIT 协议开源](https://attachment.soulteary.com/2026/01/04/owlmail-project.jpg) 同一个“收信箱”,在不同角色眼里有不同价值: - **后端工程师**:联调发信逻辑、异步任务、错误重试、队列消费一致性 - **QA / 测试工程师**:回归用例、E2E 自动化断言、批量验证邮件内容与链接 - **前端 / 全栈**:点邮件里的链接,把注册、验证、找回密码走完整流程 - **产品 / 运营**:验收模板文案、变量渲染、多语言与品牌皮肤一致性 - **DevOps**:在测试环境提供统一组件,降低团队“各自搭一套”的成本 典型场景大致可以分成五类: - **账号体系与通知流测试**:注册验证、邮箱激活、改密与验证码、订单与支付通知、工单与告警 - **微服务与消息系统集成测试**:A 触发事件,B 异步发邮件,关心是否漏发、重复、错路由 - **CI / 自动化回归**:nightly 或 PR 的 e2e 自动触发发信,再通过 API 拉取邮件做断言 - **模板与多语言验收**:多套模板、不同语言、不同品牌主题,关心可预览与可检索 - **更贴近真实环境的 SMTP 行为验证**:TLS/STARTTLS、SMTP AUTH、SMTPS 465 等,验证客户端或网关实现是否符合预期 这类工具的价值不在于“能不能收邮件”,而在于“能不能把邮件这条链路变成可观察、可回归、可脚本化的工程能力”。 它可以理解为开发测试邮箱工具的工程化版本:一边提供 SMTP 接收与 Web UI 预览,一边对齐常见工具(例如 MailDev)的使用习惯与接口,方便替换与集成,同时更适配团队环境与 CI 场景。 下面我用一个非常具体的“跨设备开发”场景,把它从安装、联通、验证、压测到排错走一遍。 ## OwlMail 是什么 OwlMail 是前一阵子做的开源软件([https://github.com/soulteary/owlmail](https://github.com/soulteary/owlmail))。如果用一句话描述它的边界:**OwlMail 是一个面向开发与私有化环境的测试邮箱工具**,提供 SMTP 接收与 Web UI(以及 API),把“应用发出去的邮件”拦下来,变成可查看、可检索、可自动化断言的数据。 如果你用过 MailDev,它们的心智模型非常接近: - MailDev 更类似“我本地起一个就行”,追求上手快 - OwlMail 更专注“团队组件/CI 组件”,追求可观察、可脚本化、可批量处理,并尽量贴近真实 SMTP 行为 对团队而言,邮件系统最讨厌的地方在于:它经常“**看起来能用**”,但一到联调与回归就变得不可控。 OwlMail 试图**把这些不可控收敛成工程问题**:接口、数据、存储、检索、以及可重复的运行方式。 ### OwlMail 与 MailDev 的关系 你可以把 OwlMail 理解为 **Go 版本的 MailDev**,并尽量做到**使用习惯与 API 端点兼容**,让“替换成本”足够低。(MailDev 本身是基于 Node.js 的“SMTP + Web UI”开发测试邮箱工具) 它更强调工程化能力的原因很现实: - 团队环境下邮件量会变大 - 自动化测试需要 API 拉取、检索、清理、导出 - 有些公司内网环境要验证 TLS/STARTTLS、AUTH、SMTPS 之类行为 - 资源占用与稳定性会从“能跑”变成“要长期跑” 所以它的定位更像:**一个可被部署、可被集成、可被压测的测试邮箱服务**,而不是一个临时的小工具。 如果啰嗦一些,OwlMail 大概有以下亮点: - 完全兼容:MailDev API 端点、环境变量、Auto Relay Rules 格式都兼容(可无痛替换)。 - SMTPS 465 + TLS/STARTTLS + SMTP AUTH(开发测试更贴近真实环境) - 全文搜索 + 时间范围过滤 + 排序(找邮件不再靠翻) - 批量处理 / 统计 / 预览 API / ZIP 导出(更适合团队与 CI) - 配置管理 API(GET/PUT/PATCH)+ 更规范的 API 接口设计 - 更新快,最新 feat:i18n 中英文切换 + Email ID(UUID)支持 - 更工程化的部署体验:单二进制、低资源、更快启动、更好的并发模型(Go)。 回到场景来看,两个软件的侧重: - MailDev:个人或小团队本地调试,“能看到邮件就行”,追求上手快。 - OwlMail:更像“团队工具/CI 组件”——邮件量大、要 API 自动化、要搜索/批量/导出、要更贴近真实 SMTP(TLS/AUTH/SMTPS)、还希望资源占用更低。 ## 快速启动 OwlMail 程序 如果你希望使用二进制,那么在[发布页面](https://github.com/soulteary/owlmail)下载不同操作系统的二进制文件,直接启动它即可: ```bash ./owlmail ``` 或者,如果你和我一样,更喜欢使用容器来做事情: ```bash docker run --rm -it -p 1080:1080 -p 1025:1025 ghcr.io/soulteary/owlmail:0.2.0 ``` 上面的容器启动命令中,我们约定服务端口如下: - 1025:SMTP(应用把邮件投递到这里) - 1080:Web UI 与 API(人看邮件、脚本拉邮件) 当程序启动后,你会看到类似日志: ```text Starting OwlMail Web API on http://0.0.0.0:1080 ``` 只要你能在浏览器中打开 `http://:1080`(或者使用程序访问 API),就可以开始联调了。 ### 跨设备开发的联通模型 你可以在任何环境中测试和使用 OwlMail,单机也好,集群也罢。为了方便行文,我还是使用上一篇文章中提到的两台设备,做跨设备演示。在上一篇文章《[用 Split DNS 提升跨系统双机开发体验](https://soulteary.com/2026/01/03/split-dns-for-cross-platform-dual-machine-dev.html)》中,我提到了我目前使用的设备组合是: - macOS 作为主力输入设备:尽量轻量、少跑后台服务,开盖即用 - Ubuntu 作为移动工作站与服务承载机:跑服务、跑 CI、跑容器,把折腾留在 Linux 上 两台设备通过雷电网桥或同一热点互联后,我会把 Ubuntu 侧固定在一个稳定 IP,例如:`192.168.123.200`。OwlMail 就运行在这台 Ubuntu 设备上。Mac 侧只要能访问到 Ubuntu 的 `1025/1080`,开发闭环就成立了。 ## 在 macOS 上用命令行验证远程 SMTP 当你在一台服务器(例如 `192.168.123.200`)上用任何方式跑起 OwlMail 后,你可能想在自己的 macOS 上用命令行发一封测试邮件,确认 SMTP 投递正常,并在 OwlMail 的 Web UI 里看到收件效果。 ### 目标与端口约定 我们还是先进行端口约定,接下来测试使用的环境为: - SMTP 端口是 `1025` - OwlMail Web UI 端口是 `1080` - 容器运行在 `192.168.123.200` 接下来,我们要做的事情就是:从 macOS 向 `192.168.123.200:1025` 投递邮件,然后打开 `http://192.168.123.200:1080` 查看收件箱。 ### 设备连通性检查 我们在 macOS 上分别执行: ```bash nc -vz 192.168.123.200 1025 nc -vz 192.168.123.200 1080 # 或 curl -I http://192.168.123.200:1080 ``` 如果 `1025` 通,那么说明 SMTP 可达;如果 `1080` 通,那么说明 Web UI 可达。如果端口不通,我们优先排查服务器防火墙与端口转发是否放行。 ## 使用 “Mail 命令行工具” 进行测试 这里我们并不直接推荐使用 macOS 自带 mail 命令,因为 macOS 自带的 `/usr/bin/mail` 更像是“把邮件丢给本机 MTA”的前端,它不擅长做“显式指定远程 SMTP 服务器”的测试。 为了让行为更可控、更可复现,建议用更明确的 SMTP 客户端工具。一般来说,我们可以这样选择: - **s-nail**:兼容 mail 使用习惯,适合快速手工测试 - **swaks**:SMTP 领域的瑞士军刀,适合脚本化与诊断 ### 使用 s-nail 发送测试邮件 `s-nail` 的安装非常简单,在 macOS 中使用 Homebrew:`brew install s-nail`。 安装完毕后,我们可以执行一条命令来进行邮件测试。在下面这条命令中,我们显式指定远程 SMTP,同时避免任何本地配置干扰。 ```bash echo "hello owlmail" | s-nail -:/ \ -S v15-compat=yes \ -S mta=smtp://192.168.123.200:1025 \ -S smtp-auth=none \ -S from=test@local \ -s "owlmail test" someone@example.com ``` 让我们来检查下上面命令中的参数: - `-:/` 表示不读取任何 rc 配置文件,保证测试环境干净 - `mta=smtp://...` 是程序支持的新写法,替代旧的 smtp=... - `smtp-auth=none` 明确关闭鉴权,适合 OwlMail 这种本地测试 SMTP - `v15-compat=yes` 用于压制 s-nail 在 14.x 版本里常见的“old-style credentials”淘汰告警 ![当 OwlMail 收到邮件后的预览界面变化](https://attachment.soulteary.com/2026/01/04/owlmail-simple-test.jpg) 命令执行完毕后,我们使用浏览器打开:`http://192.168.123.200:1080`,正常情况下应该能够在 OwlMail 页面里看到刚刚投递的邮件。 ### 直接用 swaks(更稳的 SMTP 测试工具) 程序的安装依旧是使用 Homebrew 进行安装: `brew install swaks`。 安装完毕后,我们执行和上面类似的一条命令,就可以开始邮件发送测试了。 ```bash swaks --server 192.168.123.200 --port 1025 \ --from test@local --to someone@example.com \ --header "Subject: owlmail test" \ --body "hello owlmail" ``` 相比较 `s-nail`,`swaks` 的输出更加详细,我们能够看到相对完整的 SMTP 对话细节,更适合排查握手、TLS、AUTH 等问题。 ## 测试服务性能 当你把 OwlMail 从“个人调试工具”变成“团队共享组件”,很快会关心这些问题: - 大量邮件进入后 UI 是否还能正常使用 - API 是否还能稳定检索 - SMTP 投递是否会抖动或丢失 - 存储与资源占用是否符合预期 为了让我们使用起来安心,最简单、直接的方式就是进行真实验证。 ### 编写 OwlMail 压力测试脚本 下面我们来编写一个简单的压测脚本,用于在私有环境中进行 OwlMail 的基础性能测试。 我们创建一个脚本程序 `send_bulk_mail.py`: ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import ipaddress import random import string import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from email.message import EmailMessage import smtplib def is_private_host(host: str) -> bool: try: ip = ipaddress.ip_address(host) return ip.is_private except ValueError: # If it's a hostname, we conservatively deny by default return False def rand_text(n: int) -> str: alphabet = string.ascii_letters + string.digits + " " return "".join(random.choice(alphabet) for _ in range(n)).strip() or "hello" def build_message(i: int, from_addr: str, to_addr: str) -> EmailMessage: msg = EmailMessage() msg["From"] = from_addr msg["To"] = to_addr msg["Subject"] = f"owlmail load test #{i} {rand_text(10)}" # Make each message unique msg["Message-ID"] = f"" body = ( f"Index: {i}\n" f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}\n" f"Payload: {rand_text(200)}\n" ) msg.set_content(body) return msg def send_one(host: str, port: int, timeout: int, i: int, from_addr: str, to_addr: str) -> None: msg = build_message(i, from_addr, to_addr) with smtplib.SMTP(host=host, port=port, timeout=timeout) as s: s.ehlo() s.send_message(msg) def main(): p = argparse.ArgumentParser(description="Send bulk test emails to a private SMTP server (no auth, no TLS).") p.add_argument("--host", default="192.168.123.200") p.add_argument("--port", type=int, default=1025) p.add_argument("--count", type=int, default=10000) p.add_argument("--concurrency", type=int, default=20) p.add_argument("--rate", type=float, default=200.0, help="Max emails per second (approx).") p.add_argument("--timeout", type=int, default=10) p.add_argument("--from", dest="from_addr", default="test@local") p.add_argument("--to", dest="to_addr", default="someone@example.com") p.add_argument("--allow-non-private", action="store_true", help="DANGEROUS: allow non-private targets.") args = p.parse_args() if not args.allow_non_private and not is_private_host(args.host): print(f"Refusing to send to non-private host: {args.host}\n" f"Use --allow-non-private only if you own/have permission.", file=sys.stderr) sys.exit(2) # Simple token-bucket-ish pacing min_interval = 1.0 / max(args.rate, 1e-9) last_send_at = 0.0 sent = 0 failed = 0 start = time.time() def paced_send(i: int): nonlocal last_send_at # Pace globally (best-effort) while True: now = time.time() wait = (last_send_at + min_interval) - now if wait <= 0: last_send_at = now break time.sleep(min(wait, 0.05)) send_one(args.host, args.port, args.timeout, i, args.from_addr, args.to_addr) print(f"Target SMTP: {args.host}:{args.port}") print(f"Count: {args.count}, Concurrency: {args.concurrency}, Rate: ~{args.rate}/s") print("Starting...") with ThreadPoolExecutor(max_workers=args.concurrency) as ex: futures = [ex.submit(paced_send, i) for i in range(1, args.count + 1)] for f in as_completed(futures): try: f.result() sent += 1 except Exception as e: failed += 1 total = sent + failed if total % 500 == 0: elapsed = time.time() - start qps = total / elapsed if elapsed > 0 else 0 print(f"Progress {total}/{args.count} sent={sent} failed={failed} avg_rate={qps:.1f}/s") elapsed = time.time() - start print(f"Done. sent={sent} failed={failed} elapsed={elapsed:.1f}s avg_rate={(sent+failed)/elapsed:.1f}/s") if __name__ == "__main__": main() ``` 这个脚本做了几件事:默认只允许私网目标(避免误用)、支持限速与并发、每封邮件内容随机,保证唯一性、不启用 TLS、不做鉴权,适配本地测试 SMTP。 ### 执行压力测试 既然是压力测试,我们可以“相对暴力一些”,比如执行下面的命令,使用上面的 Python 脚本向我们的 OwlMail 内网服务(`192.168.123.200:1025`) 发送 10000 封随机内容邮件。 ```bash python ./send_bulk_mail.py --host 192.168.123.200 --port 1025 --count 10000 --concurrency 50 --rate 1000 ``` - `--rate 1000` 是“期望上限”,实际吞吐由网络与服务端处理能力决定 - 如果想测试更稳就降 `rate` 或 `concurrency` - 如果你对测试服务机器能力信心不足,建议先从 `--count 1000` 起跑一遍确认行为,再往上调整跑一万封 当我使用不插电的两台笔记本进行测试的时候,得到了下面的结果: ```text Target SMTP: 192.168.123.200:1025 Count: 10000, Concurrency: 50, Rate: ~1000.0/s Starting... Progress 500/10000 sent=500 failed=0 avg_rate=413.9/s Progress 1000/10000 sent=1000 failed=0 avg_rate=438.6/s Progress 1500/10000 sent=1500 failed=0 avg_rate=451.6/s Progress 2000/10000 sent=2000 failed=0 avg_rate=456.1/s Progress 2500/10000 sent=2500 failed=0 avg_rate=461.4/s Progress 3000/10000 sent=3000 failed=0 avg_rate=464.3/s Progress 3500/10000 sent=3500 failed=0 avg_rate=467.5/s Progress 4000/10000 sent=4000 failed=0 avg_rate=470.6/s Progress 4500/10000 sent=4500 failed=0 avg_rate=472.8/s Progress 5000/10000 sent=5000 failed=0 avg_rate=474.8/s Progress 5500/10000 sent=5500 failed=0 avg_rate=475.8/s Progress 6000/10000 sent=6000 failed=0 avg_rate=472.3/s Progress 6500/10000 sent=6500 failed=0 avg_rate=473.6/s Progress 7000/10000 sent=7000 failed=0 avg_rate=474.9/s Progress 7500/10000 sent=7500 failed=0 avg_rate=474.2/s Progress 8000/10000 sent=8000 failed=0 avg_rate=474.5/s Progress 8500/10000 sent=8500 failed=0 avg_rate=473.6/s Progress 9000/10000 sent=9000 failed=0 avg_rate=474.3/s Progress 9500/10000 sent=9500 failed=0 avg_rate=475.1/s Progress 10000/10000 sent=10000 failed=0 avg_rate=476.4/s Done. sent=10000 failed=0 elapsed=21.0s avg_rate=476.3/s ``` 虽然这段数据不能当做“绝对性能指标”,但是作为“在你的网络与机器上,当前版本能跑到什么程度”的参考点还是非常合适的:至少在两台不插电的笔记本上,每秒都能够承载接近500封邮件,作为一般的团队和个人开发使用,应该是绰绰有余的。 ![对 OwlMail 进行 1万封邮件基础测试](https://attachment.soulteary.com/2026/01/04/owlmail-batch-test.jpg) ## 常见问题与排错 聊完了基础使用和一般测试,我们来聊聊如何排错。 虽然软件很简单,我们使用的链路也非常固定,但是排错的时候,最好基于下面的顺序:先确认服务端“应答是否答对”,再确认客户端“提问是否问对”。 ### 端口不通 当遇到端口不通的时候,我们通常需要先确认两件事: - 服务器防火墙是否放行 `1025` 与 `1080` - Docker 是否真的做了端口映射(以及是否绑定在可访问的网卡上) 可以通过下面的命令来做快速检查: ```bash nc -vz 192.168.123.200 1025 nc -vz 192.168.123.200 1080 ``` ### 命令行显示发送成功但 UI 看不到 这里最常见的原因是,你可能启动了多个服务实例,此刻“你看的不是同一个实例”。 建议先用 curl 确认你打开的确实是目标服务: ```bash curl -I http://192.168.123.200:1080 ``` 另外,在压测场景下 UI 可能因为数据量变大而加载变慢,此时优先用 API 或搜索能力去定位最新邮件,而不是靠手动翻页。(通常刷新下页面,能够得到实时的新数据,而不用等待浏览器程序动态更新数据) ### 压测时失败率上升 压测时的失败率上升不一定是 OwlMail 的问题,通常从三类原因里来: - 客户端并发过大导致本机端口耗尽或连接排队 - 网络抖动导致连接超时 - 服务端资源触顶导致 accept 变慢或写盘阻塞 调整顺序建议是: - 先降低并发,增大 timeout,观察失败率变化 - 再降低 rate,找一个稳定区间 - 最后观察服务器 CPU、内存、磁盘 IO,确认瓶颈点 ## 最后 把邮件链路从“看天吃饭”变成“可控可测”,往往只差一个可靠的收信组件。 OwlMail 带来的直接收益是:邮件不再污染真实邮箱、联调与验收不再靠截图与转发、自动化测试可以把邮件内容也纳入断言、测试环境可以把邮件能力收敛成统一的团队组件。 如果你已经有一台 Ubuntu 设备在跑各种服务,不妨把邮件也一起收拢进去:开发机只负责“发”,测试邮箱负责“收”,边界清晰,问题也更好定位。 --EOF