Loading...
Loading...
Compare original and translation side by side
@novu/frameworkUse this skill when building workflows in code. For workflows authored in the Novu Dashboard, just trigger them via— no Framework needed.trigger-notification
@novu/framework当你需要在代码中构建工作流时使用此技能。若工作流是在Novu仪表盘中创建的,只需通过触发即可——无需使用Framework。trigger-notification
| Use Framework | Use Dashboard Workflows |
|---|---|
| Workflows must live in source control / GitOps | Non-technical peers own all the content |
| Need just-in-time data from your DB / APIs | All data fits in the trigger payload |
| Render emails with React/Vue/Svelte Email | Block editor is enough |
| Execute custom code (LLMs, third-party APIs) | Pure send-only flows |
| Need typed payload + step controls | Quick prototype |
| 使用框架 | 使用仪表盘工作流 |
|---|---|
| 工作流必须纳入版本控制/GitOps管理 | 非技术人员全权负责所有内容 |
| 需要从数据库/API获取实时数据 | 所有数据均可放入触发负载中 |
| 使用React/Vue/Svelte Email渲染邮件 | 区块编辑器已满足需求 |
| 需要执行自定义代码(LLM、第三方API) | 仅需纯发送流 |
| 需要类型化负载+步骤控制 | 快速原型开发 |
workflow(...)@novu/framework/api/novunpx novu syncTrigger ──► Novu Cloud Worker ──► Your Bridge (/api/novu) ──► Provider (SendGrid, FCM, …)@novu/frameworkworkflow(...)/api/novunpx novu sync触发 ──► Novu Cloud Worker ──► 你的Bridge (/api/novu) ──► 服务商(SendGrid、FCM等)npx novu init --secret-key=<YOUR_NOVU_SECRET_KEY>/api/novunpx novu init --secret-key=<YOUR_NOVU_SECRET_KEY>/api/novunpm install @novu/framework zod @react-email/components react-emailNOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>npm install @novu/framework zod @react-email/components react-emailNOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>import { workflow } from "@novu/framework";
import { z } from "zod";
export const welcomeWorkflow = workflow(
"welcome-email",
async ({ step, payload, subscriber }) => {
await step.email("send-email", async (controls) => {
return {
subject: controls.subject,
body: `Welcome ${subscriber.firstName ?? payload.userName}!`,
};
}, {
controlSchema: z.object({
subject: z.string().default("Welcome to {{payload.appName}}"),
}),
});
},
{
payloadSchema: z.object({
userName: z.string(),
appName: z.string().default("Acme"),
}),
name: "Welcome Email",
description: "Sent when a new user signs up",
tags: ["onboarding"],
}
);import { workflow } from "@novu/framework";
import { z } from "zod";
export const welcomeWorkflow = workflow(
"welcome-email",
async ({ step, payload, subscriber }) => {
await step.email("send-email", async (controls) => {
return {
subject: controls.subject,
body: `欢迎 ${subscriber.firstName ?? payload.userName}!`,
};
}, {
controlSchema: z.object({
subject: z.string().default("欢迎来到 {{payload.appName}}"),
}),
});
},
{
payloadSchema: z.object({
userName: z.string(),
appName: z.string().default("Acme"),
}),
name: "欢迎邮件",
description: "新用户注册时发送",
tags: ["onboarding"],
}
);npx novu@latest dev --port <YOUR_APP_PORT>http://localhost:2022npx novu@latest dev --port <YOUR_APP_PORT>http://localhost:2022Designing the workflow itself? Seefor channel selection, severity,design-workflow/, digest defaults, step conditions, and the 9 reference templates (order confirmation, payment failed, account suspended, comment, trial expiring, password reset, webhook fan-out, fetch-then-notify). The Framework SKILL covers how to express those decisions in code;criticalcovers what to decide.design-workflow/
workflow(workflowId, handler, options);| Param | Type | Description |
|---|---|---|
| | Unique identifier in your environment |
| | Workflow body — calls steps in order |
| | Schema, name, description, tags, preferences |
想要设计工作流本身?查看了解渠道选择、优先级、design-workflow/、默认摘要、步骤条件以及9个参考模板(订单确认、支付失败、账户暂停、评论、试用到期、密码重置、Webhook扇出、先获取再通知)。Framework SKILL涵盖如何在代码中表达这些决策;critical则涵盖需要做出哪些决策。design-workflow/
workflow(workflowId, handler, options);| 参数 | 类型 | 描述 |
|---|---|---|
| | 环境中的唯一标识符 |
| | 工作流主体——按顺序调用步骤 |
| | schema、名称、描述、标签、偏好设置 |
| Option | Type | Purpose |
|---|---|---|
| | Validates the trigger payload, infers TS type for |
| | Human-readable name shown in Dashboard / |
| | Description shown in Dashboard |
| | Categorize for filtering / Inbox tabs |
| | Visual prioritization in the Inbox. Leave unset for most workflows. |
| | Bypasses subscriber preferences, skips digest, runs without delays. Reserve for must-deliver events. |
| | Default channel preferences and |
| 选项 | 类型 | 用途 |
|---|---|---|
| | 验证触发负载,为 |
| | 在仪表盘/ |
| | 在仪表盘中显示的描述 |
| | 分类以便过滤/收件箱标签页 |
| | 收件箱中的视觉优先级。大多数工作流无需设置。 |
| | 绕过订阅者偏好,跳过摘要,无延迟运行。仅用于必须送达的事件。 |
| | 默认渠道偏好和 |
{ step, payload, subscriber }stepstep.emailstep.delaystep.digestpayloadpayloadSchemasubscriber{ subscriberId, firstName?, lastName?, locale?, data?, ... }{ step, payload, subscriber }stepstep.emailstep.delaystep.digestpayloadpayloadSchemasubscriber{ subscriberId, firstName?, lastName?, locale?, data?, ... }await step.<channel>(stepId, resolver, options?);| Step | Output Required | Notable Outputs | Returns Result |
|---|---|---|---|
| | | No |
| | — | No |
| | | No |
| | — (override per-provider) | No |
| | | |
await step.<channel>(stepId, resolver, options?);| 步骤 | 必填输出 | 值得关注的输出 | 返回结果 |
|---|---|---|---|
| | | 否 |
| | — | 否 |
| | | 否 |
| | —(按服务商覆盖) | 否 |
| | | |
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<WelcomeEmail name={subscriber.firstName} />),
from: "hello@acme.com",
replyTo: "support@acme.com",
}));await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<WelcomeEmail name={subscriber.firstName} />),
from: "hello@acme.com",
replyTo: "support@acme.com",
}));await step.inApp("inbox", async () => ({
subject: "Welcome to Acme!",
body: "We are excited to have you on board.",
avatar: "https://acme.com/avatar.png",
redirect: { url: "/welcome", target: "_self" },
primaryAction: {
label: "Get Started",
redirect: { url: "/get-started", target: "_self" },
},
data: { entityType: "user", entityId: payload.userId },
}));{ seen, read, lastSeenDate, lastReadDate }skipawait step.inApp("inbox", async () => ({
subject: "欢迎来到Acme!",
body: "我们很高兴你能加入。",
avatar: "https://acme.com/avatar.png",
redirect: { url: "/welcome", target: "_self" },
primaryAction: {
label: "开始使用",
redirect: { url: "/get-started", target: "_self" },
},
data: { entityType: "user", entityId: payload.userId },
}));{ seen, read, lastSeenDate, lastReadDate }skipawait step.sms("verification", async () => ({
body: `Your code is ${payload.code}`,
}));
await step.push("new-message", async () => ({
title: "New message",
body: payload.preview,
data: { messageId: payload.id },
}));
await step.chat("notify", async () => ({
body: `:rocket: Deploy ${payload.id} succeeded`,
}));await step.sms("verification", async () => ({
body: `你的验证码是 ${payload.code}`,
}));
await step.push("new-message", async () => ({
title: "新消息",
body: payload.preview,
data: { messageId: payload.id },
}));
await step.chat("notify", async () => ({
body: `:rocket: 部署 ${payload.id} 成功`,
}));step.delaystep.delayawait step.delay("wait-a-day", async () => ({
unit: "days",
amount: 1,
}));unitsecondsminuteshoursdaysweeksmonthsawait step.delay("wait-a-day", async () => ({
unit: "days",
amount: 1,
}));unitsecondsminuteshoursdaysweeksmonthsstep.digeststep.digestconst { events } = await step.digest("daily", async () => ({
unit: "days",
amount: 1,
digestKey: payload.projectId, // optional — group by custom key
}));
await step.email("summary", async () => ({
subject: `${events.length} updates today`,
body: render(<DigestEmail events={events} />),
}));cron: "0 0 * * *"unitamount{ id, time, payload }step.customconst { events } = await step.digest("daily", async () => ({
unit: "days",
amount: 1,
digestKey: payload.projectId, // 可选——按自定义键分组
}));
await step.email("summary", async () => ({
subject: `${events.length} 条今日更新`,
body: render(<DigestEmail events={events} />),
}));cron: "0 0 * * *"unitamount{ id, time, payload }step.customstep.httpstep.httpconst plan = await step.http("fetch-plan", async () => ({
method: "GET",
url: `https://api.example.com/users/${payload.userId}/plan`,
responseBodySchema: {
type: "object",
properties: { planName: { type: "string" }, renewalDate: { type: "string" } },
required: ["planName", "renewalDate"],
} as const,
}));
await step.email("notify", async () => ({
subject: `Your ${plan.planName} plan`,
body: `Renews on ${plan.renewalDate}.`,
}));await step.http("webhook", async () => ({
method: "POST",
url: payload.webhookUrl,
headers: [{ key: "Content-Type", value: "application/json" }],
body: [
{ key: "event", value: "payment_failed" },
{ key: "subscriberId", value: subscriber.subscriberId },
],
continueOnFailure: true,
}));responseBodySchema{{ steps.<http-step-id>.<property> }}const plan = await step.http("fetch-plan", async () => ({
method: "GET",
url: `https://api.example.com/users/${payload.userId}/plan`,
responseBodySchema: {
type: "object",
properties: { planName: { type: "string" }, renewalDate: { type: "string" } },
required: ["planName", "renewalDate"],
} as const,
}));
await step.email("notify", async () => ({
subject: `你的 ${plan.planName} 套餐`,
body: `续费日期为 ${plan.renewalDate}。`,
}));await step.http("webhook", async () => ({
method: "POST",
url: payload.webhookUrl,
headers: [{ key: "Content-Type", value: "application/json" }],
body: [
{ key: "event", value: "payment_failed" },
{ key: "subscriberId", value: subscriber.subscriberId },
],
continueOnFailure: true,
}));responseBodySchema{{ steps.<http-step-id>.<property> }}step.customstep.customconst task = await step.custom("fetch-task", async () => {
const t = await db.fetchTask(payload.taskId);
return { id: t.id, title: t.title, complete: t.complete };
}, {
outputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
complete: { type: "boolean" },
},
required: ["id", "complete"],
} as const,
});
await step.email("reminder", async () => ({
subject: `Reminder: ${task.title}`,
body: "Please complete your task.",
}), {
skip: () => task.complete,
});resolverprovidersskipconst task = await step.custom("fetch-task", async () => {
const t = await db.fetchTask(payload.taskId);
return { id: t.id, title: t.title, complete: t.complete };
}, {
outputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
complete: { type: "boolean" },
},
required: ["id", "complete"],
} as const,
});
await step.email("reminder", async () => ({
subject: `提醒:${task.title}`,
body: "请完成你的任务。",
}), {
skip: () => task.complete,
});resolverprovidersskipawait step.email(stepId, resolver, {
controlSchema, // Zod | JSON Schema | Class-Validator class
skip, // (controls) => boolean | Promise<boolean>
providers, // per-provider override callbacks
disableOutputSanitization, // boolean — for raw HTML in Inbox
});await step.email(stepId, resolver, {
controlSchema, // Zod | JSON Schema | Class-Validator类
skip, // (controls) => boolean | Promise<boolean>
providers, // 按服务商覆盖回调
disableOutputSanitization, // boolean——允许收件箱中的原始HTML
});skipskipawait step.email("follow-up", resolver, {
skip: () => inAppNotification.read === true,
});await step.email("follow-up", resolver, {
skip: () => inAppNotification.read === true,
});providersprovidersblocksccawait step.email("alert", resolver, {
providers: {
sendgrid: ({ controls, outputs }) => ({
from: "alerts@acme.com",
cc: ["ops@acme.com"],
_passthrough: {
body: { ip_pool_name: "transactional" },
headers: { "X-Custom": "value" },
},
}),
},
});_passthrough_passthroughblocksccawait step.email("alert", resolver, {
providers: {
sendgrid: ({ controls, outputs }) => ({
from: "alerts@acme.com",
cc: ["ops@acme.com"],
_passthrough: {
body: { ip_pool_name: "transactional" },
headers: { "X-Custom": "value" },
},
}),
},
});_passthrough_passthroughdisableOutputSanitizationdisableOutputSanitization&data.linkawait step.inApp("link", async () => ({
body: "Check it out",
data: { link: "/p/123?active=true&env=prod" },
}), { disableOutputSanitization: true });data.link&await step.inApp("link", async () => ({
body: "查看详情",
data: { link: "/p/123?active=true&env=prod" },
}), { disableOutputSanitization: true });payloadpayloadimport { z } from "zod";
workflow("comment", handler, {
payloadSchema: z.object({
postId: z.number(),
authorName: z.string(),
comment: z.string().max(200),
}),
});import { z } from "zod";
workflow("comment", handler, {
payloadSchema: z.object({
postId: z.number(),
authorName: z.string(),
comment: z.string().max(200),
}),
});workflow("comment", handler, {
payloadSchema: {
type: "object",
properties: {
postId: { type: "number" },
authorName: { type: "string" },
comment: { type: "string", maxLength: 200 },
},
required: ["postId", "comment"],
additionalProperties: false,
} as const,
});Theis required for TS to infer the payload type from JSON Schema.as const
workflow("comment", handler, {
payloadSchema: {
type: "object",
properties: {
postId: { type: "number" },
authorName: { type: "string" },
comment: { type: "string", maxLength: 200 },
},
required: ["postId", "comment"],
additionalProperties: false,
} as const,
});需要添加才能让TS从JSON Schema推断负载类型。as const
import { IsString, IsNumber } from "class-validator";
class CommentPayload {
@IsNumber() postId!: number;
@IsString() authorName!: string;
@IsString() comment!: string;
}
workflow("comment", handler, { payloadSchema: CommentPayload });class-validatorclass-validator-jsonschemareflect-metadatareferences/schema-validation.mdimport { IsString, IsNumber } from "class-validator";
class CommentPayload {
@IsNumber() postId!: number;
@IsString() authorName!: string;
@IsString() comment!: string;
}
workflow("comment", handler, { payloadSchema: CommentPayload });class-validatorclass-validator-jsonschemareflect-metadatareferences/schema-validation.mdawait step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<EmailTemplate hideBanner={controls.hideBanner} />),
}), {
controlSchema: z.object({
hideBanner: z.boolean().default(false),
subject: z.string().default("Hi {{subscriber.firstName | capitalize}}"),
}),
});await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<EmailTemplate hideBanner={controls.hideBanner} />),
}), {
controlSchema: z.object({
hideBanner: z.boolean().default(false),
subject: z.string().default("你好 {{subscriber.firstName | capitalize}}"),
}),
});{{subscriber.firstName}}{{payload.userId}}payloadSchema{{payload.invoiceDate | date: '%a, %b %d, %y'}}{{subscriber.firstName | append: ': ' | append: payload.status | capitalize}}{{{{subscriber.firstName}}{{payload.userId}}payloadSchema{{payload.invoiceDate | date: '%a, %b %d, %y'}}{{subscriber.firstName | append: ': ' | append: payload.status | capitalize}}{{| Controls | Payload | |
|---|---|---|
| Edited by | Non-technical peers in Dashboard | Developers in code |
| Schema | | |
| Persistence | Stored in Novu Cloud per environment | Sent at trigger time |
| Use case | Subject, copy, styling, behaviour toggles | Dynamic per-trigger data |
| 控件 | 负载 | |
|---|---|---|
| 编辑者 | 非技术人员在仪表盘中编辑 | 开发者在代码中设置 |
| Schema | 每个步骤的 | 每个工作流的 |
| 存储位置 | 按环境存储在Novu Cloud中 | 触发时传递 |
| 使用场景 | 主题、文案、样式、行为开关 | 每个触发事件的动态数据 |
manage-preferencesworkflow("system-alert", handler, {
preferences: {
all: { enabled: true, readOnly: false },
channels: {
email: { enabled: true },
sms: { enabled: false },
inApp: { enabled: true },
},
},
});all.readOnly: trueall.enabledchannelsenabled: truereadOnly: falsemanage-preferencesworkflow("system-alert", handler, {
preferences: {
all: { enabled: true, readOnly: false },
channels: {
email: { enabled: true },
sms: { enabled: false },
inApp: { enabled: true },
},
},
});all.readOnly: trueall.enabledchannelsenabled: truereadOnly: false/api/novuGETPOSTserve/api/novuGETPOSTserveimport { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "@/novu/workflows";
export const { GET, POST, OPTIONS } = serve({
workflows: [welcomeWorkflow],
});import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "@/novu/workflows";
export const { GET, POST, OPTIONS } = serve({
workflows: [welcomeWorkflow],
});import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "../../novu/workflows";
export default serve({ workflows: [welcomeWorkflow] });import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "../../novu/workflows";
export default serve({ workflows: [welcomeWorkflow] });import express from "express";
import { serve } from "@novu/framework/express";
import { welcomeWorkflow } from "./novu/workflows";
const app = express();
app.use(express.json()); // required
app.use("/api/novu", serve({ workflows: [welcomeWorkflow] }));
app.listen(4000);import express from "express";
import { serve } from "@novu/framework/express";
import { welcomeWorkflow } from "./novu/workflows";
const app = express();
app.use(express.json()); // 必填
app.use("/api/novu", serve({ workflows: [welcomeWorkflow] }));
app.listen(4000);import { Module } from "@nestjs/common";
import { NovuModule } from "@novu/framework/nest";
import { welcomeWorkflow } from "./novu/workflows";
@Module({
imports: [
NovuModule.register({
apiPath: "/api/novu",
workflows: [welcomeWorkflow],
}),
],
})
export class AppModule {}NovuModule.registerAsyncreferences/bridge-endpoint.mdimport { Module } from "@nestjs/common";
import { NovuModule } from "@novu/framework/nest";
import { welcomeWorkflow } from "./novu/workflows";
@Module({
imports: [
NovuModule.register({
apiPath: "/api/novu",
workflows: [welcomeWorkflow],
}),
],
})
export class AppModule {}NovuModule.registerAsyncreferences/bridge-endpoint.mdimport { serve } from "@novu/framework/remix";
import { welcomeWorkflow } from "../novu/workflows";
const handler = serve({ workflows: [welcomeWorkflow] });
export { handler as action, handler as loader };import { serve } from "@novu/framework/remix";
import { welcomeWorkflow } from "../novu/workflows";
const handler = serve({ workflows: [welcomeWorkflow] });
export { handler as action, handler as loader };import { serve } from "@novu/framework/sveltekit";
import { welcomeWorkflow } from "$lib/novu/workflows";
export const { GET, POST, OPTIONS } = serve({ workflows: [welcomeWorkflow] });import { serve } from "@novu/framework/sveltekit";
import { welcomeWorkflow } from "$lib/novu/workflows";
export const { GET, POST, OPTIONS } = serve({ workflows: [welcomeWorkflow] });import { serve } from "@novu/framework/nuxt";
import { welcomeWorkflow } from "~/novu/workflows";
export default defineEventHandler(serve({ workflows: [welcomeWorkflow] }));import { serve } from "@novu/framework/nuxt";
import { welcomeWorkflow } from "~/novu/workflows";
export default defineEventHandler(serve({ workflows: [welcomeWorkflow] }));import { createApp, eventHandler, toNodeListener } from "h3";
import { createServer } from "node:http";
import { serve } from "@novu/framework/h3";
import { welcomeWorkflow } from "./novu/workflows";
const app = createApp();
app.use("/api/novu", eventHandler(serve({ workflows: [welcomeWorkflow] })));
createServer(toNodeListener(app)).listen(4000);import { createApp, eventHandler, toNodeListener } from "h3";
import { createServer } from "node:http";
import { serve } from "@novu/framework/h3";
import { welcomeWorkflow } from "./novu/workflows";
const app = createApp();
app.use("/api/novu", eventHandler(serve({ workflows: [welcomeWorkflow] })));
createServer(toNodeListener(app)).listen(4000);import { serve } from "@novu/framework/lambda";
import { welcomeWorkflow } from "./novu/workflows";
export const novu = serve({ workflows: [welcomeWorkflow] });import { serve } from "@novu/framework/lambda";
import { welcomeWorkflow } from "./novu/workflows";
export const novu = serve({ workflows: [welcomeWorkflow] });import { NovuRequestHandler, ServeHandlerOptions } from "@novu/framework";
export const serve = (options: ServeHandlerOptions) =>
new NovuRequestHandler({
frameworkName: "my-framework",
...options,
handler: (req, res) => ({ /* method, headers, body, url, transformResponse */ }),
}).createHandler();references/bridge-endpoint.mdimport { NovuRequestHandler, ServeHandlerOptions } from "@novu/framework";
export const serve = (options: ServeHandlerOptions) =>
new NovuRequestHandler({
frameworkName: "my-framework",
...options,
handler: (req, res) => ({ /* method, headers, body, url, transformResponse */ }),
}).createHandler();references/bridge-endpoint.mdnpx novu@latest devnpx novu@latest dev
Then open `http://localhost:2022` (Chrome only).
然后打开`http://localhost:2022`(仅支持Chrome浏览器)。| Flag | Default | Purpose |
|---|---|---|
| | Your app's port |
| | Bridge route path |
| | Bridge origin |
| | Dashboard URL — use |
| | Studio UI port |
| auto | Self-hosted tunnel URL (e.g. ngrok) |
| | Skip the Studio UI |
npx novu@latest dev --port 3002 --dashboard-url https://eu.dashboard.novu.cohttps://<id>.novu.sh/api/novuprocess.env.NODE_ENV=development| 参数 | 默认值 | 用途 |
|---|---|---|
| | 你的应用端口 |
| | Bridge路由路径 |
| | Bridge源地址 |
| | 仪表盘URL——欧盟地区使用 |
| | Studio UI端口 |
| 自动 | 自托管隧道URL(例如ngrok) |
| | 跳过Studio UI |
npx novu@latest dev --port 3002 --dashboard-url https://eu.dashboard.novu.cohttps://<id>.novu.sh/api/novuprocess.env.NODE_ENV=development@novu/apiimport { Novu } from "@novu/api";
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY });
await novu.trigger({
workflowId: "welcome-email",
to: { subscriberId: "user-123", email: "jane@acme.com" },
payload: { userName: "Jane", appName: "Acme" },
});step.customawait step.custom("trigger-summary", async () => {
return await summaryWorkflow.trigger({
to: subscriber.subscriberId,
payload: { events: events.map(e => e.payload) },
});
});trigger-notification@novu/apiimport { Novu } from "@novu/api";
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY });
await novu.trigger({
workflowId: "welcome-email",
to: { subscriberId: "user-123", email: "jane@acme.com" },
payload: { userName: "Jane", appName: "Acme" },
});step.customawait step.custom("trigger-summary", async () => {
return await summaryWorkflow.trigger({
to: subscriber.subscriberId,
payload: { events: events.map(e => e.payload) },
});
});trigger-notificationnpm install @react-email/components react-emailimport { Body, Container, Head, Html, render } from "@react-email/components";
export const WelcomeEmail = ({ name }: { name: string }) => (
<Html>
<Head />
<Body>
<Container>Hello {name}, welcome!</Container>
</Body>
</Html>
);
export const renderWelcome = (name: string) => render(<WelcomeEmail name={name} />);await step.email("welcome", async () => ({
subject: "Welcome",
body: renderWelcome(payload.userName),
}));references/email-templates.mdnpm install @react-email/components react-emailimport { Body, Container, Head, Html, render } from "@react-email/components";
export const WelcomeEmail = ({ name }: { name: string }) => (
<Html>
<Head />
<Body>
<Container>你好 {name},欢迎加入!</Container>
</Body>
</Html>
);
export const renderWelcome = (name: string) => render(<WelcomeEmail name={name} />);await step.email("welcome", async () => ({
subject: "欢迎",
body: renderWelcome(payload.userName),
}));references/email-templates.mdsubscriber.localeimport { workflow } from "@novu/framework";
import i18n from "./i18n";
export const localizedWorkflow = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("email", async (controls) => {
const t = i18n.getFixedT([subscriber.locale ?? controls.defaultLocale]);
return {
subject: t("welcomeEmailSubject", { username: subscriber.firstName }),
body: render(<Welcome subject={t("subject")} body={t("body")} />),
};
}, {
controlSchema: z.object({
defaultLocale: z.string().default("en_US"),
}),
});
},
);references/translations.mdsubscriber.localeimport { workflow } from "@novu/framework";
import i18n from "./i18n";
export const localizedWorkflow = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("email", async (controls) => {
const t = i18n.getFixedT([subscriber.locale ?? controls.defaultLocale]);
return {
subject: t("welcomeEmailSubject", { username: subscriber.firstName }),
body: render(<Welcome subject={t("subject")} body={t("body")} />),
};
}, {
controlSchema: z.object({
defaultLocale: z.string().default("en_US"),
}),
});
},
);references/translations.mdworkflow("login-alert", handler, { tags: ["security"] });
workflow("password-change", handler, { tags: ["security"] });tabs={[{ label: "Security", filter: { tags: ["security"] } }]}inbox-integrationworkflow("login-alert", handler, { tags: ["security"] });
workflow("password-change", handler, { tags: ["security"] });tabs={[{ label: "安全", filter: { tags: ["security"] } }]}inbox-integrationnpx novu@latest sync \
--bridge-url https://api.acme.com/api/novu \
--secret-key $NOVU_SECRET_KEY \
--api-url https://api.novu.co # use https://eu.api.novu.co for EUnpx novu@latest sync \
--bridge-url https://api.acme.com/api/novu \
--secret-key $NOVU_SECRET_KEY \
--api-url https://api.novu.co # 欧盟地区使用https://eu.api.novu.coname: Sync Novu Workflows
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: novuhq/actions-novu-sync@v2
with:
secret-key: ${{ secrets.NOVU_SECRET_KEY }}
bridge-url: ${{ secrets.NOVU_BRIDGE_URL }}
api-url: https://api.novu.coname: 同步Novu工作流
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: novuhq/actions-novu-sync@v2
with:
secret-key: ${{ secrets.NOVU_SECRET_KEY }}
bridge-url: ${{ secrets.NOVU_BRIDGE_URL }}
api-url: https://api.novu.conpx novu syncmainnpx novu syncnpx novu syncmainnpx novu syncNODE_ENV !== "development"serveNovu-Signaturet=timestamp,v1=signatureNOVU_SECRET_KEYNODE_ENV=development?x-vercel-protection-bypass=<token>NODE_ENV !== "development"serveNovu-Signaturet=timestamp,v1=signatureNOVU_SECRET_KEYNODE_ENV=development?x-vercel-protection-bypass=<token>import { Client as NovuFrameworkClient } from "@novu/framework";
import { serve } from "@novu/framework/next";
export const { GET, POST, OPTIONS } = serve({
client: new NovuFrameworkClient({
secretKey: process.env.NOVU_SECRET_KEY,
strictAuthentication: false, // disables HMAC — only for local dev
}),
workflows: [/* … */],
});NOVU_SECRET_KEYNOVU_API_URLhttps://api.novu.cohttps://eu.api.novu.coimport { Client as NovuFrameworkClient } from "@novu/framework";
import { serve } from "@novu/framework/next";
export const { GET, POST, OPTIONS } = serve({
client: new NovuFrameworkClient({
secretKey: process.env.NOVU_SECRET_KEY,
strictAuthentication: false, // 禁用HMAC——仅用于本地开发
}),
workflows: [/* … */],
});NOVU_SECRET_KEYNOVU_API_URLhttps://api.novu.cohttps://eu.api.novu.colocalhostworkflowIdnovu.trigger({ workflowId })idas conststringpayloadunknownstep.digeststep.customresolverprovidersskipnpx novu syncNODE_ENV !== "development"developmentClientsecretKey"use client"_passthroughstep.custom@novu/frameworklocalhostworkflowIdnovu.trigger({ workflowId })idas conststringpayloadunknownstep.digeststep.customresolverprovidersskipnpx novu syncNODE_ENV !== "development"developmentClientsecretKey"use client"_passthroughstep.custom@novu/frameworksrc/novu/workflows/<workflow-id>.tssrc/novu/workflows/index.tsoneOfif/then/else$refsrc/novu/workflows/welcome/template.tsxstep.customfetchUser(payload.userId)NovuModule.registerAsyncNotificationServicesrc/novu/workflows/<workflow-id>.tssrc/novu/workflows/index.tsoneOfif/then/else$refsrc/novu/workflows/welcome/template.tsxstep.customfetchUser(payload.userId)NovuModule.registerAsyncNotificationServiceservenpx novu syncservenpx novu sync