在开发与私有化部署环境里,用 OwlMail 把“发出去的邮件”拦下来,让它从一次性联调变成可验证的工程能力。
写在前面
在开发和测试阶段,我们经常需要一个“能收邮件的黑洞”:应用照常发信,但邮件不进入公网、不进真实邮箱,而是被本地或内网服务拦下来,供我们查看内容、点链接、验证流程。你可以把它当作邮件世界里的“抓包工具”。
OwlMail(https://github.com/soulteary/owlmail)就是为这个目的准备的,欢迎“一键三连”。

同一个“收信箱”,在不同角色眼里有不同价值:
- 后端工程师:联调发信逻辑、异步任务、错误重试、队列消费一致性
- 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)。如果用一句话描述它的边界: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 程序
如果你希望使用二进制,那么在发布页面下载不同操作系统的二进制文件,直接启动它即可:
./owlmail
或者,如果你和我一样,更喜欢使用容器来做事情:
docker run --rm -it -p 1080:1080 -p 1025:1025 ghcr.io/soulteary/owlmail:0.2.0
上面的容器启动命令中,我们约定服务端口如下:
- 1025:SMTP(应用把邮件投递到这里)
- 1080:Web UI 与 API(人看邮件、脚本拉邮件)
当程序启动后,你会看到类似日志:
Starting OwlMail Web API on http://0.0.0.0:1080
只要你能在浏览器中打开 http://<server>:1080(或者使用程序访问 API),就可以开始联调了。
跨设备开发的联通模型
你可以在任何环境中测试和使用 OwlMail,单机也好,集群也罢。为了方便行文,我还是使用上一篇文章中提到的两台设备,做跨设备演示。在上一篇文章《用 Split DNS 提升跨系统双机开发体验》中,我提到了我目前使用的设备组合是:
- 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 上分别执行:
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,同时避免任何本地配置干扰。
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 这种本地测试 SMTPv15-compat=yes用于压制 s-nail 在 14.x 版本里常见的“old-style credentials”淘汰告警

命令执行完毕后,我们使用浏览器打开:http://192.168.123.200:1080,正常情况下应该能够在 OwlMail 页面里看到刚刚投递的邮件。
直接用 swaks(更稳的 SMTP 测试工具)
程序的安装依旧是使用 Homebrew 进行安装: brew install swaks。
安装完毕后,我们执行和上面类似的一条命令,就可以开始邮件发送测试了。
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:
#!/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"<loadtest-{i}-{int(time.time()*1e6)}@local>"
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 封随机内容邮件。
python ./send_bulk_mail.py --host 192.168.123.200 --port 1025 --count 10000 --concurrency 50 --rate 1000
--rate 1000是“期望上限”,实际吞吐由网络与服务端处理能力决定- 如果想测试更稳就降
rate或concurrency - 如果你对测试服务机器能力信心不足,建议先从
--count 1000起跑一遍确认行为,再往上调整跑一万封
当我使用不插电的两台笔记本进行测试的时候,得到了下面的结果:
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封邮件,作为一般的团队和个人开发使用,应该是绰绰有余的。

常见问题与排错
聊完了基础使用和一般测试,我们来聊聊如何排错。
虽然软件很简单,我们使用的链路也非常固定,但是排错的时候,最好基于下面的顺序:先确认服务端“应答是否答对”,再确认客户端“提问是否问对”。
端口不通
当遇到端口不通的时候,我们通常需要先确认两件事:
- 服务器防火墙是否放行
1025与1080 - Docker 是否真的做了端口映射(以及是否绑定在可访问的网卡上)
可以通过下面的命令来做快速检查:
nc -vz 192.168.123.200 1025
nc -vz 192.168.123.200 1080
命令行显示发送成功但 UI 看不到
这里最常见的原因是,你可能启动了多个服务实例,此刻“你看的不是同一个实例”。
建议先用 curl 确认你打开的确实是目标服务:
curl -I http://192.168.123.200:1080
另外,在压测场景下 UI 可能因为数据量变大而加载变慢,此时优先用 API 或搜索能力去定位最新邮件,而不是靠手动翻页。(通常刷新下页面,能够得到实时的新数据,而不用等待浏览器程序动态更新数据)
压测时失败率上升
压测时的失败率上升不一定是 OwlMail 的问题,通常从三类原因里来:
- 客户端并发过大导致本机端口耗尽或连接排队
- 网络抖动导致连接超时
- 服务端资源触顶导致 accept 变慢或写盘阻塞
调整顺序建议是:
- 先降低并发,增大 timeout,观察失败率变化
- 再降低 rate,找一个稳定区间
- 最后观察服务器 CPU、内存、磁盘 IO,确认瓶颈点
最后
把邮件链路从“看天吃饭”变成“可控可测”,往往只差一个可靠的收信组件。
OwlMail 带来的直接收益是:邮件不再污染真实邮箱、联调与验收不再靠截图与转发、自动化测试可以把邮件内容也纳入断言、测试环境可以把邮件能力收敛成统一的团队组件。
如果你已经有一台 Ubuntu 设备在跑各种服务,不妨把邮件也一起收拢进去:开发机只负责“发”,测试邮箱负责“收”,边界清晰,问题也更好定位。
–EOF