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.

136 lines · 8.1 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 { 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 ["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("unsubscribe links", () => {
127 it("round-trips and rejects tampering", async () => {
128 const t = await unsubToken(env, { accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
129 expect(await readUnsubToken(env, t)).toEqual({ accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
130 const [payload, sig] = t.split(".");
131 const forged = Buffer.from(JSON.stringify({ a: "someone-else", s: { t: "category", category: "watching" } })).toString("base64url");
132 expect(await readUnsubToken(env, `${forged}.${sig}`)).toBeNull();
133 expect(await readUnsubToken(env, `${payload}.${sig!.slice(0, -2)}xx`)).toBeNull();
134 expect(await readUnsubToken({ ...env, MASTER_KEY: "another-key" } as Env, t)).toBeNull();
135 });
136});