自托管邮件追踪工具Caveman:透明像素与Webhook事件统计方案 如果你有一天发现自己在一封发给用户的邮件里塞了一个1像素的透明GIF只为了搞清楚这封邮件到底有没有被打开恭喜你你已经踏进了邮件追踪这个非常微妙的领域。Caveman就是我在这种事里折腾出来的一个小工具——一个极简的自托管邮件事件统计器接收邮件服务商送来的打开、点击、退订、退信事件写进本地SQLite然后在一个没有任何前端框架的网页上把数据铺给你看。起名Caveman是因为整个项目对“复杂”极度不信任凡是能用最直接方式解决的就不引入多余的抽象。这篇记录适合正被邮件平台报表绑架的人、想把数据拿回自己手里的开发者也适合所有想搞明白透明像素到底怎么工作的人。1. 为什么我会自建一个邮件追踪工具被“统计报表”逼出的Caveman1.1 平台报表让我多干了三倍的活我以前也老老实实用邮件平台自带的报表。注册、登录、选任务、看打开率曲线表面上非常省事。但真到要回答“这一批订单通知到底有没有送到人手里”时平台报表就开始给我制造各种各样的麻烦。我做的是订单通知、密码重置这类事务性邮件不是广告轰炸但即便如此报表平台依然能把我绕晕打开率只给汇总数字想查哪些收件人没打开得单独调API而API又有次数限制。邮件里的链接点击不同平台统计口径不一样。有的按人算有的按点击次数算同一人连续点三次就算三次。想导出发送后两小时内打开明细得先在后台设置自定义时间窗导出文件最多给5000行再多就得分批。换一家邮件服务商报表字段、统计口径、导出格式全部变一遍历史数据根本没法纵向对比。被这一套折腾下来我意识到自己真正需要的不是“报表”而是“原始事件”。打开就是一条打开记录点击就是一条点击记录退订就是一条退订记录。给我一张表和一个查询入口后续怎么分析我自己来。这个想法慢慢长成了Caveman。1.2 对比三种方案为什么最后选了自托管做之前我把市面上的思路过了一遍大致能分成三类继续用邮件平台自带报表、接第三方邮件统计服务、自己写一个自托管工具。三者之间的取舍如下表方案优点代价邮件平台自带报表零部署点开即用字段固定明细导出麻烦数据留在别人手里换平台即换口径第三方邮件统计服务功能丰富图表和归因开箱即用按事件量收费多一跳外部请求收件人数据多一次流转自托管Caveman原始数据完全自持一条SQL随便查部署成本极低需要自己管服务器和域名图表、告警都要自己动手我当时给自己定了四条硬标准原始数据必须能随便导出事件字段必须由我定义部署时间不超过半小时数据不经过第三方。只要能满足这四条我情愿牺牲一部分“开箱即用”。Caveman作为自托管工具正好卡在这四条标准上。而且它的维护成本没有想象的吓人——编译后是一个二进制文件数据库是一个文件进程挂了重启也就一秒。对于个人项目和中小业务这个投入几乎可以忽略不计。之所以不怕“没有好看图表”是因为我做邮件统计真正关心的是变化趋势和异常样本不是要一个五彩斑斓的驾驶舱。折线图自己拿Canvas画都行关键是底下的原始数据必须干净、完整、可控。2. Caveman的底层原理一次打开一个像素一条Webhook2.1 透明像素为什么能统计打开HTML邮件本质上是一段能在邮件客户端里渲染的网页而邮件客户端加载远程图片时会发起一次HTTP请求。Caveman做的就是在邮件HTML里放一个img标签图片地址指向自己的服务器并且在请求路径里带上收件人标识。当收件人打开邮件、邮件客户端去请求这张图片时服务器先记录一条事件再返回一张1×1的透明GIF。收件人看到的是内容我们拿到的是“这个客户端请求过这张图片”的事实。这个机制和网站里嵌第三方统计脚本是同一个道理只不过邮件环境更受限不能执行JavaScript只能靠图片请求这种最原始的方式。这反而很贴合Caveman的定位不用什么高级技术一个HTTP请求就能实现统计。但这里有个现实前提需要说清楚现在的邮件客户端越来越注重隐私。Gmail和Outlook会把外部图片代理缓存苹果的隐私保护甚至会预加载邮件内容。所以透明像素统计出来的“打开数”并不等于真人打开数。我的处理方法是把“打开”理解成“像素被请求”在数据库里去重后才当成“一次打开尝试”分析的时候看趋势不看绝对数字。2.2 事件从触发到入库的完整链路Caveman里的事件采集分两条路径。路径A是实时请求型覆盖打开和点击。收件人的邮件客户端加载邮件里的像素图片时HTTP请求直接打到Caveman服务端写入一条open事件然后立刻返回1×1 GIF。点击统计类似邮件里的链接指向Caveman的一个中转地址用户点击时先请求到Caveman服务端记录click事件再通过302跳转到真正的目标地址。路径B是消息回调型覆盖退订、退信、举报。这类事件由邮件服务商在后台产生服务商通过Webhook把事件POST到Caveman配置好的接收地址服务端验签后写入数据库。两条路径的分工可以用下面这张表说清楚事件类型触发场景采集方式open收件人邮件客户端加载像素图片像素图片GET请求click收件人点击邮件中的追踪链接追踪链接GET请求记录后302跳转unsubscribe收件人主动退订邮件服务商Webhook POSTbounce收件地址无效或邮箱拒收邮件服务商Webhook POSTcomplaint收件人把邮件举报为垃圾邮件邮件服务商Webhook POST为什么点击统计要用302跳转而不是直接把真实链接写进邮件因为直接写真实链接反垃圾引擎会看到链接指向的域名如果这个域名比较新或者信誉低邮件很可能在投递环节就被拦下来。走Caveman中转可以在记录点击的瞬间决定要不要跳转也可以顺手做A/B分流功能都在一个接口里解决。2.3 一张表存储所有事件够用且好用Caveman的存储没有搞复杂设计就是一张events表。建表语句大致是这样的CREATE TABLE events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, campaign_id TEXT, recipient_id TEXT, tracking_id TEXT UNIQUE, ip TEXT, user_agent TEXT, target_url TEXT, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE INDEX idx_events_type_time ON events(event_type, created_at); CREATE INDEX idx_events_recipient ON events(recipient_id);tracking_id字段承担了很重要的去重职责。一次发信给一个收件人在生成邮件时就会随机一个唯一ID放进像素或链接参数里。后来无论这个请求被邮件客户端重复多少次、被代理缓存请求多少次数据库里都只保留第一条。这个唯一索引在防重上帮了大忙我在下文提到的“重复打开”问题基本都靠它兜底。选择SQLite而不是Postgres是因为Caveman的核心诉求不是海量分析而是快速记录和灵活查询。SQLite单文件、零运维、备份就是拷贝一个文件。等哪天事件量真的到千万级再考虑Postgres在初期这张表完全能撑住。补充一个经验IP字段一定要小心。真实部署里你拿到的往往是CDN节点或邮件客户端代理服务器的IP不是用户真实IP。所以Caveman默认不建议把IP当地理定位依据最多用来判断重复请求。想要更准确的用户信息靠点击后落到业务系统再采集不要在像素层纠结。3. 从零搭建10分钟把Caveman跑起来3.1 运行环境与目录结构要跑起Caveman你需要三样东西一台能编译Go的机器一台有公网IP的服务器一个能正常收发信、并且能加解析记录的域名。编译产出物是一个二进制文件拷到服务器上就能运行不需要额外运行时环境。项目目录结构大致如下caveman/ ├── main.go ├── store.go ├── handler.go ├── static/ │ ├── index.html │ └── app.js └── caveman.conf.example每个文件职责很清晰main.go负责读配置和启动HTTP服务store.go负责数据库初始化、事件写入和查询handler.go包含三个核心接口——像素接口、点击中转接口、Webhook接收接口static/是单页面面板两个文件没有构建步骤caveman.conf.example是配置模板。选Go就是看中它单文件部署、标准库HTTP性能充足、并发处理一个SQLite文件也不费劲。当然用Python也能写但这篇文章按Go的实践来讲。3.2 编译、配置、启动配置文件的思路长这样实际字段根据你自己的情况调整http_listen 127.0.0.1:8080 db_path data/caveman.db base_url https://stats.example.com webhook_secret 改成足够长的随机串 api_token 另一个随机串几个字段说明一下http_listen默认绑127.0.0.1不要直接暴露到公网前面套一层Nginx或Caddybase_url用于生成面板里的绝对链接webhook_secret要和邮件服务商后台填的值保持一致用来验证Webhook签名api_token留给后续API访问用。编译启动的命令很简单go build -o caveman ./cmd/caveman ./caveman -conf caveman.conf curl http://127.0.0.1:8080/api/health然后配置反向代理把HTTPS流量转给本地服务。Nginx配置片段大致如下server { listen 443 ssl; server_name stats.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } }证书用Let’s Encrypt或者云厂商免费证书都行HTTPS是必须的否则像素链接里的参数会在传输过程中明文暴露。反代配好后到邮件服务商后台把Webhook接收地址填成https://stats.example.com/webhook勾选open、click、unsubscribe、bounce等事件。到底哪些事件支持回调要看服务商文档不用默认全都有。3.3 邮件模板里如何嵌入追踪元素在邮件HTML里加入像素和跳转链接长这样img srchttps://stats.example.com/tracker/open?eu_1001corder_456t8f3a9c21 width1 height1 styleopacity:0 alt / a hrefhttps://stats.example.com/tracker/click?tohttps%3A%2F%2Fexample.com%2Forders%2F456eu_1001corder_456查看订单/a这里有几个细节值得单独拿出来说。第一像素img不要用display:none某些反垃圾规则会把隐藏图片当作异常处理建议用width1 height1 styleopacity:0或者干脆把图片放在正文底部当一个小占位。第二链接里的to参数必须做URL编码否则外层?和会把目标地址里的参数截断掉。第三t参数的随机token是防缓存和去重的关键没有这个参数同一封邮件在不同客户端里会复用缓存漏统计的概率会明显增加。第四不要把邮箱地址明文放进URL。URL会出现在服务器访问日志里建议用内部用户ID收件人邮箱只放在服务端的映射表里需要关联时再查。4. 接入真实邮件流Webhook验证与踩坑记录4.1 第一个坑回调地址怎么都收不到事件我最早在本地开发环境调试用curl模拟Webhook请求服务端应答一切正常。结果到邮件服务商后台配置回调时后台一直提示“回调失败”。排查了很久发现根本不是代码的问题而是我的服务部署在本地邮件服务商根本无法访问我本机地址。生产环境需要的是一个公网可达的HTTPS地址。当时处理方式很简单把Caveman迁到一台有公网IP的云主机上域名解析过去套上HTTPS证书再回到邮件服务商后台把回调地址填成生产环境的URL立刻就通了。这里有几个经验可以直接用后端服务不一定要很大的机器但必须有独立公网IP回调地址尽量只用80或443端口很多平台后台只允许填标准端口如果你用了Nginx或Caddy记得先确认代理层能正常透传POST请求体Webhook验签依赖完整的原始Body。4.2 第二个坑不做签名验证Webhook接口变成公共接口剪了Webhook刚开始为了调试方便我把签名验证关了结果上线没两天数据库里出现了大量没有对应任务的点击事件IP一看全是陌生地址。当时就明白了有人扫到了Webhook端点正在往里灌假数据。Caveman后来的默认配置是验签开启验签失败直接返回401不写入任何数据。签名验证的原理不复杂邮件服务商用密钥对请求体做HMAC-SHA256把签名放在Header里Caveman用同一个密钥对收到的Body重新计算结果比对一致才放行。Go代码大概是这个意思func verifySignature(payload []byte, sig string, secret string) bool { mac : hmac.New(sha256.New, []byte(secret)) mac.Write(payload) expect : hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expect), []byte(sig)) }不同服务商的签名算法五花八门有的签整个请求体有的只签某个字段有的把签名放Header有的放Query参数。配置的时候先查清楚文档确认是HMAC-SHA256还是其他算法。我甚至遇到过用sha1签名的事件平台验签函数必须和平台对齐否则很容易出现“全拒”或“全放行”两个极端。4.3 第三个坑打开率突然飙升或暴跌先检查图片代理有一段时间打开率突然从20%涨到60%我第一反应是邮件爆了。后来查日志发现同一个tracking_id被重复请求了三四次全部来自类似“图片代理服务器”的IP段。原因其实很常见邮件客户端把所有外部图片提前拉取缓存像素被预加载了。还有一类隐私保护更强的客户端甚至会用一个虚拟用户代理预先请求真实用户根本没打开。这个坑的处理并不复杂tracking_id上建唯一索引重复请求直接ON CONFLICT DO NOTHING面板统计用COUNT(DISTINCT tracking_id)而不是COUNT(*)写分析结论时只谈“像素触发次数”不冒充“用户阅读时长”。以后你遇到某天打开率突然异常第一件事就是看重复请求比例和请求来源IP大概率能从日志里找到答案。4.4 第四个坑统计域名信誉差正常邮件被扔进垃圾箱早期我贪省事单独注册了一个新域名做追踪结果邮件服务商后台显示投递成功但收件箱里就是看不到邮件最后在垃圾箱里找到了。反垃圾引擎判定邮件权重时会看邮件里链接的域名和发件域名是否一致以及这个域名有没有历史信誉记录。一个刚注册的独立域名第一次出现在邮件链接里天然就有劝退感。解决方式是把追踪链接放到主域名的子域名下例如stats.example.com让它和发件域名同根。同时给子域名配上合格的SPF记录邮件里的追踪链接保持长期稳定不要频繁更换统计域名。我们还给追踪域名上了HTTPS没有证书的统计域名在浏览器和邮件客户端里都会被双重标记。这些做下来之后垃圾箱问题才真正消退。5. 数据之外性能、安全与使用边界5.1 面板两张SQL撑起一个仪表盘Caveman的面板很简单顶部四张卡片显示今日打开、今日点击、独立打开人数、退订数中间一张最近14天的趋势折线图底部一个最新100条事件的表格。这些内容背后其实就是两条SQL。SELECT COUNT(*) AS total_events, COUNT(DISTINCT tracking_id) AS unique_opens FROM events WHERE event_type open AND created_at datetime(now, start of day); SELECT date(created_at) AS day, COUNT(DISTINCT tracking_id) AS opens FROM events WHERE event_type open AND created_at datetime(now, -14 days) GROUP BY day;面板本身基本上不做二次加工它只是把SQL查询结果用HTML渲染出来。遇到“这个数字为什么不对”我从来不在面板代码里找原因而是打开SQLite命令行直接查数据。这个思路在后来的维护里省了非常多时间。5.2 API与自动化把昨天数据拉回内部系统为了让脚本和内部系统能直接拿到数据Caveman暴露了四个基础接口GET /api/health健康检查。GET /api/events?since2025-01-01T00:00:00Ztypeopen拉取事件明细。GET /api/stats/summary汇总指标。GET /api/stats/daily?days14按天聚合数据。请求时统一带Authorization: Bearer头用配置文件里的api_token访问。示例curl -H Authorization: Bearer $APP_TOKEN \ https://stats.example.com/api/stats/daily?days14我有段时间每天靠一个Python脚本把前一天数据拉下来扔到内部群里做简单播报。脚本本身很短import requests url https://stats.example.com/api/stats/daily r requests.get( url, params{days: 2}, headers{Authorization: Bearer ...} ) r.raise_for_status() print(r.json())有一点值得说Caveman的功能顺序是先有SQL和API后来嫌弃命令行不好看才补了个极简面板。这个顺序的启发是任何统计系统都别一上来就堆图表先把原始数据和访问接口做稳面板只是最外面一层皮。5.3 性能上限、安全边界与什么情况该放弃CavemanSQLite的写入并发不算强但Caveman的场景基本是单机低并发。我把数据库打开时设成WAL模式读写锁互斥的情况会少很多。几十万级事件量的库简单查询仍然是毫秒级。如果单日事件量到了几十万甚至更高或者需要多机写入SQLite的写锁就会成为瓶颈那时候再迁Postgres不迟。架构演进是自然的不要一开始就用一把牛刀砍蚊子。安全方面我自己的底线是这几条全站启用HTTPS禁止HTTP明文回调Webhook必须验签API访问用独立Token不和Webhook密钥混用日志只记录必要字段不打印收件人邮箱原文如果邮件服务商有固定的Webhook出口IP可以在防火墙上只允许这些IP访问/webhook路径。这些做完Caveman作为自托管工具的暴露面已经很小了。Caveman跑了大半年我最深的体会是统计邮件这件事卡点从来不是代码而是你总想加更多报表。真把打开、点击、退订这些底层事件想清楚了会发现你需要的数据可能就值两张SQL。最后再分享一个实用小技巧做标题A/B测试时我会把campaign_id直接写成order_a、order_b这种带后缀的名字发出去两小时后按campaign_id分组看点击率不用等报表刷新随时查随时有数。邮件的世界很玄但手里有一个能自己跑SQL的小工具至少能让你在玄学里多摸到一点底。