- Published on
邮件不是网页:当渲染引擎不受你控制,视觉契约该建在哪一层
- Authors

- Name
- Jack Qin
写一封"好看"的 HTML 邮件,最自然的心智模型是把网页那套 CSS 搬过来。这个模型在 Apple Mail 和 Gmail 上几乎不会让你失望——直到邮件落进 Outlook 桌面端:渐变没了、内联 SVG 被整块剥掉、图片宽度对不齐、max-width 被无视。
这不是一串孤立的兼容性 bug。它们全部源于同一个第一性事实,而一旦你接受这个事实,"该把视觉契约建在哪一层"这个问题就有了确定答案。本文不讲某一封邮件怎么拼,而是想从这个根事实出发,推导出一套渲染契约:哪些视觉元素不能信任客户端 CSS、为什么必须在服务端栅格化、以及栅格化之后还会在哪些机制交汇处踩坑。贯穿全篇的工作样例,是某环境监测平台给客户定时推送的热力图报告、储罐液位告警、流量计周报(栈:.NET 10、MassTransit、SkiaSharp、Playwright、Microsoft Graph 发信、PostgreSQL)。
根事实:Outlook 渲染的不是 HTML,是 Word
所有现象的根,只有一句话:Outlook Desktop 的 HTML 渲染走的是 Word 的排版引擎,不是 WebKit/Blink/Gecko。 Word 引擎对 CSS 的支持停留在一个很古老的子集上,而且它的失败方式是静默的——不报错,只是悄悄不生效。
下面这张表是逐一实测出来的"静默失败"清单。但比清单本身更重要的,是它揭示的一条分界线:
| 特性 | Outlook 支持情况 | 替代方案 |
|---|---|---|
background: linear-gradient(...) | 不渲染 | 栅格化成 PNG,或搭配 background-color 兜底 |
内联 <svg>(含渐变) | 被剥离 | 用 SkiaSharp 栅格化成 PNG,以 CID 附件方式内嵌 |
<img> 上的 border-radius | 不可靠 | 把圆角直接烤进 PNG 的 alpha 通道 |
<img> 上的 max-width: Npx | 可能被忽略,回退到图片自然宽度 | 用 HTML width="N" 属性,或从共享 <table> 容器派生宽度 |
box-sizing: border-box | 不可靠 | 别用;改为通过去掉差异化的边框来对齐宽度 |
这条分界线是:凡是依赖"现代 CSS 计算"才能呈现的视觉——渐变、矢量、圆角裁剪、盒模型宽度——在 Word 引擎里都不可信。 反过来,纯粹的结构性 HTML(表格、width 属性、<img> 引用)才是可信的最大公约数。
由此推出整套契约的第一性结论:邮件正文里任何"可见的"渐变和内联 SVG,都不能指望 CSS。 凡是纯 HTML+CSS 画不出来、又必须可见的视觉元素(渐变、图表、示意图、储罐剪影),都得在服务端渲染成 PNG,再以 CID(Content-ID)内联附件的形式塞进去。这不是为了规避某个 bug,而是因为你无法控制最重要那个客户端的渲染引擎——把视觉契约从"客户端会正确解释我的 CSS"下移到"我在服务端就把像素定死",是唯一不依赖运行环境善意的层。
设计空间:契约该建在哪一层
接受了"可见视觉服务端栅格化",下一个问题是:渲染管线该怎么分层,才能让这条契约稳定地落到每一封邮件上?平台的做法是把邮件生产拆成三层,每层只干一件事:
- Builders(
*EmailBuilder.cs):拼出每种邮件的最终 HTML 字符串。 - Renderers(
*Renderer.cs):取数据、调 Builder、生成附件,返回RenderedEmail。 - Consumers / Senders:通用路径走
SendEmailConsumer,热力图路径走SendHeatmapReportConsumer,最终交给GraphEmailSender经 Microsoft Graph 发出。
视觉元素统一三步走:服务端渲染成 PNG 字节流(矢量内容用 SkiaSharp,DOM 内容用 Playwright)→ 作为内联附件挂上(带非空 ContentId)→ HTML 里用 <img src="cid:{contentId}" /> 引用。GraphEmailSender 兼容两条路径:只要附件的 ContentId 非空,就在 Graph 载荷里把它标记为 isInline = true。CID 命名约定是 {kind}-{identifier}(如 heatmap-{siteId}-{type}、legend-{variant}、tank-image-{assetId}),同一 CID 被多个 <img> 引用时要去重,别重复附同一张图。
这个分层的意义在于:"哪些视觉走 CSS、哪些走栅格"的判断,被收敛到 Builder 和 Renderer 两层,调用方不必每次重新做这个判断。契约一旦下沉成结构,就不再依赖每个写邮件的人都记得 Outlook 的怪癖。
栅格化之后:几个机制交汇处的坑
把视觉烤成 PNG 解决了"能不能显示",但随之带来一组新的机制,每一组都在交汇处藏着坑。
HiDPI:以 2× 渲染。 直接按逻辑像素渲染的图,在 Retina 屏上会糊。做法是按 2× 逻辑像素创建画布、绘制前 canvas.Scale(2, 2),而 HTML 的 width/height 属性仍写逻辑尺寸——这样图片在 Retina 上 1:1 清晰,在普通屏上被干净降采样。
private const int CanvasWidth = 140; // 逻辑像素
private const int CanvasHeight = 130;
private const int RenderScale = 2;
using var surface = SKSurface.Create(
new SKImageInfo(CanvasWidth * RenderScale, CanvasHeight * RenderScale));
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
canvas.Scale(RenderScale, RenderScale);
// ... 之后全部用逻辑坐标绘制 ...
字体:别信默认值。 Worker 跑在精简的 Docker 镜像里。SkiaSharp 画文字时若落到 SKTypeface.Default、而容器里又没装任何默认字体,Skia 只能画出空心方块(tofu)。这本质上是"它总会有个默认值吧"这种隐含环境假设在生产容器里破裂——开发机字体齐全,精简镜像没有。修法是给一条显式回退链,绝不把命运交给默认:
private static readonly SKTypeface LabelTypeface =
SKFontManager.Default.MatchFamily("DejaVu Sans")
?? SKFontManager.Default.MatchFamily("FreeSans")
?? SKFontManager.Default.MatchFamily("Arial")
?? SKTypeface.Default;
兄弟图片的宽度一致性。 热力图和它下方的图例条要等宽。别靠 max-width(Outlook 忽略它),两张图必须从同一种 HTML 机制派生宽度:要么都用 HTML width="N" 属性且放同类容器,要么都用 width="100%" 且在同一个外层 <td> / <table> 里。还有一个隐蔽的坑——别给兄弟图片配不对称的 1px 边框:默认 box-sizing: content-box 下,一个 1px 边框会给总渲染宽度加 2px,对齐立刻崩。要对齐就去掉差异化的边框,而不是靠 box-sizing: border-box(它在 Outlook 也不可靠)。
小渐变:渐进增强而非全量附件。 状态指示条这类小装饰渐变,不值得单独挂一个 PNG 附件。让渐变叠在纯色兜底之上即可——Outlook 忽略 background-image: linear-gradient(...) 而渲染纯色,现代客户端则在纯色之上叠加渐变,两边都好看:
<div
style="background-color: #ef4444;
background-image: linear-gradient(90deg, #ef4444, #b91c1c);"
></div>
这条揭示了一个有用的设计视角:栅格化不是非黑即白的开关。"值不值得为一个视觉付一个附件的代价"是一道成本题——核心可读性元素(热力图、图例)值得,纯装饰的小渐变不值得,后者用渐进增强把账付得更轻。
两个最值得单拎出来的隐式契约
栅格化管线之外,还有两处坑不属于渲染本身,而属于跨边界的隐式契约——它们正是机制交汇处最容易烂掉的地方。
跨层调色板同步。 热力图图例的渐变,必须和前端 canvas 渲染器的调色板一致——图例本质是读者用来"解码"热力图颜色的标尺,两边对不上整张图就读错了。同步点是后端的 ColorSchemeImageProvider.GetGradientStops(SKColor[])和前端 apps/web/src/features/map-core/constants.ts 里的 CANVAS_COLOR_SCHEMES,改一边必须改另一边。这是一条跨仓库的隐式契约,靠注释守不住——它最好的归宿是文档化加测试。
一个会"静默吞掉"的数据库约束。 这是全篇最值得警惕的生产陷阱。EmailLog.SentVia 被 CHECK 约束 email_logs_sent_via_check 限定为恰好两个值:'manual' 或 'cron'。写入任何其他值(如 "transactional"、"system")都会触发 Postgres 23514、插入失败。
致命的是它在生产中静默失败:SendEmailConsumer 把 EmailLog 的写入包在 try/catch 里有意吞掉 DB 失败。为什么有意?因为若让异常上抛,会触发 MassTransit 重试,进而重复发送邮件——重发邮件远比丢一条审计记录严重。于是结果是:邮件正常发出、审计行却从未落库、全程无异常。你根本不知道审计断了。
赋值规则:人触发的(UI 按钮、密码重置、手动重发)→ "manual";调度触发的(Quartz 作业、cron)→ "cron";新增事务型流程 → "manual"。约束定义在 EmailDbContext.OnModelCreating 和迁移 20260408103141_CodifyRemainingCheckConstraints,现有生产者用 SentVia = cmd.TriggerSource 赋值,所以要在派发处就把 TriggerSource 约束在这两个字面量上。防这个坑的唯一办法是测试——写一个 consumer 级集成测试,断言 EmailLog 行确实存在。光靠人眼发现不了,因为它在生产里不报错。
这两个坑共享同一种结构:一条只活在某一处代码/注释里的契约,会随着新调用点的增加而稳定地腐烂。 调色板靠"改一边记得改另一边"的默契、SentVia 靠"别写错字面量"的自觉——默契和自觉都不可执行。能拦住回归的,是把契约钉成测试。
测试策略:只测稳定的,不测易碎的像素
现有测试形态值得借鉴——它刻意只测稳定的东西,回避易碎的像素:
- Builder 测试:纯字符串输入/输出。
- Renderer / Provider 测试:断言 PNG 魔数
[0x89, 0x50, 0x4E, 0x47],不断言 Skia 的像素保真度。
该断言:HTML 含预期的 cid:{...} 引用;HTML 不含 <svg、也不含"未配对"的 linear-gradient(配对意味着同时存在 background-color 兜底);PNG 字节以魔数开头;附件 ContentId 的形状跨多次调用稳定。不该断言:精确的渲染像素值;PNG 的精确字节长度(随压缩变化);Outlook 专属渲染——自动化覆盖不了,只能在 PR 描述里标"需人工验证"。
这个取舍本身就是一条可迁移原则:断言契约的形状(有没有 CID 引用、有没有兜底色),而不是断言渲染的结果(像素长什么样)。 形状稳定、像素易碎;测形状能拦回归,测像素只会喂养一堆脆弱的快照。
可迁移的那一层
抛开 SkiaSharp 和 Outlook 的具体 API,这个案例真正可迁移的认知是:
当一段视觉/逻辑的最终呈现依赖一个你不控制的渲染环境时,把契约下移到你确实控制的那一层。 你管不了 Outlook 怎么解释 CSS,但你管得了服务端吐出什么字节——于是把可见视觉烤成 PNG,是把"对不对"从客户端的善意手里夺回到自己手里。这套思路不限于邮件:任何"目标环境是十几年前的子集、且没法升级"的场景(老旧浏览器、嵌入式 WebView、PDF 渲染器),最稳的答案都是别在不可控层做计算,在可控层把结果定死。
以及那句最该带走的话:邮件不是网页。 它跑在一群你无法控制、其中最重要的那个还停留在十几年前 CSS 子集的渲染引擎上。凡是有疑问的视觉元素,把它烤成一张服务端 PNG,几乎总是最稳妥的答案。