在开发与私有化部署环境里,用 OwlMail 把“发出去的邮件”拦下来,让它从一次性联调变成可验证的工程能力。

写在前面

在开发和测试阶段,我们经常需要一个“能收邮件的黑洞”:应用照常发信,但邮件不进入公网、不进真实邮箱,而是被本地或内网服务拦下来,供我们查看内容、点链接、验证流程。你可以把它当作邮件世界里的“抓包工具”。

OwlMail(https://github.com/soulteary/owlmail)就是为这个目的准备的,欢迎“一键三连”。

OwlMail,使用 MIT 协议开源

同一个“收信箱”,在不同角色眼里有不同价值:

  • 后端工程师:联调发信逻辑、异步任务、错误重试、队列消费一致性
  • 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 这种本地测试 SMTP
  • v15-compat=yes 用于压制 s-nail 在 14.x 版本里常见的“old-style credentials”淘汰告警

当 OwlMail 收到邮件后的预览界面变化

命令执行完毕后,我们使用浏览器打开: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-nailswaks 的输出更加详细,我们能够看到相对完整的 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 是“期望上限”,实际吞吐由网络与服务端处理能力决定
  • 如果想测试更稳就降 rateconcurrency
  • 如果你对测试服务机器能力信心不足,建议先从 --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封邮件,作为一般的团队和个人开发使用,应该是绰绰有余的。

对 OwlMail 进行 1万封邮件基础测试

常见问题与排错

聊完了基础使用和一般测试,我们来聊聊如何排错。

虽然软件很简单,我们使用的链路也非常固定,但是排错的时候,最好基于下面的顺序:先确认服务端“应答是否答对”,再确认客户端“提问是否问对”。

端口不通

当遇到端口不通的时候,我们通常需要先确认两件事:

  • 服务器防火墙是否放行 10251080
  • 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