diff --git a/test/digest-email.test.ts b/test/digest-email.test.ts
new file mode 100644
index 000000000..c3e75721b
--- /dev/null
+++ b/test/digest-email.test.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect } from "vitest";
+import { buildDigestHtml, buildDigestText } from "../src/lib/digest-email";
+
+const BASE_DATA = {
+ githubLogin: "testuser",
+ unsubscribeUrl: "https://example.com/unsubscribe?token=abc",
+ weekLabel: "Week of 1 June 2026",
+ metrics: null,
+};
+
+describe("digest-email", () => {
+ describe("buildDigestHtml", () => {
+ it("includes the githubLogin in the greeting", () => {
+ const html = buildDigestHtml({ ...BASE_DATA, githubLogin: "Alice" });
+ expect(html).toContain("Hey Alice");
+ });
+
+ it("includes the weekLabel in the title and header", () => {
+ const html = buildDigestHtml({ ...BASE_DATA, weekLabel: "Week of 15 June 2026" });
+ expect(html).toContain("Week of 15 June 2026");
+ });
+
+ it("includes the unsubscribeUrl in the footer", () => {
+ const url = "https://example.com/unsubscribe?token=xyz";
+ const html = buildDigestHtml({ ...BASE_DATA, unsubscribeUrl: url });
+ expect(html).toContain(url);
+ });
+
+ it("renders fallback message when metrics is null", () => {
+ const html = buildDigestHtml({ ...BASE_DATA, metrics: null });
+ expect(html).toContain("Metrics are loading");
+ });
+
+ it("renders streak section when metrics present with streak", () => {
+ const html = buildDigestHtml({
+ ...BASE_DATA,
+ metrics: {
+ streak: { current: 5, longest: 10, lastCommitDate: null },
+ weeklyCommits: 20,
+ prsThisWeek: 3,
+ weeklyActiveDays: 4,
+ topLanguages: [],
+ topRepos: [],
+ },
+ });
+ expect(html).toContain("Current Streak");
+ expect(html).toContain("5 days");
+ });
+
+ it("renders weekly activity section with correct commit count", () => {
+ const html = buildDigestHtml({
+ ...BASE_DATA,
+ metrics: {
+ streak: { current: 0, longest: 0, lastCommitDate: null },
+ weeklyCommits: 42,
+ prsThisWeek: 2,
+ weeklyActiveDays: 3,
+ topLanguages: [],
+ topRepos: [],
+ },
+ });
+ expect(html).toContain("42");
+ expect(html).toContain("PRs merged");
+ // weeklyActiveDays is rendered as "3" followed by span with "/7"
+ expect(html).toContain("3");
+ expect(html).toContain("/7");
+ });
+
+ it("renders top languages section when languages are present", () => {
+ const html = buildDigestHtml({
+ ...BASE_DATA,
+ metrics: {
+ streak: { current: 0, longest: 0, lastCommitDate: null },
+ weeklyCommits: 10,
+ prsThisWeek: 1,
+ weeklyActiveDays: 2,
+ topLanguages: [{ name: "TypeScript", percentage: 80.5 }],
+ topRepos: [],
+ },
+ });
+ expect(html).toContain("Top languages");
+ expect(html).toContain("TypeScript");
+ });
+
+ it("renders top repos section when repos are present", () => {
+ const html = buildDigestHtml({
+ ...BASE_DATA,
+ metrics: {
+ streak: { current: 0, longest: 0, lastCommitDate: null },
+ weeklyCommits: 10,
+ prsThisWeek: 1,
+ weeklyActiveDays: 2,
+ topLanguages: [],
+ topRepos: [{ name: "my-project", commits: 5, url: "https://github.com/test/my-project" }],
+ },
+ });
+ expect(html).toContain("Most active repositories");
+ expect(html).toContain("my-project");
+ });
+
+ it("escapes HTML in githubLogin", () => {
+ const html = buildDigestHtml({ ...BASE_DATA, githubLogin: "" });
+ expect(html).not.toContain("