diff --git a/package.json b/package.json
index 565b49a8..3a05c55b 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,10 @@
"azure-functions-core-tools",
"esbuild",
"msw"
+ ],
+ "ignoredBuiltDependencies": [
+ "@prisma/engines",
+ "prisma"
]
}
}
diff --git a/patchnotes-email/package.json b/patchnotes-email/package.json
index ffcfb825..6a441f8f 100644
--- a/patchnotes-email/package.json
+++ b/patchnotes-email/package.json
@@ -18,11 +18,16 @@
"@azure/functions": "^4.0.0",
"@prisma/adapter-mssql": "^7.4.0",
"@prisma/client": "^7.4.0",
+ "@react-email/components": "^1.0.7",
+ "@react-email/render": "^2.0.4",
+ "esbuild": "^0.27.3",
+ "react": "^19.2.4",
"resend": "^6.9.2"
},
"packageManager": "pnpm@10.28.0",
"devDependencies": {
"@types/node": "25.x",
+ "@types/react": "^19.2.14",
"azure-functions-core-tools": "^4.x",
"dotenv": "^17.3.1",
"prisma": "^7.4.0",
diff --git a/patchnotes-email/src/functions/sendDigest.test.ts b/patchnotes-email/src/functions/sendDigest.test.ts
index 049f770d..937d772e 100644
--- a/patchnotes-email/src/functions/sendDigest.test.ts
+++ b/patchnotes-email/src/functions/sendDigest.test.ts
@@ -1,8 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-const { mockSend, mockFindMany } = vi.hoisted(() => ({
+const { mockSend, mockFindMany, mockFindUnique, mockRenderTemplate, mockInterpolateSubject } = vi.hoisted(() => ({
mockSend: vi.fn(),
mockFindMany: vi.fn(),
+ mockFindUnique: vi.fn(),
+ mockRenderTemplate: vi.fn(),
+ mockInterpolateSubject: vi.fn(),
}));
vi.mock("../lib/resend", () => ({
@@ -16,7 +19,15 @@ vi.mock("../lib/resend", () => ({
}));
vi.mock("../lib/prisma", () => ({
- getPrismaClient: () => ({ users: { findMany: mockFindMany } }),
+ getPrismaClient: () => ({
+ users: { findMany: mockFindMany },
+ emailTemplates: { findUnique: mockFindUnique },
+ }),
+}));
+
+vi.mock("../lib/templateRenderer", () => ({
+ renderTemplate: mockRenderTemplate,
+ interpolateSubject: mockInterpolateSubject,
}));
import { sendDigest } from "./sendDigest";
@@ -60,6 +71,8 @@ function makeUser(email: string, name: string, releases: Array<{ tag: string; ma
describe("sendDigest", () => {
beforeEach(() => {
vi.clearAllMocks();
+ // Default: no template in DB, use fallback HTML
+ mockFindUnique.mockResolvedValue(null);
});
it("returns early when no users have pending items", async () => {
@@ -187,4 +200,62 @@ describe("sendDigest", () => {
// All 3 users should have been attempted (not short-circuited)
expect(mockSend).toHaveBeenCalledTimes(3);
});
+
+ it("uses DB template when available", async () => {
+ const template = {
+ Name: "digest",
+ Subject: "Digest for {{name}} — {{count}} updates",
+ JsxSource: "",
+ };
+ mockFindUnique.mockResolvedValue(template);
+ mockRenderTemplate.mockResolvedValue("rendered");
+ mockInterpolateSubject.mockReturnValue("Digest for Alice — 1 updates");
+ mockFindMany.mockResolvedValue([
+ makeUser("a@test.com", "Alice", [{ tag: "v1.0.0", major: 1 }]),
+ ]);
+ mockSend.mockResolvedValue({ error: null });
+ const context = makeContext();
+
+ await sendDigest(makeTimer(), context);
+
+ expect(mockRenderTemplate).toHaveBeenCalledWith("", {
+ name: "Alice",
+ releases: [{ packageName: "test-package", version: "v1.0.0", summary: "Summary for v1.0.0" }],
+ });
+ expect(mockInterpolateSubject).toHaveBeenCalledWith(
+ "Digest for {{name}} — {{count}} updates",
+ { name: "Alice", count: "1" }
+ );
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ html: "rendered",
+ subject: "Digest for Alice — 1 updates",
+ })
+ );
+ });
+
+ it("falls back to hardcoded HTML when template rendering fails", async () => {
+ mockFindUnique.mockResolvedValue({
+ Name: "digest",
+ Subject: "Digest",
+ JsxSource: "bad-jsx",
+ });
+ mockRenderTemplate.mockRejectedValue(new Error("render failed"));
+ mockFindMany.mockResolvedValue([
+ makeUser("a@test.com", "Alice", [{ tag: "v1.0.0", major: 1 }]),
+ ]);
+ mockSend.mockResolvedValue({ error: null });
+ const context = makeContext();
+
+ await sendDigest(makeTimer(), context);
+
+ expect(context.warn).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to render digest template"),
+ expect.any(Error)
+ );
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ // Should still send with fallback HTML
+ const sentHtml = mockSend.mock.calls[0][0].html;
+ expect(sentHtml).toContain("Your Weekly PatchNotes Digest");
+ });
});
diff --git a/patchnotes-email/src/functions/sendDigest.ts b/patchnotes-email/src/functions/sendDigest.ts
index f8f73904..91d0053f 100644
--- a/patchnotes-email/src/functions/sendDigest.ts
+++ b/patchnotes-email/src/functions/sendDigest.ts
@@ -1,6 +1,7 @@
import { app, InvocationContext, Timer } from "@azure/functions";
import { resend, FROM_ADDRESS, APP_BASE_URL, escapeHtml, emailFooter, sanitizeSubject, isValidEmail } from "../lib/resend";
import { getPrismaClient } from "../lib/prisma";
+import { renderTemplate, interpolateSubject } from "../lib/templateRenderer";
const DIGEST_WINDOW_DAYS = 7;
@@ -66,6 +67,14 @@ export async function sendDigest(
context.log(`Sending digests to ${users.length} users`);
+ // Fetch digest template once for all users
+ const template = await db.emailTemplates.findUnique({ where: { Name: "digest" } });
+ if (template) {
+ context.log("Using digest template from DB");
+ } else {
+ context.log("No digest template found in DB, using fallback for all users");
+ }
+
const failures: Array<{ email: string; error: unknown }> = [];
let sentCount = 0;
let skippedCount = 0;
@@ -97,24 +106,34 @@ export async function sendDigest(
continue;
}
- // TODO: Replace with React Email WeeklyDigest template when available
- const releaseList = releases
- .map((r) => `
${escapeHtml(r.packageName)} ${escapeHtml(r.version)}: ${escapeHtml(r.summary)}`)
- .join("\n");
+ let html: string;
+ let subject: string;
- const html = `
- Your Weekly PatchNotes Digest
- Hi ${escapeHtml(user.Name ?? "there")}, here's what happened this week with the packages you're watching:
-
- View all updates on PatchNotes
- ${emailFooter()}
- `;
+ if (template) {
+ try {
+ html = await renderTemplate(template.JsxSource, {
+ name: user.Name ?? "there",
+ releases,
+ });
+ subject = interpolateSubject(template.Subject, {
+ name: user.Name ?? "there",
+ count: String(releases.length),
+ });
+ } catch (renderErr) {
+ context.warn(`Failed to render digest template for ${user.Email}, using fallback:`, renderErr);
+ html = fallbackDigestHtml(user.Name, releases);
+ subject = sanitizeSubject(`Your Weekly PatchNotes Digest — ${releases.length} updates`);
+ }
+ } else {
+ html = fallbackDigestHtml(user.Name, releases);
+ subject = sanitizeSubject(`Your Weekly PatchNotes Digest — ${releases.length} updates`);
+ }
try {
const { error } = await resend.emails.send({
from: FROM_ADDRESS,
to: user.Email!,
- subject: sanitizeSubject(`Your Weekly PatchNotes Digest — ${releases.length} updates`),
+ subject,
html,
});
@@ -143,6 +162,23 @@ export async function sendDigest(
}
}
+function fallbackDigestHtml(
+ name: string | null,
+ releases: Array<{ packageName: string; version: string; summary: string }>
+): string {
+ const releaseList = releases
+ .map((r) => `${escapeHtml(r.packageName)} ${escapeHtml(r.version)}: ${escapeHtml(r.summary)}`)
+ .join("\n");
+
+ return `
+ Your Weekly PatchNotes Digest
+ Hi ${escapeHtml(name ?? "there")}, here's what happened this week with the packages you're watching:
+
+ View all updates on PatchNotes
+ ${emailFooter()}
+ `;
+}
+
// Runs every Monday at 9:00 AM UTC
app.timer("sendDigest", {
schedule: "0 0 9 * * 1",
diff --git a/patchnotes-email/src/functions/sendWelcome.test.ts b/patchnotes-email/src/functions/sendWelcome.test.ts
new file mode 100644
index 00000000..2bb8bf01
--- /dev/null
+++ b/patchnotes-email/src/functions/sendWelcome.test.ts
@@ -0,0 +1,149 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const { mockSend, mockFindUnique, mockRenderTemplate, mockInterpolateSubject } = vi.hoisted(() => ({
+ mockSend: vi.fn(),
+ mockFindUnique: vi.fn(),
+ mockRenderTemplate: vi.fn(),
+ mockInterpolateSubject: vi.fn(),
+}));
+
+vi.mock("../lib/resend", () => ({
+ resend: { emails: { send: mockSend } },
+ FROM_ADDRESS: "PatchNotes ",
+ escapeHtml: (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """),
+ emailFooter: () => "",
+ sanitizeSubject: (s: string) => s.replace(/[\r\n]+/g, " ").trim(),
+ isValidEmail: (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
+}));
+
+vi.mock("../lib/prisma", () => ({
+ getPrismaClient: () => ({
+ emailTemplates: { findUnique: mockFindUnique },
+ }),
+}));
+
+vi.mock("../lib/templateRenderer", () => ({
+ renderTemplate: mockRenderTemplate,
+ interpolateSubject: mockInterpolateSubject,
+}));
+
+import { sendWelcome } from "./sendWelcome";
+
+function makeContext() {
+ return {
+ log: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ } as any;
+}
+
+function makeRequest(body: unknown): any {
+ return {
+ json: async () => body,
+ };
+}
+
+describe("sendWelcome", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindUnique.mockResolvedValue(null);
+ });
+
+ it("returns 400 for invalid JSON", async () => {
+ const request = { json: async () => { throw new Error("bad json"); } } as any;
+ const result = await sendWelcome(request, makeContext());
+ expect(result.status).toBe(400);
+ expect(result.body).toBe("Invalid JSON body");
+ });
+
+ it("returns 400 when email or name is missing", async () => {
+ const result = await sendWelcome(makeRequest({ email: "a@b.com" }), makeContext());
+ expect(result.status).toBe(400);
+ expect(result.body).toBe("Missing required fields: email, name");
+ });
+
+ it("returns 400 for invalid email format", async () => {
+ const result = await sendWelcome(makeRequest({ email: "not-valid", name: "Test" }), makeContext());
+ expect(result.status).toBe(400);
+ expect(result.body).toBe("Invalid email address format");
+ });
+
+ it("sends welcome email with fallback HTML when no template in DB", async () => {
+ mockSend.mockResolvedValue({ error: null });
+ const context = makeContext();
+
+ const result = await sendWelcome(makeRequest({ email: "a@test.com", name: "Alice" }), context);
+
+ expect(result.status).toBe(200);
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ const call = mockSend.mock.calls[0][0];
+ expect(call.html).toContain("Welcome to PatchNotes, Alice!");
+ expect(call.subject).toBe("Welcome to PatchNotes, Alice!");
+ expect(call.to).toBe("a@test.com");
+ });
+
+ it("uses DB template when available", async () => {
+ mockFindUnique.mockResolvedValue({
+ Name: "welcome",
+ Subject: "Welcome, {{name}}!",
+ JsxSource: "",
+ });
+ mockRenderTemplate.mockResolvedValue("welcome rendered");
+ mockInterpolateSubject.mockReturnValue("Welcome, Alice!");
+ mockSend.mockResolvedValue({ error: null });
+ const context = makeContext();
+
+ const result = await sendWelcome(makeRequest({ email: "a@test.com", name: "Alice" }), context);
+
+ expect(result.status).toBe(200);
+ expect(mockRenderTemplate).toHaveBeenCalledWith("", { name: "Alice" });
+ expect(mockInterpolateSubject).toHaveBeenCalledWith("Welcome, {{name}}!", { name: "Alice" });
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ html: "welcome rendered",
+ subject: "Welcome, Alice!",
+ })
+ );
+ });
+
+ it("falls back to hardcoded HTML when template rendering fails", async () => {
+ mockFindUnique.mockResolvedValue({
+ Name: "welcome",
+ Subject: "Welcome",
+ JsxSource: "bad-jsx",
+ });
+ mockRenderTemplate.mockRejectedValue(new Error("render failed"));
+ mockSend.mockResolvedValue({ error: null });
+ const context = makeContext();
+
+ const result = await sendWelcome(makeRequest({ email: "a@test.com", name: "Alice" }), context);
+
+ expect(result.status).toBe(200);
+ expect(context.warn).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to render welcome template"),
+ expect.any(Error)
+ );
+ const sentHtml = mockSend.mock.calls[0][0].html;
+ expect(sentHtml).toContain("Welcome to PatchNotes, Alice!");
+ });
+
+ it("returns 500 when resend returns an error", async () => {
+ mockSend.mockResolvedValue({ error: { message: "rate limited" } });
+ const context = makeContext();
+
+ const result = await sendWelcome(makeRequest({ email: "a@test.com", name: "Alice" }), context);
+
+ expect(result.status).toBe(500);
+ expect(result.body).toContain("Failed to send email");
+ });
+
+ it("returns 500 on unexpected error", async () => {
+ mockSend.mockRejectedValue(new Error("network failure"));
+ const context = makeContext();
+
+ const result = await sendWelcome(makeRequest({ email: "a@test.com", name: "Alice" }), context);
+
+ expect(result.status).toBe(500);
+ expect(result.body).toBe("Internal server error");
+ });
+});
diff --git a/patchnotes-email/src/functions/sendWelcome.ts b/patchnotes-email/src/functions/sendWelcome.ts
index 21dca500..8343d579 100644
--- a/patchnotes-email/src/functions/sendWelcome.ts
+++ b/patchnotes-email/src/functions/sendWelcome.ts
@@ -1,5 +1,7 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { resend, FROM_ADDRESS, escapeHtml, emailFooter, sanitizeSubject, isValidEmail } from "../lib/resend";
+import { getPrismaClient } from "../lib/prisma";
+import { renderTemplate, interpolateSubject } from "../lib/templateRenderer";
interface WelcomeRequest {
email: string;
@@ -28,19 +30,32 @@ export async function sendWelcome(
}
try {
- // TODO: Replace with React Email template when available
- const name = escapeHtml(body.name);
- const html = `
- Welcome to PatchNotes, ${name}!
- You're all set to receive release notifications for the packages you care about.
- Head to your dashboard to start watching packages.
- ${emailFooter()}
- `;
+ let html: string;
+ let subject: string;
+
+ const db = getPrismaClient();
+ const template = await db.emailTemplates.findUnique({ where: { Name: "welcome" } });
+
+ if (template) {
+ try {
+ html = await renderTemplate(template.JsxSource, { name: body.name });
+ subject = interpolateSubject(template.Subject, { name: body.name });
+ context.log("Rendered welcome email from DB template");
+ } catch (renderErr) {
+ context.warn("Failed to render welcome template, using fallback:", renderErr);
+ html = fallbackWelcomeHtml(body.name);
+ subject = sanitizeSubject(`Welcome to PatchNotes, ${body.name}!`);
+ }
+ } else {
+ context.log("No welcome template found in DB, using fallback");
+ html = fallbackWelcomeHtml(body.name);
+ subject = sanitizeSubject(`Welcome to PatchNotes, ${body.name}!`);
+ }
const { error } = await resend.emails.send({
from: FROM_ADDRESS,
to: body.email,
- subject: sanitizeSubject(`Welcome to PatchNotes, ${body.name}!`),
+ subject,
html,
});
@@ -56,6 +71,16 @@ export async function sendWelcome(
}
}
+function fallbackWelcomeHtml(name: string): string {
+ const escaped = escapeHtml(name);
+ return `
+ Welcome to PatchNotes, ${escaped}!
+ You're all set to receive release notifications for the packages you care about.
+ Head to your dashboard to start watching packages.
+ ${emailFooter()}
+ `;
+}
+
app.http("sendWelcome", {
methods: ["POST"],
authLevel: "function",
diff --git a/patchnotes-email/src/lib/templateRenderer.test.ts b/patchnotes-email/src/lib/templateRenderer.test.ts
new file mode 100644
index 00000000..e617ddb0
--- /dev/null
+++ b/patchnotes-email/src/lib/templateRenderer.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from "vitest";
+import { renderTemplate, interpolateSubject } from "./templateRenderer";
+
+describe("interpolateSubject", () => {
+ it("replaces {{variable}} placeholders", () => {
+ expect(interpolateSubject("Hello, {{name}}!", { name: "Alice" })).toBe("Hello, Alice!");
+ });
+
+ it("replaces multiple placeholders", () => {
+ expect(
+ interpolateSubject("{{name}} has {{count}} updates", { name: "Bob", count: "3" })
+ ).toBe("Bob has 3 updates");
+ });
+
+ it("replaces missing variables with empty string", () => {
+ expect(interpolateSubject("Hello, {{name}}!", {})).toBe("Hello, !");
+ });
+
+ it("returns string unchanged when no placeholders", () => {
+ expect(interpolateSubject("No placeholders here", { name: "Alice" })).toBe("No placeholders here");
+ });
+});
+
+describe("renderTemplate", () => {
+ it("renders a simple JSX component to HTML", async () => {
+ const jsxSource = `
+ import * as React from "react";
+
+ export default function TestEmail({ name }) {
+ return React.createElement("div", null, "Hello, " + name + "!");
+ }
+ `;
+
+ const html = await renderTemplate(jsxSource, { name: "Alice" });
+ expect(html).toContain("Hello, Alice!");
+ });
+
+ it("renders a component using React Email components", async () => {
+ const jsxSource = `
+ import { Html, Text } from "@react-email/components";
+
+ export default function TestEmail({ name }) {
+ return Welcome, {name}!;
+ }
+ `;
+
+ const html = await renderTemplate(jsxSource, { name: "Bob" });
+ expect(html).toContain("Welcome,");
+ expect(html).toContain("Bob");
+ });
+
+ it("throws when template does not export a component", async () => {
+ const jsxSource = `export const foo = "bar";`;
+
+ await expect(renderTemplate(jsxSource, {})).rejects.toThrow(
+ "Template does not export a valid React component"
+ );
+ });
+
+ it("throws on invalid JSX syntax", async () => {
+ const jsxSource = `export default function() { return <<<<; }`;
+
+ await expect(renderTemplate(jsxSource, {})).rejects.toThrow();
+ });
+});
diff --git a/patchnotes-email/src/lib/templateRenderer.ts b/patchnotes-email/src/lib/templateRenderer.ts
new file mode 100644
index 00000000..1d1805eb
--- /dev/null
+++ b/patchnotes-email/src/lib/templateRenderer.ts
@@ -0,0 +1,58 @@
+import { transform } from "esbuild";
+import { render } from "@react-email/render";
+import * as React from "react";
+import * as ReactEmailComponents from "@react-email/components";
+
+const MODULE_MAP: Record = {
+ react: React,
+ "@react-email/components": ReactEmailComponents,
+};
+
+function sandboxRequire(moduleName: string): unknown {
+ const mod = MODULE_MAP[moduleName];
+ if (!mod) throw new Error(`Module not available in template sandbox: ${moduleName}`);
+ return mod;
+}
+
+/**
+ * Transpiles JSX source from the database into HTML using React Email's render().
+ * The JSX source should export a default React component function.
+ *
+ * Security note: Templates are admin-seeded in the database via the admin API,
+ * not from user input. The sandboxed require only exposes react and
+ * @react-email/components modules.
+ */
+export async function renderTemplate(
+ jsxSource: string,
+ props: Record
+): Promise {
+ const { code } = await transform(jsxSource, {
+ loader: "tsx",
+ jsx: "transform",
+ jsxFactory: "React.createElement",
+ jsxFragment: "React.Fragment",
+ format: "cjs",
+ });
+
+ const moduleObj: { exports: Record } = { exports: {} };
+ // eslint-disable-next-line no-new-func -- Intentional: evaluating admin-seeded DB templates
+ const fn = new Function("module", "exports", "require", "React", code);
+ fn(moduleObj, moduleObj.exports, sandboxRequire, React);
+
+ const Component = (moduleObj.exports.default ?? moduleObj.exports) as React.FC;
+ if (typeof Component !== "function") {
+ throw new Error("Template does not export a valid React component");
+ }
+
+ return await render(React.createElement(Component, props));
+}
+
+/**
+ * Interpolates {{variable}} placeholders in a subject line template.
+ */
+export function interpolateSubject(
+ subject: string,
+ vars: Record
+): string {
+ return subject.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cac458c4..02b96438 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -19,13 +19,28 @@ importers:
'@prisma/client':
specifier: ^7.4.0
version: 7.4.0(prisma@7.4.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
+ '@react-email/components':
+ specifier: ^1.0.7
+ version: 1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@react-email/render':
+ specifier: ^2.0.4
+ version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ esbuild:
+ specifier: ^0.27.3
+ version: 0.27.3
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
resend:
specifier: ^6.9.2
- version: 6.9.2(@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
+ version: 6.9.2(@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
devDependencies:
'@types/node':
specifier: 25.x
version: 25.2.3
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
azure-functions-core-tools:
specifier: ^4.x
version: 4.7.0
@@ -953,13 +968,165 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
- '@react-email/render@1.1.2':
- resolution: {integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==}
- engines: {node: '>=18.0.0'}
+ '@react-email/body@0.2.1':
+ resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/button@0.2.1':
+ resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/code-block@0.2.1':
+ resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/code-inline@0.0.6':
+ resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/column@0.0.14':
+ resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/components@1.0.7':
+ resolution: {integrity: sha512-mY+v4C1SMaGOKuKp0QWDQLGK+3fvH06ZE10EVavv+T6tQneDHq9cpQ9NdCrvuO1nWZnWrA/0tRpvyqyF0uo93w==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/container@0.0.16':
+ resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/font@0.0.10':
+ resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/head@0.0.13':
+ resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/heading@0.0.16':
+ resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/hr@0.0.12':
+ resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/html@0.0.12':
+ resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/img@0.0.12':
+ resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/link@0.0.13':
+ resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/markdown@0.0.18':
+ resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/preview@0.0.14':
+ resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/render@2.0.4':
+ resolution: {integrity: sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==}
+ engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
+ '@react-email/row@0.0.13':
+ resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/section@0.0.17':
+ resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/tailwind@2.0.4':
+ resolution: {integrity: sha512-cDp8Ss6LJKI8zBLKE+tsXFurn6I2nnQNg1qqjfZuNPNoToN1Uyx3egW0bwSVk1JjrNWx/Xnme7ZxvNLRrU9K0Q==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ '@react-email/body': 0.2.1
+ '@react-email/button': 0.2.1
+ '@react-email/code-block': 0.2.1
+ '@react-email/code-inline': 0.0.6
+ '@react-email/container': 0.0.16
+ '@react-email/heading': 0.0.16
+ '@react-email/hr': 0.0.12
+ '@react-email/img': 0.0.12
+ '@react-email/link': 0.0.13
+ '@react-email/preview': 0.0.14
+ '@react-email/text': 0.1.6
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@react-email/body':
+ optional: true
+ '@react-email/button':
+ optional: true
+ '@react-email/code-block':
+ optional: true
+ '@react-email/code-inline':
+ optional: true
+ '@react-email/container':
+ optional: true
+ '@react-email/heading':
+ optional: true
+ '@react-email/hr':
+ optional: true
+ '@react-email/img':
+ optional: true
+ '@react-email/link':
+ optional: true
+ '@react-email/preview':
+ optional: true
+
+ '@react-email/text@0.1.6':
+ resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
'@rolldown/pluginutils@1.0.0-rc.3':
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
@@ -2101,9 +2268,6 @@ packages:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
- fast-deep-equal@2.0.1:
- resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==}
-
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2701,6 +2865,11 @@ packages:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
+ marked@15.0.12:
+ resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
+ engines: {node: '>= 18'}
+ hasBin: true
+
mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
@@ -3056,6 +3225,10 @@ packages:
typescript:
optional: true
+ prismjs@1.30.0:
+ resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+ engines: {node: '>=6'}
+
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -3109,9 +3282,6 @@ packages:
'@types/react': '>=18'
react: '>=18'
- react-promise-suspense@0.3.4:
- resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
-
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -4656,14 +4826,129 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- '@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@react-email/body@0.2.1(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/button@0.2.1(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/code-block@0.2.1(react@19.2.4)':
+ dependencies:
+ prismjs: 1.30.0
+ react: 19.2.4
+
+ '@react-email/code-inline@0.0.6(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/column@0.0.14(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/components@1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@react-email/body': 0.2.1(react@19.2.4)
+ '@react-email/button': 0.2.1(react@19.2.4)
+ '@react-email/code-block': 0.2.1(react@19.2.4)
+ '@react-email/code-inline': 0.0.6(react@19.2.4)
+ '@react-email/column': 0.0.14(react@19.2.4)
+ '@react-email/container': 0.0.16(react@19.2.4)
+ '@react-email/font': 0.0.10(react@19.2.4)
+ '@react-email/head': 0.0.13(react@19.2.4)
+ '@react-email/heading': 0.0.16(react@19.2.4)
+ '@react-email/hr': 0.0.12(react@19.2.4)
+ '@react-email/html': 0.0.12(react@19.2.4)
+ '@react-email/img': 0.0.12(react@19.2.4)
+ '@react-email/link': 0.0.13(react@19.2.4)
+ '@react-email/markdown': 0.0.18(react@19.2.4)
+ '@react-email/preview': 0.0.14(react@19.2.4)
+ '@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@react-email/row': 0.0.13(react@19.2.4)
+ '@react-email/section': 0.0.17(react@19.2.4)
+ '@react-email/tailwind': 2.0.4(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)
+ '@react-email/text': 0.1.6(react@19.2.4)
+ react: 19.2.4
+ transitivePeerDependencies:
+ - react-dom
+
+ '@react-email/container@0.0.16(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/font@0.0.10(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/head@0.0.13(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/heading@0.0.16(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/hr@0.0.12(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/html@0.0.12(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/img@0.0.12(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/link@0.0.13(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/markdown@0.0.18(react@19.2.4)':
+ dependencies:
+ marked: 15.0.12
+ react: 19.2.4
+
+ '@react-email/preview@0.0.14(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
html-to-text: 9.0.5
prettier: 3.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- react-promise-suspense: 0.3.4
- optional: true
+
+ '@react-email/row@0.0.13(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/section@0.0.17(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@react-email/tailwind@2.0.4(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@react-email/text': 0.1.6(react@19.2.4)
+ react: 19.2.4
+ tailwindcss: 4.1.18
+ optionalDependencies:
+ '@react-email/body': 0.2.1(react@19.2.4)
+ '@react-email/button': 0.2.1(react@19.2.4)
+ '@react-email/code-block': 0.2.1(react@19.2.4)
+ '@react-email/code-inline': 0.0.6(react@19.2.4)
+ '@react-email/container': 0.0.16(react@19.2.4)
+ '@react-email/heading': 0.0.16(react@19.2.4)
+ '@react-email/hr': 0.0.12(react@19.2.4)
+ '@react-email/img': 0.0.12(react@19.2.4)
+ '@react-email/link': 0.0.13(react@19.2.4)
+ '@react-email/preview': 0.0.14(react@19.2.4)
+
+ '@react-email/text@0.1.6(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
'@rolldown/pluginutils@1.0.0-rc.3': {}
@@ -4782,7 +5067,6 @@ snapshots:
dependencies:
domhandler: 5.0.3
selderee: 0.11.0
- optional: true
'@shikijs/engine-oniguruma@3.22.0':
dependencies:
@@ -5594,8 +5878,7 @@ snapshots:
deepmerge-ts@7.1.5: {}
- deepmerge@4.3.1:
- optional: true
+ deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -5631,22 +5914,18 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
- optional: true
- domelementtype@2.3.0:
- optional: true
+ domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
- optional: true
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
- optional: true
dotenv@16.6.1: {}
@@ -5860,9 +6139,6 @@ snapshots:
dependencies:
pure-rand: 6.1.0
- fast-deep-equal@2.0.1:
- optional: true
-
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -6071,7 +6347,6 @@ snapshots:
dom-serializer: 2.0.0
htmlparser2: 8.0.2
selderee: 0.11.0
- optional: true
html-url-attributes@3.0.1: {}
@@ -6081,7 +6356,6 @@ snapshots:
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
- optional: true
http-proxy-agent@7.0.2:
dependencies:
@@ -6304,8 +6578,7 @@ snapshots:
typescript: 5.9.3
zod: 4.3.6
- leac@0.6.0:
- optional: true
+ leac@0.6.0: {}
leven@4.1.0: {}
@@ -6465,6 +6738,8 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
+ marked@15.0.12: {}
+
mdast-util-from-markdown@2.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -6928,7 +7203,6 @@ snapshots:
dependencies:
leac: 0.6.0
peberminta: 0.9.0
- optional: true
path-exists@4.0.0: {}
@@ -6950,8 +7224,7 @@ snapshots:
pathe@2.0.3: {}
- peberminta@0.9.0:
- optional: true
+ peberminta@0.9.0: {}
pend@1.2.0: {}
@@ -7011,6 +7284,8 @@ snapshots:
- react
- react-dom
+ prismjs@1.30.0: {}
+
process@0.11.10: {}
progress@2.0.3: {}
@@ -7070,11 +7345,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-promise-suspense@0.3.4:
- dependencies:
- fast-deep-equal: 2.0.1
- optional: true
-
react-refresh@0.18.0: {}
react@19.2.4: {}
@@ -7135,12 +7405,12 @@ snapshots:
require-from-string@2.0.2: {}
- resend@6.9.2(@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
+ resend@6.9.2(@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
dependencies:
postal-mime: 2.7.3
svix: 1.84.1
optionalDependencies:
- '@react-email/render': 1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
resolve-pkg-maps@1.0.0: {}
@@ -7216,7 +7486,6 @@ snapshots:
selderee@0.11.0:
dependencies:
parseley: 0.12.1
- optional: true
semver@6.3.1: {}