使用 @novu/maily-core 构建邮件编辑器:Slash 命令、变量注入与扩展机制详解
@novu/maily-core 是 Novu 开源通信基础设施中用于创建邮件内容的 WYSIWYG 编辑器核心包,它基于 Tiptap(ProseMirror)构建,为邮件模板提供"预设计、移动端友好"的编辑体验。本文以 libs/maily-core/readme.md 为骨架,结合 libs/maily-core/src 下的源码实现,系统讲解其安装接入、Slash 命令定制、变量注入、扩展机制与图片上传等核心能力,并给出与之配套的 @novu/maily-render 渲染方案,帮助你在自己的 React 应用中快速集成一套可定制、可扩展、可输出 HTML 邮件的可视化编辑能力。
安装与前置条件
该包位于仓库的 libs/maily-core 目录,包名为 @novu/maily-core,当前版本 0.2.7-novu.19-core(见 package.json)。使用 pnpm 安装:
pnpm add @novu/maily-core
# 类型依赖
pnpm add -D @tiptap/core
几点环境说明(均以仓库实际配置为准):
- Node 版本:
engines.node要求>=18.0.0。 - React 版本:peerDependencies 为
react/react-dom^18.0.0 || ^19.0.0,其中react-dom标记为可选。 - 核心依赖:底层基于
@tiptap/core、@tiptap/react、@tiptap/suggestion、@tiptap/starter-kit等 2.11.5 系列扩展,同时依赖tippy.js(变量建议浮层)、sanitize-html(HTML 消毒)、tailwindcss(内置样式)等。 - 样式导入:包通过子路径
@novu/maily-core/style.css暴露编译后的样式(对应源码中的 styles/index.css、styles/preflight.css、styles/tailwind.css),使用前必须引入。 - 子路径导出:
package.json的exports提供了三个入口:根入口.(Editor 组件)、./blocks(预置块)、./extensions(扩展),以及./style.css。
快速上手:接入 Editor 组件
最基础的用法是导入样式与 Editor 组件,传入 JSON 格式的初始内容,并通过 onCreate / onUpdate 拿到编辑器实例:
import '@novu/maily-core/style.css';
import { useState } from 'react';
import { Editor } from '@novu/maily-core';
import type { Editor as TiptapEditor, JSONContent } from '@tiptap/core';
type AppProps = {
contentJson: JSONContent;
};
function App(props: AppProps) {
const { contentJson: defaultContentJson } = props;
const [editor, setEditor] = useState<TiptapEditor>();
return (
<Editor
contentJson={defaultContentJson}
onCreate={setEditor}
onUpdate={setEditor}
/>
);
}
从 Editor 组件实现 可以看到更多内置行为与可配置项:
- 初始内容优先级:
contentJson优先于contentHtml;若两者均未提供,则默认创建一个空的doc > paragraph文档。传入的contentJson若顶层不是doc,会被自动包装为{ type: 'doc', content }。 - config 选项:支持
hasMenuBar(默认true)、spellCheck(默认false)、autofocus(默认'end')、immediatelyRender(默认false),以及wrapClassName/toolbarClassName/contentClassName/bodyClassName等样式类名覆盖。 - editable:默认
true;组件内部通过useEffect同步editor.setEditable(editable, false),因此运行时切换只读/编辑状态是安全的。 - 默认扩展:编辑器会默认装配
MailyKit、SlashCommandExtension、VariableExtension、HTMLCodeBlockExtension、InlineImageExtension、PlaceholderExtension(见 extensions/index.tsx),并对同名扩展做去重,避免重复注册。 - 占位符:placeholder.ts 定义了分级占位提示——标题显示
Heading {level},HTML 代码块显示Type your HTML code...,普通段落显示Write something or / to see commands。
Slash Commands:按分组组织命令面板
Slash 命令让你在编辑器中输入 / 加命令名即可与编辑器交互。命令按组(group)组织,每组是一个包含 title 与 commands 数组的对象,数组内的每一项是一个 BlockItem(见 blocks/types.ts)。
基本分组示例
将若干基础块(如文本、标题)归为一组:
// omitting imports
import { text, heading1 } from '@novu/maily-core/blocks';
<Editor
blocks={[
{
title: 'Basic Blocks',
commands: [text, heading1],
},
]}
/>
注意:分组的顺序以及组内命令的顺序,决定它们在编辑器命令面板中的展示顺序。例如 default-slash-commands.tsx 中默认先注册
Blocks组(text、heading1~3、列表、图片、logo、columns、section、repeat、divider、spacer、button、linkCard、hardBreak、blockquote、footer、clearLine),再注册Components组(预置 headers、footers、htmlCodeBlock)。
带子命令的分组命令块
有时你希望一个命令展开为二级命令列表。此时定义一个带 id 和 commands 数组的命令即可——id 用于 slash 命令的查询过滤,例如输入 /headers. 会展示其子命令:
// omitting imports
<Editor
blocks={[
{
title: 'Formatting',
commands: [
{
title: 'Headers',
// id 用于过滤命令,例如输入 `/headers.` 展示这些子命令
id: 'headers',
searchTerms: ['header', 'title'],
commands: [
{
title: 'Heading 1',
searchTerms: ['h1', 'heading1'],
command: ({ editor, range }) => {
// 将当前块转换为 Heading 1
},
},
{
title: 'Heading 2',
searchTerms: ['h2', 'heading2'],
command: ({ editor, range }) => {
// 将当前块转换为 Heading 2
},
},
// 继续添加更多子命令
],
},
],
},
]}
/>
注意:目前子命令只支持一层嵌套深度。
从 BlockItem 类型定义(blocks/types.ts)看,一个命令项除了 title、searchTerms,还可选提供 description、icon、preview(预览图 URL 或函数)与 render 函数。默认命令的实际实现可参考 typography.tsx(如 heading1 执行 setNode('heading', { level: 1 }))与 layout.tsx(如 repeat 执行 setRepeat()、spacer 执行 setSpacer({ height: 'sm' }))。
自定义渲染块
若要完全自定义命令的渲染结果,可给块对象传入 render 函数,它接收编辑器实例作为参数;基于编辑器状态你甚至可以返回 null 来表示不渲染任何内容:
// omitting imports
<Editor
blocks={[
{
title: 'Custom Blocks',
commands: [
{
title: 'Custom Block',
searchTerms: ['custom'],
render: (editor) => {
return <div>Custom Block</div>;
},
},
],
},
]}
/>
Slash 命令本身是一个 Tiptap 扩展:SlashCommandExtension 默认配置触发字符 char: '/',并通过 @tiptap/suggestion 的 ProseMirror 插件监听输入、弹出建议面板。
Variables:变量注入与动态建议
变量默认是必填的;将其 required 设为 false 即可变为可选。可选变量在未提供值时,编辑器会在其位置展示占位内容。
向编辑器传入变量有两种方式。
方式一:对象数组(静态建议)
以对象数组形式传入 variables 时,编辑器中输入 @ 即可触发变量的自动建议:
// (Omitted repeated imports)
import { VariableExtension, getVariableSuggestions } from '@novu/maily-core/extensions';
<Editor
extensions={[
VariableExtension.configure({
suggestions: getVariableSuggestions('@'),
variables: [{
name: 'currentTime',
required: false,
}],
}),
]}
/>
方式二:函数(动态建议)
当变量需要根据编辑器状态或其他输入动态生成时,将 variables 传为函数。函数签名(见 variable.ts 的 VariableFunctionOptions)提供 query(触发字符后的文本)、from(变量请求来源,取值为 content-variable | bubble-variable | repeat-variable)与 editor:
// (Omitted repeated imports)
import { VariableExtension, getVariableSuggestions } from '@novu/maily-core/extensions';
<Editor
extensions={[
VariableExtension.configure({
suggestions: getVariableSuggestions('@'),
variables: ({ query, from, editor }) => {
// query: 触发字符之后的文本
// from: 变量请求的上下文(repeat / variable 等)
// editor: 编辑器实例
if (from === 'repeat-variable') {
// 返回 Repeat 块 `each` 键可用的变量
return [
{ name: 'notifications' },
{ name: 'comments' },
];
}
return [
{ name: 'currentDate' },
{ name: 'currentTime', required: false },
];
},
}),
]}
/>
关键差异:传入对象数组时,Maily 会根据
query自动完成过滤;而传入函数时,过滤逻辑需要你自己实现。
从源码看,变量扩展是一个 atom 内联节点(variable.ts),其属性包括 id、label、fallback、required(默认 true),通过 data-id / data-label / data-fallback / data-required 属性与 HTML 双向序列化。建议浮层由 variable-suggestions.tsx 实现,使用 tippy.js 弹出、支持键盘上下键与 Enter 选择、Esc 关闭;getVariableSuggestions(char = '@') 返回的 items 会调用 processVariables 结合查询文本生成候选。此外变量节点还注册了 Backspace 快捷键:当光标紧贴变量时按 Backspace,会还原为触发字符 @ 以便重新输入。
Extensions:扩展编辑器能力
扩展是增强编辑器功能的主要手段,你可以通过扩展添加自定义块(block)、标记(mark),或直接扩展已有能力。
使用 MailyKit 一键装配邮件块
MailyKit 是邮件编辑的核心扩展集合。通过 MailyKit.configure(...) 可按需禁用某类节点,例如关闭 link card 节点:
// (Omitted repeated imports)
import { MailyKit, VariableExtension, getVariableSuggestions } from '@novu/maily-core/extensions';
<Editor
extensions={[
MailyKit.configure({
// 禁用 link card 节点
linkCard: false,
}),
// 扩展 Variable 扩展并自定义变量视图
VariableExtension.extend({
addNodeView() {
// 用自定义 VariableView 替换默认视图
return ReactNodeViewRenderer(VariableView, {
className: 'mly-relative mly-inline-block',
as: 'div',
});
},
}).configure({
suggestions: getVariableSuggestions(variableTriggerCharacter),
}),
]}
/>
MailyKitOptions 支持的可关闭项包括:linkCard、repeat、section、columns、column、button、spacer、logo、image、link,每一项都可传 false 或对应配置对象。MailyKit 内部还做了大量邮件友好的默认配置,例如:
- Document 内容模型被约束为
(block|columns)+; StarterKit被精细裁剪(禁用自带 heading、paragraph、horizontalRule、dropcursor、document,改用自研版本),并给 code、blockquote、列表注入mly-*样式类;- 链接默认
target='_blank'、rel='noopener noreferrer nofollow'、openOnClick: false; - 标题仅支持 h1~h3;
TextAlign作用于 paragraph、heading、footer。
注册自定义扩展
你也可以直接传入自己的 Tiptap 扩展,写法与普通 Tiptap 扩展完全一致:
// (Omitted repeated imports)
import { CustomExtension } from './extensions/custom-extension';
<Editor
extensions={[
CustomExtension.configure({
// your configuration
}),
]}
/>
注意 extensions/index.tsx 中的同名去重逻辑:若你传入的扩展与默认扩展同名,默认版本会被你的版本替换,例如 Dashboard 中正是通过重新注册 image 扩展来覆盖 MailyKit 的图片节点配置。
Image Upload:图片上传
要启用图片上传,需要向编辑器传入 ImageUploadExtension 扩展。onImageUpload 会在图片上传时被调用,你可以借此把图片上传到自己的服务器并返回最终 URL:
// (Omitted repeated imports)
import { ImageUploadExtension } from '@novu/maily-core/extensions';
<Editor
extensions={[
ImageUploadExtension.configure({
onImageUpload: async (file) => {
// 上传图片到任意位置
const url = await uploadImage(file);
return url;
},
}),
]}
/>
其实现(image-upload.ts)说明了两点细节:
- 默认允许的 MIME 类型:
image/jpeg、image/png、image/gif、image/webp、image/svg+xml,可通过allowedMimeTypes覆盖; - 未配置
onImageUpload时不注册任何 ProseMirror 插件(addProseMirrorPlugins直接返回空数组),因此上传行为完全由你提供。
将编辑内容渲染为 HTML 邮件
编辑器中产生的是 Tiptap JSONContent,真正投递邮件前需要将其渲染为 HTML。这与 @novu/maily-render 包配合使用,渲染包在仓库中的入口见 maily-render/src/index.ts。
核心用法(render.ts):
import { render } from '@novu/maily-render';
const html = await render(contentJson, {
theme: {
colors: { heading: '#111827' },
},
preview: '邮件预览文本',
pretty: true,
});
从 Maily 类实现 可以了解其渲染管线:
- 内容被映射为 React Email 组件(
Html/Body/Container/Text/Heading/Button/Section/Row/Column等),再通过@react-email/render的renderAsync输出 HTML; - 变量替换:默认情况下变量按
{{name,fallback=...}}格式输出(Liquid 风格);调用maily.setVariableValue(name, value)或setVariableValues({...})后进入"替换模式",将变量替换为真实值。仓库测试 render.test.ts 验证了:设置变量值后{{name}}被替换为实际字符串;未设置时输出默认格式化文本;还支持通过setVariableFormatter自定义变量输出格式; - 主题定制:
theme.colors支持 heading、paragraph、horizontal、footer、blockquoteBorder、codeBackground、codeText 以及 linkCard 系列颜色;theme.fontSize支持 paragraph 与 footer 的fontSize/fontStyle/fontWeight/lineHeight; - 渲染选项:
pretty(美化缩进)、plainText(纯文本输出)、noHtmlWrappingTags(不输出 Html 包裹标签),默认均为false; - 此外还提供
setLinkValue/setLinkValues批量替换链接、setPayloadValue注入 payload、setOpenTrackingPixel插入 1x1 打开跟踪像素、setMetaTags/setHtmlProps定制 meta 与 html 属性等能力。
在 Novu Dashboard 中的真实集成
除了独立使用,@novu/maily-core 也是 Novu Dashboard 邮件工作流编辑器的底层实现。在 apps/dashboard/src/components/maily/maily.tsx 中可以看到生产级集成方式:
- 通过
createVariableNodeView传入自定义变量节点视图,把变量渲染成校验过的 pill 样式; - 通过
extensions传入RepeatExtension.extend(...)等扩展,自动为 Repeat 块挑选 payload 数组作为each默认值(见 maily-config.tsx 中payload.前缀变量的扫描逻辑); - 通过
imageExtensionOptions区分聊天场景与邮件场景的图片默认对齐方式与尺寸限制; - 通过
onCreateNewVariable在用户选择未存在于 payload schema 中的变量时动态创建新变量。
这些用法说明,Maily 编辑器在设计上充分考虑了"被宿主应用深度定制"的场景:变量、块、菜单、扩展、节点视图均可按需替换。
小结
本文从安装、基础用法、Slash 命令、变量注入、扩展机制、图片上传到 HTML 渲染,完整覆盖了 @novu/maily-core 的核心 API。实践要点可归纳为:
- 记得先
import '@novu/maily-core/style.css',并通过./blocks、./extensions子路径按需引入块与扩展; - Slash 命令按"组 → 命令 →(可选)子命令"组织,子命令仅支持一层,
searchTerms影响搜索命中; - 变量数组形式由 Maily 自动过滤,函数形式需自行过滤;
required: false可在缺失时展示占位; MailyKit.configure({ xxx: false })是裁剪默认节点集的快捷开关;- 编辑内容(JSONContent)交给 @novu/maily-render 即可得到兼容邮件客户端的 HTML,并支持主题、预览文本、变量与链接替换、跟踪像素等高级配置。
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 StartedRust4.2 K634
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown300
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java101
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java60
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript60
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python280