基于 @novu/maily-render 将 Maily 内容转换为 HTML 邮件模板:渲染 API、变量替换与主题定制完全指南
@novu/maily-render 是 Novu 仓库中负责把 Maily 编辑器产出的 JSON 内容(ProseMirror/TipTap 文档结构)转换为可直接发送的 HTML 邮件模板的核心渲染库。它底层基于 React Email 组件体系,通过将 JSON 节点树映射为 @react-email/components 组件,再借助 @react-email/render 输出 HTML 字符串。读完本文,你将掌握 render() 函数与 Maily 类两种渲染方式、变量(Variable)与载荷(Payload)的替换机制、Repeat/Show If 动态块、链接替换、主题定制以及渲染选项的完整用法,并能结合仓库源码理解其内部实现原理。
本库源码位于 libs/maily-render,核心实现集中在 src/maily.tsx(约 1800 行的节点渲染器)、src/render.ts(render 快捷函数)、src/meta.tsx(meta 标签处理)与 src/render.test.ts(测试用例),文档主体即 readme.md。
一、安装与工程概况
在命令行安装:
pnpm add @novu/maily-render
该包在 package.json 中声明为 private: true,版本为 0.1.3-novu.8-render,说明它当前以工作区内部包的形式存在(由 pnpm workspace 管理),同时 publishConfig.access 为 public,具备对外发布的能力。构建产物同时输出 CJS 与 ESM 两种格式(dist/index.js 与 dist/index.mjs),并附带类型声明 dist/index.d.ts,构建由 tsup 完成(见 tsup.config.ts),react 被标记为 external。
关键依赖如下(见 package.json):
| 依赖 | 作用 |
|---|---|
@react-email/components |
提供 Html、Body、Container、Text、Heading、Button、Img、Section、Row、Column、Link 等邮件组件 |
@react-email/render |
将 React 组件树渲染为 HTML 字符串(renderAsync) |
@tiptap/core |
提供 JSONContent 类型,即 Maily 文档的 JSON 结构类型 |
juice |
将 CSS 内联到 HTML(用于 HTML 代码块节点) |
node-html-parser |
解析并清洗 HTML 代码块内容 |
运行时要求 Node.js >= 18.0.0,peer 依赖为 react 与 react-dom(^18 || ^19),其中 react-dom 可选。
二、核心概念:Maily 文档即 JSONContent
Maily 渲染器的输入是一个标准的 TipTap/ProseMirror JSON 文档(即 JSONContent 类型,从 @tiptap/core 导出,见 src/index.ts)。最简单的文档结构如下:
{
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello World!' },
],
},
],
}
从源码 renderNode(src/maily.tsx)可以看出,渲染器会根据节点的 type 动态调用 Maily 类中同名的方法(paragraph、heading、button、image、section、columns、repeat 等),若节点类型不支持则抛出 Node type "xxx" is not supported. 错误。因此,只要掌握了节点名,就能推断出对应的渲染方法。
2.1 支持的节点(Node)类型
从 src/maily.tsx 的实现可以整理出渲染器支持的节点集合:
- 文本与排版:
paragraph、text、heading(h1/h2/h3)、horizontalRule(<Hr/>)、hardBreak(<br/>)、spacer(指定像素高度的占位)、footer(使用主题 footer 颜色与字号)、blockquote、listItem、orderedList、bulletList - 媒体与交互:
button、logo、image、inlineImage、linkCard、htmlCodeBlock(把原始 HTML 经juice内联 CSS 后注入) - 布局:
section(带背景、边框、圆角、内边距的容器)、columns/column(多列布局,自动计算列宽与列间距) - 动态内容:
variable(变量)、repeat(遍历数组重复渲染,旧名for为其别名)、show条件(通过showIfKey控制显隐)
section 与 column 的默认样式常量均以 DEFAULT_* 形式导出,例如默认背景色 #ffffff、圆角 6px、内边距 8px,columns 默认列间距 8px(见 src/maily.tsx),方便使用者对齐编辑器中的默认值。
2.2 支持的标记(Mark)类型
文本节点的 marks 数组按固定顺序 ['underline', 'bold', 'italic', 'textStyle', 'link'] 排序后逐层包裹(见 src/maily.tsx 与 renderMark 实现),支持 bold(<strong>)、italic(<em>)、underline(<u>)、strike(删除线)、textStyle(颜色)、link(链接)、code(行内代码,使用等宽字体与背景色)。
三、快速开始:将 JSON 渲染为 HTML
render 是一个开箱即用的异步函数,接受 JSON 内容与可选配置,返回 HTML 字符串(实现见 src/render.ts):
import { render } from '@novu/maily-render';
const html = await render({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello World!',
},
],
},
],
});
render 内部等价于以下操作:
const maily = new Maily(content);
maily.setPreviewText(preview); // 设置预览文本(可选)
maily.setTheme(theme || {}); // 设置主题(可选)
return maily.render(rest); // 透传其余 RenderOptions
也就是说,render(content, { theme, preview, ...rest }) 中传入的 theme 与 preview 会被拆出分别交给 setTheme 与 setPreviewText,其余选项(如 plainText)直接传给 maily.render()。这条调用链说明 render 是「一次性渲染」的轻量入口,而 Maily 类是「可配置、可复用」的完整渲染器。
四、变量(Variables)替换
邮件模板通常需要把占位符替换为真实数据。Maily 类提供了变量管理 API:
import { Maily } from '@novu/maily-render';
const maily = new Maily({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { textAlign: 'left' },
content: [
{
type: 'variable',
attrs: {
id: 'currentDate',
fallback: 'now',
showIfKey: null,
},
},
],
},
],
});
maily.setVariableValue('currentDate', new Date().toISOString());
const html = await maily.render();
4.1 变量节点的关键属性
id:变量名,必填。渲染时通过variableValues映射查找对应值。fallback:兜底值。当变量未被替换且shouldReplaceVariableValues为真时使用。showIfKey:可选,与showIfKey条件渲染共用机制(见第六节),为null表示无条件显示。
4.2 变量的解析顺序与格式化
源码中的 getVariableValue(src/maily.tsx)揭示了取值优先级:
- 若当前处于
Repeat循环内且存在payloadValue(对象形式),优先取payloadValue[variable]; - 否则取
variableValues中通过setVariableValue设置的值; - 再否则取
fallback; - 最终兜底为「格式化后的变量占位符」。
默认的 variableFormatter 把变量格式化为模板占位符字符串:有 fallback 时输出 {{name,fallback=Buddy}},无 fallback 时输出 {{name}}。该格式化行为可以通过 setVariableFormatter 完全自定义,例如:
maily.setVariableFormatter(({ variable, fallback }) => {
return fallback ? `[${variable},fallback=${fallback}]` : `[${variable}]`;
});
测试 src/render.test.ts 中对上述行为有完整断言:设置值后渲染为 John Doe;未设置值时输出 {{name,fallback=Buddy}};自定义格式化后输出 [name,fallback=Buddy];仅开启替换而未设置值时输出兜底值 Buddy。
4.3 变量替换的开关
setVariableValue/setVariableValues/setPayloadValue 在调用时会自动把 shouldReplaceVariableValues 置为 true;也可以手动调用 setShouldReplaceVariableValues(false) 关闭替换,此时渲染结果会保留 {{variable}} 形式的占位符——这在生成模板预览、交由上层模板引擎二次处理时非常有用。
4.4 批量设置与链接变量
setVariableValues(values: Record<string, string>):一次性注入多个变量。setLinkValue(link, value)/setLinkValues(values):把文档中的链接替换为目标地址。源码中链接解析(getAllLinks)会遍历button节点的attrs.url与文本linkmark 的attrs.href,剔除#、mailto:、tel:前缀及非法 URL 后返回一个Set,便于调用方批量准备替换值。测试用例验证了setLinkValue('https://maily.to', 'https://maily.to/playground')后链接被替换。
4.5 变量 URL 与按钮文本变量
button 节点支持 isUrlVariable / isTextVariable、image/logo 节点支持 isSrcVariable、image 支持 isExternalLinkVariable:为 true 时,属性值被当作变量名解析(先查 payloadValue,再查 variableValues)。测试中通过 setVariableValue('unsubscribe_url', '...') 配合 url: 'unsubscribe_url', isUrlVariable: true 实现退订链接的按需替换(见 src/render.test.ts)。
五、载荷(Payloads)与动态块:Repeat 与 Show If
Payload 是驱动邮件中动态区块的数据源,专门服务于 Repeat(重复块)与 Show If(条件块)。
5.1 Repeat 重复块
// (省略重复的 import)
const maily = new Maily({
type: 'doc',
content: [
{
type: 'repeat',
attrs: { each: 'items', showIfKey: null },
content: [
{
type: 'paragraph',
attrs: { textAlign: 'left' },
content: [{ type: 'text', text: 'Hello' }],
},
],
},
],
});
maily.setPayloadValue('items', ['Alice', 'Bob', 'Charlie']);
const html = await maily.render();
repeat 节点的 attrs.each 指定载荷键名,渲染逻辑(src/maily.tsx)为:
- 从
payloadValues(或外层payloadValue)中取出数组,非数组时抛出Payload value for each "xxx" is not an array; iterations属性可限制渲染次数,为0时渲染全部元素,否则按values[i % values.length]循环取值补足到指定次数;- 每个元素会作为
payloadValue传入子节点,因此循环体内的variable节点可以引用当前元素(测试中变量 id 为$value时输出当前项,见 src/render.test.ts)。
旧节点名 for 是 repeat 的别名(标注为 @deprecated),二者行为一致。
5.2 Show If 条件块
任意节点(如 paragraph、section、button、spacer、repeat)都可以携带 attrs.showIfKey。shouldShow(src/maily.tsx)的判定为:showIfKey 为空则始终显示;否则取 payloadValues.get(showIfKey) 或当前 payloadValue[showIfKey] 的真值性决定是否渲染。因此:
maily.setPayloadValue('showDiscountSection', true); // 渲染该区块
maily.setPayloadValue('showDiscountSection', false); // 区块被整体跳过
六、Maily 类完整 API 一览
Maily 类(src/maily.tsx)除上述变量/载荷方法外,还提供以下能力:
| 方法 | 说明 |
|---|---|
setPreviewText(preview?) |
设置邮件客户端收件箱预览文本(<Preview>,紧随主题行之后显示) |
setTheme(theme) |
合并自定义主题,与默认主题做深度合并(deepMerge) |
setMetaTags(meta) |
追加 meta 标签(自动去重) |
setHtmlProps(props) |
设置 <Html> 根标签属性(默认 lang: 'en'、dir: 'ltr') |
setOpenTrackingPixel(pixel?) |
注入 1×1 的打开追踪像素图 |
getAllLinks() |
收集文档内全部有效外链(供替换/审批用) |
markup({ noHtmlWrappingTags }) |
返回未经 HTML 序列化的 React 组件树 |
render(options) |
异步输出最终 HTML 字符串 |
6.1 渲染选项(RenderOptions)
render 方法接受以下选项(默认值见 src/maily.tsx):
pretty(默认false):输出带缩进与换行的格式化 HTML;plainText(默认false):输出纯文本而非 HTML,便于测试与断言(测试用例大量使用该模式);noHtmlWrappingTags(默认false):为true时省略<Html>/<Head>/<Body>/<Container>包裹,只输出<Preview>(若有)与内容节点,常用于把渲染结果嵌入更大的邮件布局。
6.2 主题(Theme)
主题对象分为 colors 与 fontSize 两块(定义见 src/maily.tsx):
{
colors: {
heading: '#111827',
paragraph: '#374151',
horizontal: '#EAEAEA',
footer: '#64748B',
blockquoteBorder: '#374151',
codeBackground: '#EFEFEF',
codeText: '#111827',
linkCardTitle: '#111827',
linkCardDescription: '#6B7280',
linkCardBadgeText: '#111827',
linkCardBadgeBackground: '#FEF08A',
linkCardSubTitle: '#6B7280',
},
fontSize: {
paragraph: { fontSize: '14px', fontStyle: 'normal', fontWeight: 500, lineHeight: '20px' },
footer: { fontSize: '14px', fontStyle: 'normal', fontWeight: 500, lineHeight: '20px' },
},
}
setTheme 使用 deepMerge 与默认主题合并,因此可以只覆盖个别键:
const maily = new Maily(content);
maily.setTheme({
colors: { heading: '#111827' },
fontSize: { footer: { fontSize: '14px', lineHeight: '24px' } },
});
测试用例验证了自定义主题会实际反映到输出:标题颜色 rgb(255, 0, 0)、段落颜色 rgb(0, 255, 0)、字号 18px 均出现在最终 HTML 中(见 src/render.test.ts)。
七、默认输出结构:邮件外壳与移动端适配
当 noHtmlWrappingTags 为 false 时(src/maily.tsx),渲染器会生成完整的邮件外壳:
<Html lang="en" dir="ltr">根元素,属性可通过setHtmlProps覆盖;<Head>内注入一段全局 CSS:清除blockquote/h1/h2/h3/img/li/ol/p/ul的默认外边距,并在max-width: 425px视口下将.tab-row-full置为全宽、.tab-col-full切换为块级、.tab-pad清除内边距——这是columns布局在移动端的响应式降级方案;- 默认 meta 标签(见 src/maily.tsx):
viewport、X-UA-Compatible、x-apple-disable-message-reformatting、format-detection(禁用 iOS 自动识别电话/地址/邮箱/日期/URL)、color-scheme: light等,均可通过setMetaTags追加;meta()函数(src/meta.tsx)会按键排序哈希去重; <Body>内是最大宽度600px、最小300px、带1rem内边距的Container,邮件正文节点依次渲染;- 打开追踪像素(若设置)以
display: none的 1×1Img形式追加在 Body 末尾。
八、在 Novu 中的真实应用:邮件输出渲染器
@novu/maily-render 并非孤立存在,它已被 Novu 的邮件渲染链路直接使用。在 email-output-renderer.usecase.ts 中可以看到:
import { JSONContent as MailyJSONContent, render as mailyRender } from '@novu/maily-render';
// ...
const renderedMaily = await mailyRender(parsedMaily, { noHtmlWrappingTags });
return decodeHTML(renderedMaily);
整个流程(同文件的 renderWithLayout)大致为:先通过 replaceMailyNodesByCondition 处理条件节点,再经 wrapMailyInLiquid 与 transformMailyContent 将 Liquid 模板语言处理到 Maily 内容中,配合 processMailyTranslations 做多语言翻译,最后 parseMailyContentByLiquid 解析出最终 JSON 并调用 mailyRender(parsedMaily, { noHtmlWrappingTags }) 渲染。这里使用 noHtmlWrappingTags: true 正是为了把渲染结果嵌入到外层布局模板中。相关辅助逻辑还出现在 base-translation-renderer.usecase.ts 与 preview-utils.ts 中,测试覆盖见 email-output-renderer.spec.ts。
这一应用示例说明:Maily JSON 内容与上层模板引擎(Liquid)可以无缝衔接——先用模板引擎处理含占位符的文档,再由 @novu/maily-render 完成最终 HTML 输出,而 noHtmlWrappingTags 为这种「分步组装」提供了关键支持。
九、小结与最佳实践
- 轻量单次渲染用
render(content, { theme, preview, plainText });需要变量/载荷/链接注入、追踪像素、meta 定制时使用Maily类实例,因为只有在实例上才能调用setVariableValue、setPayloadValue等系列方法。 - 变量适合单值替换,Payload 适合数组循环(
Repeat)与条件显隐(Show If);两者的值解析都遵循「循环内 payloadValue 优先 → 显式设置值 → fallback → 格式化占位符」的顺序。 - 测试友好:利用
plainText: true可直接对渲染结果做快照断言,仓库自身的测试即采用toMatchInlineSnapshot方式。 - 嵌入布局:需要把渲染片段拼进外层模板时,务必开启
noHtmlWrappingTags,这正是 Novu 邮件输出渲染器的实际用法。 - 模板预览:不注入真实数据(不调用
setVariableValue/setPayloadValue)即可输出{{variable,fallback=...}}形式的可读占位符,便于人工审阅模板结构。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00