首页
/ 基于 @novu/maily-render 将 Maily 内容转换为 HTML 邮件模板:渲染 API、变量替换与主题定制完全指南

基于 @novu/maily-render 将 Maily 内容转换为 HTML 邮件模板:渲染 API、变量替换与主题定制完全指南

2026-09-09 22:13:29作者:毕习沙Eudora

@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.tsrender 快捷函数)、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.accesspublic,具备对外发布的能力。构建产物同时输出 CJS 与 ESM 两种格式(dist/index.jsdist/index.mjs),并附带类型声明 dist/index.d.ts,构建由 tsup 完成(见 tsup.config.ts),react 被标记为 external。

关键依赖如下(见 package.json):

依赖 作用
@react-email/components 提供 HtmlBodyContainerTextHeadingButtonImgSectionRowColumnLink 等邮件组件
@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 依赖为 reactreact-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!' },
      ],
    },
  ],
}

从源码 renderNodesrc/maily.tsx)可以看出,渲染器会根据节点的 type 动态调用 Maily 类中同名的方法(paragraphheadingbuttonimagesectioncolumnsrepeat 等),若节点类型不支持则抛出 Node type "xxx" is not supported. 错误。因此,只要掌握了节点名,就能推断出对应的渲染方法。

2.1 支持的节点(Node)类型

src/maily.tsx 的实现可以整理出渲染器支持的节点集合:

  • 文本与排版:paragraphtextheading(h1/h2/h3)、horizontalRule<Hr/>)、hardBreak<br/>)、spacer(指定像素高度的占位)、footer(使用主题 footer 颜色与字号)、blockquotelistItemorderedListbulletList
  • 媒体与交互:buttonlogoimageinlineImagelinkCardhtmlCodeBlock(把原始 HTML 经 juice 内联 CSS 后注入)
  • 布局:section(带背景、边框、圆角、内边距的容器)、columns/column(多列布局,自动计算列宽与列间距)
  • 动态内容:variable(变量)、repeat(遍历数组重复渲染,旧名 for 为其别名)、show 条件(通过 showIfKey 控制显隐)

sectioncolumn 的默认样式常量均以 DEFAULT_* 形式导出,例如默认背景色 #ffffff、圆角 6px、内边距 8pxcolumns 默认列间距 8px(见 src/maily.tsx),方便使用者对齐编辑器中的默认值。

2.2 支持的标记(Mark)类型

文本节点的 marks 数组按固定顺序 ['underline', 'bold', 'italic', 'textStyle', 'link'] 排序后逐层包裹(见 src/maily.tsxrenderMark 实现),支持 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 }) 中传入的 themepreview 会被拆出分别交给 setThemesetPreviewText,其余选项(如 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 变量的解析顺序与格式化

源码中的 getVariableValuesrc/maily.tsx)揭示了取值优先级:

  1. 若当前处于 Repeat 循环内且存在 payloadValue(对象形式),优先取 payloadValue[variable]
  2. 否则取 variableValues 中通过 setVariableValue 设置的值;
  3. 再否则取 fallback
  4. 最终兜底为「格式化后的变量占位符」。

默认的 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 与文本 link mark 的 attrs.href,剔除 #mailto:tel: 前缀及非法 URL 后返回一个 Set,便于调用方批量准备替换值。测试用例验证了 setLinkValue('https://maily.to', 'https://maily.to/playground') 后链接被替换。

4.5 变量 URL 与按钮文本变量

button 节点支持 isUrlVariable / isTextVariableimage/logo 节点支持 isSrcVariableimage 支持 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)。

旧节点名 forrepeat 的别名(标注为 @deprecated),二者行为一致。

5.2 Show If 条件块

任意节点(如 paragraphsectionbuttonspacerrepeat)都可以携带 attrs.showIfKeyshouldShowsrc/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)

主题对象分为 colorsfontSize 两块(定义见 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)。

七、默认输出结构:邮件外壳与移动端适配

noHtmlWrappingTagsfalse 时(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):viewportX-UA-Compatiblex-apple-disable-message-reformattingformat-detection(禁用 iOS 自动识别电话/地址/邮箱/日期/URL)、color-scheme: light 等,均可通过 setMetaTags 追加;meta() 函数(src/meta.tsx)会按键排序哈希去重;
  • <Body> 内是最大宽度 600px、最小 300px、带 1rem 内边距的 Container,邮件正文节点依次渲染;
  • 打开追踪像素(若设置)以 display: none 的 1×1 Img 形式追加在 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 处理条件节点,再经 wrapMailyInLiquidtransformMailyContent 将 Liquid 模板语言处理到 Maily 内容中,配合 processMailyTranslations 做多语言翻译,最后 parseMailyContentByLiquid 解析出最终 JSON 并调用 mailyRender(parsedMaily, { noHtmlWrappingTags }) 渲染。这里使用 noHtmlWrappingTags: true 正是为了把渲染结果嵌入到外层布局模板中。相关辅助逻辑还出现在 base-translation-renderer.usecase.tspreview-utils.ts 中,测试覆盖见 email-output-renderer.spec.ts

这一应用示例说明:Maily JSON 内容与上层模板引擎(Liquid)可以无缝衔接——先用模板引擎处理含占位符的文档,再由 @novu/maily-render 完成最终 HTML 输出,而 noHtmlWrappingTags 为这种「分步组装」提供了关键支持。

九、小结与最佳实践

  • 轻量单次渲染render(content, { theme, preview, plainText })需要变量/载荷/链接注入、追踪像素、meta 定制时使用 Maily 类实例,因为只有在实例上才能调用 setVariableValuesetPayloadValue 等系列方法。
  • 变量适合单值替换,Payload 适合数组循环(Repeat)与条件显隐(Show If);两者的值解析都遵循「循环内 payloadValue 优先 → 显式设置值 → fallback → 格式化占位符」的顺序。
  • 测试友好:利用 plainText: true 可直接对渲染结果做快照断言,仓库自身的测试即采用 toMatchInlineSnapshot 方式。
  • 嵌入布局:需要把渲染片段拼进外层模板时,务必开启 noHtmlWrappingTags,这正是 Novu 邮件输出渲染器的实际用法。
  • 模板预览:不注入真实数据(不调用 setVariableValue/setPayloadValue)即可输出 {{variable,fallback=...}} 形式的可读占位符,便于人工审阅模板结构。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
899
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525