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: () => "