Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

186 lines · 11.6 KBRaw History
1import { describe, expect, it } from "vitest";
2import type { Env } from "../../src/env.js";
3import { mentions } from "../../src/mail/notify.js";
4import { DEFAULT_PREFS, mergePrefs, wantsEmail } from "../../src/mail/prefs.js";
5import { sampleJobs } from "../../src/mail/samples.js";
6import { buildMessage, senderAddress } from "../../src/mail/send.js";
7import { compose, toHtml, toText } from "../../src/mail/templates.js";
8import { categoryOf, isMandatory, type MailJob } from "../../src/mail/types.js";
9import { UNSUB_LEGACY_CUTOFF_MS, UNSUB_TTL_MS, readUnsubToken, unsubToken } from "../../src/mail/unsubscribe.js";
10
11const PUB = "https://diffinery.example";
12const env = { PUBLIC_URL: PUB, MASTER_KEY: "test-master-key", MAIL_FROM: "notifications@diffinery.example" } as unknown as Env;
13const samples = sampleJobs(PUB, { accountId: "acc1" }, "diffy");
14
15describe("email templates", () => {
16 it("has a sample for every kind of email", () => {
17 const kinds = new Set(samples.map((s) => s.job.kind));
18 for (const k of ["email_verify", "signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "signup_code", "signup_attempt", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) {
19 expect(kinds.has(k as MailJob["kind"]), k).toBe(true);
20 }
21 });
22
23 it.each(samples.map((s) => [s.id, s.job] as const))("renders %s as HTML and plain text", async (_id, job) => {
24 const msg = await buildMessage(env, job, { email: "diffy@example.com", name: "Diffy", login: "diffy", accountId: "acc1" });
25 expect(msg.subject.length).toBeGreaterThan(5);
26 expect(msg.subject).not.toMatch(/[\r\n]/);
27 expect(msg.html).toMatch(/^<!doctype html>/i);
28 expect(msg.html).toContain(`${PUB}/icon-512.png`);
29 expect(msg.text.length).toBeGreaterThan(40);
30 expect(msg.text).not.toMatch(/<\/?(p|div|table|a)\b/);
31 expect(msg.from.email).toBe("notifications@diffinery.example");
32 expect(msg.headers["X-Diffinery-Reason"]).toBe(job.reason);
33 expect(msg.headers["Auto-Submitted"]).toBe("auto-generated");
34 const mandatory = isMandatory(categoryOf(job));
35 // Security and account emails can't be turned off, so they carry no unsubscribe link.
36 expect(!!msg.headers["List-Unsubscribe"]).toBe(!mandatory);
37 if (!mandatory) {
38 expect(msg.headers["List-Unsubscribe"]).toMatch(new RegExp(`^<${PUB}/-/unsubscribe/[^>]+>$`));
39 expect(msg.headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
40 expect(msg.headers.Precedence).toBe("list");
41 }
42 });
43
44 it("puts a sign-up code in the message but not the subject (the email log keeps subjects)", async () => {
45 const job = samples.find((s) => s.id === "signup-code")!.job;
46 const msg = await buildMessage(env, job, { email: "new@example.com" });
47 expect(msg.subject).not.toContain("042917");
48 expect(msg.text).toMatch(/^ {4}042917$/m);
49 expect(msg.html).toContain("042917");
50 expect(categoryOf(job)).toBe("account");
51 const attempt = await buildMessage(env, samples.find((s) => s.id === "signup-attempt")!.job, { email: "diffy@example.com", login: "diffy", accountId: "acc1" });
52 expect(attempt.text).toMatch(/already belongs to your account @diffy/);
53 expect(attempt.text).not.toMatch(/\b\d{6}\b/);
54 });
55
56 it("threads issue and pull request emails like GitHub", async () => {
57 const opened = samples.find((s) => s.id === "pr-opened")!.job;
58 const comment = samples.find((s) => s.id === "comment")!.job;
59 const a = await buildMessage(env, opened, { email: "a@example.com", accountId: "acc1", login: "diffy" });
60 const b = await buildMessage(env, comment, { email: "a@example.com", accountId: "acc1", login: "diffy" });
61 expect(a.subject).toBe("[acme/website] Cache build artifacts between workflow runs (PR #42)");
62 expect(b.subject).toBe("Re: [acme/website] Cache build artifacts between workflow runs (PR #42)");
63 expect(a.headers.References).toBe(b.headers.References);
64 expect(a.headers["In-Reply-To"]).toBeUndefined();
65 expect(b.headers["In-Reply-To"]).toBe(b.headers.References);
66 expect(a.headers["List-Id"]).toBe("acme/website <website.acme.diffinery.example>");
67 });
68
69 it("escapes what people write", () => {
70 const job = { ...samples.find((s) => s.id === "issue-opened")!.job } as Extract<MailJob, { kind: "thread" }>;
71 job.data = { ...job.data, title: `<img src=x onerror=alert(1)>`, body: `<script>alert(1)</script> **bold**` };
72 const e = compose(job, PUB, "diffy");
73 const html = toHtml(e, { publicUrl: PUB, reason: job.reason, unsubscribe: [] });
74 expect(html).not.toContain("<img src=x");
75 expect(html).not.toContain("<script>alert(1)");
76 expect(html).toContain("<strong>bold</strong>");
77 expect(toText(e, { publicUrl: PUB, reason: job.reason, unsubscribe: [] })).toContain("<img src=x onerror=alert(1)>");
78 });
79});
80
81describe("sender address", () => {
82 it("uses MAIL_FROM, or notifications@ the public host", () => {
83 expect(senderAddress(env)).toBe("notifications@diffinery.example");
84 expect(senderAddress({ PUBLIC_URL: "https://git.example.com", MAIL_FROM: "" } as Env)).toBe("notifications@git.example.com");
85 expect(senderAddress({ PUBLIC_URL: "https://git.example.com" } as Env)).toBe("notifications@git.example.com");
86 });
87});
88
89describe("images in emails", () => {
90 it("links images from other hosts instead of loading them, and keeps Diffinery's own", async () => {
91 const issue = samples.find((x) => x.id === "issue-opened")!.job as Extract<MailJob, { kind: "thread" }>;
92 const job = { ...issue, data: { ...issue.data, body: `Look: ![pixel](https://tracker.example/p.gif?u=1) and ![logo](${PUB}/icon-512.png)` } } as MailJob;
93 const msg = await buildMessage(env, job, { email: "diffy@example.com", name: "Diffy", login: "diffy", accountId: "acc1" });
94 expect(msg.html).not.toMatch(/<img[^>]+tracker\.example/);
95 expect(msg.html).toContain('<a href="https://tracker.example/p.gif?u=1">[pixel]</a>');
96 expect(msg.html).toMatch(new RegExp(`<img[^>]+src="${PUB}/icon-512.png"[^>]*alt="logo"|<img[^>]+alt="logo"[^>]*src="${PUB}/icon-512.png"`));
97 });
98});
99
100describe("mentions", () => {
101 it("finds @logins outside code", () => {
102 expect(mentions("Hey @Diffy and @rivera-2, see `@notme` and\n```\n@alsonot\n```\nmail me@example.com")).toEqual(["diffy", "rivera-2"]);
103 expect(mentions(null)).toEqual([]);
104 });
105});
106
107describe("notification settings", () => {
108 it("fills in defaults and ignores junk", () => {
109 expect(mergePrefs(null)).toEqual(DEFAULT_PREFS);
110 const p = mergePrefs({ watching: { email: false, web: "yes" }, ownActivity: true, bogus: 1 });
111 expect(p.watching).toEqual({ web: true, email: false });
112 expect(p.ownActivity).toBe(true);
113 expect(p).not.toHaveProperty("bogus");
114 });
115
116 it("never turns off security and account email", () => {
117 const off = mergePrefs({ participating: { email: false }, watching: { email: false }, actions: { email: false }, repos: { email: false }, orgs: { email: false } });
118 expect(wantsEmail(off, "security")).toBe(true);
119 expect(wantsEmail(off, "account")).toBe(true);
120 expect(wantsEmail(off, "participating")).toBe(false);
121 expect(categoryOf({ kind: "thread", reason: "subscribed" })).toBe("watching");
122 expect(categoryOf({ kind: "thread", reason: "mention" })).toBe("participating");
123 });
124});
125
126describe("one-click unsubscribe", () => {
127 it.each(samples.filter((s) => !isMandatory(categoryOf(s.job))).map((s) => [s.id, s.job] as const))("%s: List-Unsubscribe turns that kind of email off", async (_id, job) => {
128 const msg = await buildMessage(env, job, { email: "diffy@example.com", login: "diffy", accountId: "acc1" });
129 const link = /^<(.+)>$/.exec(msg.headers["List-Unsubscribe"]!)![1]!;
130 const token = link.slice(`${PUB}/-/unsubscribe/`.length);
131 expect(await readUnsubToken(env, token)).toEqual({ accountId: "acc1", scope: { t: "category", category: categoryOf(job) } });
132 expect(msg.headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
133 // Muting one thread stays a footer link.
134 if (job.kind === "thread") expect(msg.text).toContain("Mute this thread");
135 });
136
137 it("is left out without an account to change", async () => {
138 const msg = await buildMessage(env, samples.find((s) => s.id === "comment")!.job, { email: "someone@example.com" });
139 expect(msg.headers["List-Unsubscribe"]).toBeUndefined();
140 });
141});
142
143describe("unsubscribe links", () => {
144 it("round-trips and rejects tampering", async () => {
145 const t = await unsubToken(env, { accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
146 expect(await readUnsubToken(env, t)).toEqual({ accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
147 const [payload, sig] = t.split(".");
148 const forged = Buffer.from(JSON.stringify({ a: "someone-else", s: { t: "category", category: "watching" } })).toString("base64url");
149 expect(await readUnsubToken(env, `${forged}.${sig}`)).toBeNull();
150 expect(await readUnsubToken(env, `${payload}.${sig!.slice(0, -2)}xx`)).toBeNull();
151 expect(await readUnsubToken({ ...env, MASTER_KEY: "another-key" } as Env, t)).toBeNull();
152 });
153
154 it("expire about a year after they're sent", async () => {
155 const sent = Date.UTC(2027, 0, 1);
156 const t = await unsubToken(env, { accountId: "acc1", scope: { t: "category", category: "watching" } }, sent);
157 expect(await readUnsubToken(env, t, sent + UNSUB_TTL_MS - 1000)).toEqual({ accountId: "acc1", scope: { t: "category", category: "watching" } });
158 expect(await readUnsubToken(env, t, sent + UNSUB_TTL_MS)).toBeNull();
159 });
160
161 it("keep links sent before expiries working only until the cutoff", async () => {
162 // A link as they were signed before: no `e`, signed with the same key.
163 const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-unsubscribe-v1:${env.MASTER_KEY}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
164 const payload = Buffer.from(JSON.stringify({ a: "acc1", s: { t: "category", category: "watching" } })).toString("base64url");
165 const old = `${payload}.${Buffer.from(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))).toString("base64url")}`;
166 expect(await readUnsubToken(env, old, UNSUB_LEGACY_CUTOFF_MS - 1000)).toEqual({ accountId: "acc1", scope: { t: "category", category: "watching" } });
167 expect(await readUnsubToken(env, old, UNSUB_LEGACY_CUTOFF_MS)).toBeNull();
168 // An expiry that isn't a number never passes.
169 const odd = Buffer.from(JSON.stringify({ a: "acc1", s: { t: "category", category: "watching" }, e: "9999999999" })).toString("base64url");
170 const oddToken = `${odd}.${Buffer.from(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(odd))).toString("base64url")}`;
171 expect(await readUnsubToken(env, oddToken, UNSUB_LEGACY_CUTOFF_MS - 1000)).toBeNull();
172 });
173});
174
175describe("Markdown in email", () => {
176 it("hides HTML comments in the HTML and plain-text parts", async () => {
177 const thread = samples.find((s) => s.id === "comment")!.job;
178 if (thread.kind !== "thread") throw new Error("expected a thread sample");
179 const job = { ...thread, data: { ...thread.data, body: '<!-- claude-projects-attribution: {"github_login":"diffy"} -->\n_Requested by Diffy_\n\nLooks good.' } } as MailJob;
180 const msg = await buildMessage(env, job, { email: "diffy@example.com", name: "Diffy", login: "diffy", accountId: "acc1" });
181 for (const part of [msg.html, msg.text]) {
182 expect(part).not.toContain("claude-projects-attribution");
183 expect(part).toContain("Looks good.");
184 }
185 });
186});