Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
"azure-functions-core-tools",
"esbuild",
"msw"
],
"ignoredBuiltDependencies": [
"@prisma/engines",
"prisma"
]
}
}
5 changes: 5 additions & 0 deletions patchnotes-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 73 additions & 2 deletions patchnotes-email/src/functions/sendDigest.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: "<fake-jsx/>",
};
mockFindUnique.mockResolvedValue(template);
mockRenderTemplate.mockResolvedValue("<html>rendered</html>");
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("<fake-jsx/>", {
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: "<html>rendered</html>",
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");
});
});
60 changes: 48 additions & 12 deletions patchnotes-email/src/functions/sendDigest.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -97,24 +106,34 @@ export async function sendDigest(
continue;
}

// TODO: Replace with React Email WeeklyDigest template when available
const releaseList = releases
.map((r) => `<li><strong>${escapeHtml(r.packageName)} ${escapeHtml(r.version)}</strong>: ${escapeHtml(r.summary)}</li>`)
.join("\n");
let html: string;
let subject: string;

const html = `
<h1>Your Weekly PatchNotes Digest</h1>
<p>Hi ${escapeHtml(user.Name ?? "there")}, here's what happened this week with the packages you're watching:</p>
<ul>${releaseList}</ul>
<p><a href="${APP_BASE_URL}">View all updates on PatchNotes</a></p>
${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,
});

Expand Down Expand Up @@ -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) => `<li><strong>${escapeHtml(r.packageName)} ${escapeHtml(r.version)}</strong>: ${escapeHtml(r.summary)}</li>`)
.join("\n");

return `
<h1>Your Weekly PatchNotes Digest</h1>
<p>Hi ${escapeHtml(name ?? "there")}, here's what happened this week with the packages you're watching:</p>
<ul>${releaseList}</ul>
<p><a href="${APP_BASE_URL}">View all updates on PatchNotes</a></p>
${emailFooter()}
`;
}

// Runs every Monday at 9:00 AM UTC
app.timer("sendDigest", {
schedule: "0 0 9 * * 1",
Expand Down
149 changes: 149 additions & 0 deletions patchnotes-email/src/functions/sendWelcome.test.ts
Original file line number Diff line number Diff line change
@@ -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 <notifications@patchnotes.dev>",
escapeHtml: (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"),
emailFooter: () => "<footer/>",
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: "<fake-jsx/>",
});
mockRenderTemplate.mockResolvedValue("<html>welcome rendered</html>");
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("<fake-jsx/>", { name: "Alice" });
expect(mockInterpolateSubject).toHaveBeenCalledWith("Welcome, {{name}}!", { name: "Alice" });
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
html: "<html>welcome rendered</html>",
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");
});
});
Loading
Loading