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.

519 lines · 31.1 KBRaw History
1// End-to-end email: a real `wrangler dev` Worker whose Cloudflare Email Sending
2// binding is simulated by Miniflare (it accepts the message and returns a
3// message id), the real `diffinery-mail` queue and the real runner agent.
4//
5// pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e
6//
7// Covers: security emails (tokens, agents), notification settings, the test
8// email, every template, watching repositories you create, @mentions, your
9// own activity staying quiet, one-click unsubscribe (RFC 8058) turning that
10// kind of email off, the footer link muting a thread while mentions still get
11// through, failed workflow run emails, and the suppression list: signed bounce
12// and complaint intake, no email to a suppressed address, lifting it again; the
13// morning digest on its schedule (phase 5c) and Today at phone width.
14import "./fresh-connections.mjs";
15import { spawn, spawnSync, execFileSync } from "node:child_process";
16import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
17import { tmpdir } from "node:os";
18import { createHmac, randomBytes } from "node:crypto";
19import { join, dirname, resolve } from "node:path";
20import { fileURLToPath } from "node:url";
21import { test, before, after } from "node:test";
22import assert from "node:assert/strict";
23import { chromium } from "playwright";
24import { migratedState } from "./d1-state.mjs";
25import { freePort } from "./ports.mjs";
26
27const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
28const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
29const work = mkdtempSync(join(tmpdir(), "diffinery-email-e2e-"));
30let base;
31let dev;
32let devLog = "";
33let token;
34let me;
35const eventsSecret = randomBytes(24).toString("hex");
36
37async function api(method, path, body, tok = token) {
38 const res = await fetch(`${base}/api/v3${path}`, {
39 method,
40 headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" },
41 body: body === undefined ? undefined : JSON.stringify(body),
42 });
43 const text = await res.text();
44 let json = null;
45 try {
46 json = text ? JSON.parse(text) : null;
47 } catch {
48 json = text;
49 }
50 return { status: res.status, json };
51}
52
53/**
54 * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
55 * does (personal tokens can't claim jobs): one runner per repository and set of labels.
56 */
57const e2eRunners = new Map();
58async function runnerClaim(repoPath, labels = ["linux"]) {
59 const key = `${repoPath} ${labels.join(",")}`;
60 if (!e2eRunners.has(key)) {
61 const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
62 assert.equal(reg.status, 201, JSON.stringify(reg.json));
63 const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
64 assert.equal(runner.status, 201, JSON.stringify(runner.json));
65 e2eRunners.set(key, runner.json.token);
66 }
67 const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
68 // A repository deleted and created again under the same name needs a new runner.
69 if (r.status !== 401) return r;
70 e2eRunners.delete(key);
71 return runnerClaim(repoPath, labels);
72}
73async function ok(method, path, body, tok) {
74 const r = await api(method, path, body, tok);
75 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
76 return r.json;
77}
78
79const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
80/** `explain`, if given, says on a timeout where things stood (the polling's own log lines are left out of the tail). */
81async function until(what, fn, timeoutMs = 60_000, explain = async () => "") {
82 const end = Date.now() + timeoutMs;
83 let last;
84 while (Date.now() < end) {
85 last = await fn();
86 if (last) return last;
87 await sleep(500);
88 }
89 const tail = devLog
90 .split("\n")
91 .filter((l) => !/runners\/claim 204/.test(l))
92 .join("\n")
93 .slice(-4000);
94 assert.fail(`timed out waiting for ${what}\n${await explain().catch((e) => `(couldn't say where things stood: ${e.message})`)}\n--- wrangler log tail ---\n${tail}`);
95}
96
97/**
98 * Waits for a self-hosted job of the repository to claim; on a timeout, says what the push printed and
99 * where the repository's runs and their jobs stood, so a job that never became claimable can be told apart.
100 */
101async function claimJob(repoPath, pushed = "") {
102 return until(
103 "a job to claim",
104 async () => {
105 const r = await runnerClaim(repoPath, ["linux"]);
106 return r.status === 200 ? r.json : null;
107 },
108 60_000,
109 async () => {
110 const runs = (await ok("GET", `${repoPath}/actions/runs?per_page=10`)).workflow_runs;
111 const lines = [`push output: ${pushed.trim() || "(none)"}`, `runs: ${runs.length}`];
112 for (const r of runs) {
113 const jobs = (await ok("GET", `${repoPath}/actions/runs/${r.id}/jobs?filter=all`)).jobs;
114 lines.push(`- run ${r.run_number} ${r.head_sha.slice(0, 7)} ${r.status}/${r.conclusion}: ${jobs.map((j) => `${j.name} ${j.status}/${j.conclusion}`).join(", ") || "no jobs"}`);
115 }
116 return lines.join("\n");
117 },
118 );
119}
120
121/** My emails, newest first, with the full message (kept in local development only). */
122const emails = async (q = "") => ok("GET", `/diffinery/emails?per_page=200${q}`);
123/** Wait for an email matching `pred` and return it. */
124const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred));
125
126function git(cwd, args, tok = token) {
127 const auth = Buffer.from(`x-token:${tok}`).toString("base64");
128 return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
129 cwd,
130 encoding: "utf8",
131 stdio: ["ignore", "pipe", "pipe"],
132 env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
133 });
134}
135
136/** Pushes main and returns what the server said (a workflow that failed to start says so here). */
137function gitPush(cwd) {
138 const auth = Buffer.from(`x-token:${token}`).toString("base64");
139 const r = spawnSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", "push", "origin", "main"], {
140 cwd,
141 encoding: "utf8",
142 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
143 });
144 assert.equal(r.status, 0, `git push failed: ${r.stderr}`);
145 return r.stderr.split("\n").filter((l) => l.startsWith("remote:")).join("\n");
146}
147
148function spawnAsync(cmd, args, env, timeoutMs) {
149 return new Promise((done) => {
150 const child = spawn(cmd, args, { env: { ...process.env, ...env } });
151 let out = "";
152 child.stdout.on("data", (d) => (out += d));
153 child.stderr.on("data", (d) => (out += d));
154 const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
155 child.on("close", (status) => {
156 clearTimeout(t);
157 done({ status, out });
158 });
159 });
160}
161
162before(async () => {
163 const port = await freePort();
164 base = `http://127.0.0.1:${port}`;
165 const persist = join(work, "state");
166 migratedState(APP, persist);
167 dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `MAIL_EVENTS_SECRET:${eventsSecret}`, "--show-interactive-dev-session=false"], {
168 cwd: APP,
169 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
170 stdio: ["ignore", "pipe", "pipe"],
171 detached: true,
172 });
173 dev.stdout.on("data", (d) => (devLog += d));
174 dev.stderr.on("data", (d) => (devLog += d));
175 await until("wrangler dev", async () => {
176 try {
177 return (await fetch(`${base}/healthz`)).ok;
178 } catch {
179 return false;
180 }
181 }, 120_000);
182 const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "email-e2e", scopes: ["admin"] }) });
183 assert.equal(res.status, 201, await res.clone().text());
184 token = (await res.json()).token;
185 me = await ok("GET", "/user");
186});
187
188after(() => {
189 if (dev?.pid) {
190 try {
191 process.kill(-dev.pid, "SIGTERM");
192 } catch {}
193 }
194 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
195});
196
197test("email: security emails, settings, the test email and every template", async () => {
198 // Creating a token is a security email, and it was delivered through the EMAIL binding.
199 const tokenMail = await email("token created", (e) => e.kind === "token_created");
200 assert.equal(tokenMail.status, "sent", JSON.stringify(tokenMail));
201 assert.ok(tokenMail.message_id, "Email Sending returned a message id");
202 assert.equal(tokenMail.to_addr, me.email);
203 assert.match(tokenMail.subject, /token/i);
204 const msg = tokenMail.dev_copy;
205 assert.equal(msg.from.email, "notifications@diffinery.com");
206 assert.match(msg.html, /email-e2e/);
207 assert.match(msg.text, /email-e2e/);
208 assert.equal(msg.headers["X-Diffinery-Reason"], "security_alert");
209 assert.equal(msg.headers["List-Unsubscribe"], undefined, "security emails can't be turned off");
210
211 // Settings: partial updates, defaults kept, junk refused.
212 const defaults = await ok("GET", "/diffinery/notification-settings");
213 assert.deepEqual(defaults.participating, { web: true, email: true });
214 assert.equal(defaults.actions.failuresOnly, true);
215 const changed = await ok("PUT", "/diffinery/notification-settings", { watching: { email: false } });
216 assert.deepEqual(changed.watching, { web: true, email: false });
217 assert.deepEqual(changed.participating, defaults.participating);
218 assert.equal((await api("PUT", "/diffinery/notification-settings", { bogus: true })).status, 422);
219 await ok("PUT", "/diffinery/notification-settings", { watching: { email: true } });
220
221 // The test email goes out right away and reports Cloudflare's answer.
222 const sent = await ok("POST", "/diffinery/emails/test");
223 assert.equal(sent.status, "sent", JSON.stringify(sent));
224 assert.ok(sent.messageId);
225
226 // Site admins: delivery status and every template, sent for real.
227 const status = await ok("GET", "/diffinery/admin/email");
228 assert.equal(status.sending, true);
229 assert.equal(status.queue, true);
230 const all = await ok("POST", "/diffinery/admin/emails/samples");
231 assert.ok(all.total >= 30, `only ${all.total} templates`);
232 assert.equal(all.sent, all.total, JSON.stringify(all.results.filter((r) => r.status !== "sent")));
233
234 // The template gallery and the settings page render.
235 const gallery = await fetch(`${base}/settings/admin/email`);
236 assert.equal(gallery.status, 200);
237 assert.match(await gallery.text(), /Send every template to me/);
238 const preview = await fetch(`${base}/settings/admin/email/preview/run-failed`);
239 assert.equal(preview.status, 200);
240 assert.match(preview.headers.get("content-security-policy") ?? "", /default-src 'none'/);
241 assert.match(await preview.text(), /Run failed/);
242 const settings = await fetch(`${base}/settings/notifications`);
243 assert.equal(settings.status, 200);
244 assert.match(await settings.text(), /Watched repositories/);
245});
246
247test("email: watching, mentions, own activity, one-click unsubscribe and failed runs", async () => {
248 const owner = me.login;
249 // Repositories you create are watched (All activity).
250 await ok("POST", "/user/repos", { name: "mailbox" });
251 const sub = await ok("GET", `/repos/${owner}/mailbox/subscription`);
252 assert.equal(sub.subscribed, true);
253
254 // Adding an agent is a security email.
255 const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" }, null);
256 const botToken = bot.token.token;
257 await email("agent added", (e) => e.kind === "agent_created");
258
259 // The agent opens an issue that @mentions me.
260 const issue = await ok("POST", `/repos/${owner}/mailbox/issues`, { title: "Flaky deploy step", body: `@${owner} can you take a look?` }, botToken);
261 const opened = await email("issue opened", (e) => e.kind === "thread" && e.subject.includes(`(Issue #${issue.number})`));
262 assert.equal(opened.status, "sent");
263 assert.equal(opened.subject, `[${owner}/mailbox] Flaky deploy step (Issue #${issue.number})`);
264 assert.equal(opened.dev_copy.headers["X-Diffinery-Reason"], "mention");
265 assert.equal(opened.dev_copy.from.name, "mail-bot (Diffinery)");
266 assert.match(opened.dev_copy.text, /can you take a look/);
267
268 // My own comment doesn't email me; the agent's reply does (I commented, like GitHub's "comment" reason).
269 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "On it." });
270 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Thanks, the logs are attached." }, botToken);
271 const reply = await email("reply", (e) => e.kind === "thread" && e.subject.startsWith("Re: ") && e.dev_copy?.text.includes("logs are attached"));
272 assert.equal(reply.dev_copy.headers["X-Diffinery-Reason"], "comment");
273 assert.equal(reply.dev_copy.headers["In-Reply-To"], opened.dev_copy.headers.References);
274
275 // One-click unsubscribe from the mail app (RFC 8058) turns that kind of email off: here, Participating.
276 const link = /^<(.+)>$/.exec(reply.dev_copy.headers["List-Unsubscribe"])[1];
277 assert.ok(link.startsWith(`${base}/-/unsubscribe/`));
278 assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click");
279 const confirm = await fetch(link);
280 const confirmPage = await confirm.text();
281 assert.match(confirmPage, /Turn off email about issues and pull requests you take part in/);
282 // Whoever holds the link doesn't learn the account's email address from it.
283 assert.ok(reply.to_addr && !confirmPage.includes(reply.to_addr), "the unsubscribe page doesn't show the email address");
284 const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
285 assert.equal(unsub.status, 200, await unsub.clone().text());
286 assert.equal((await ok("GET", "/diffinery/notification-settings")).participating.email, false);
287 assert.equal((await fetch(`${link.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused");
288 // Turned off: the next reply is skipped, not sent.
289 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Nobody hears this one." }, botToken);
290 const quiet = await email("a skipped reply", (e) => e.kind === "thread" && e.status === "skipped" && e.detail === "turned off participating emails");
291 assert.equal(quiet.dev_copy, undefined);
292 await ok("PUT", "/diffinery/notification-settings", { participating: { email: true } });
293
294 // The footer's Mute this thread link mutes just the thread (one-click works there too).
295 const mute = /Mute this thread: (\S+)/.exec(reply.dev_copy.text)[1];
296 assert.match(await (await fetch(mute)).text(), new RegExp(`Mute ${owner}/mailbox#${issue.number}`));
297 const muted = await fetch(mute, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
298 assert.equal(muted.status, 200, await muted.clone().text());
299 const page = await (await fetch(`${base}/${owner}/mailbox/issues/${issue.number}`)).text();
300 assert.match(page, /You muted this issue/);
301
302 // Muted: a plain comment stays quiet, a mention still gets through.
303 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Another update nobody needs." }, botToken);
304 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: `@${owner} this one needs you.` }, botToken);
305 await email("mention on a muted thread", (e) => e.kind === "thread" && e.dev_copy?.text.includes("this one needs you"));
306 const threadMails = (await emails("&kind=thread")).filter((e) => e.status === "sent" && e.subject.includes(`(Issue #${issue.number})`));
307 assert.deepEqual(
308 threadMails.map((e) => e.dev_copy.headers["X-Diffinery-Reason"]).reverse(),
309 ["mention", "comment", "mention"],
310 "opened, the reply and the last mention; not my comment or the muted one",
311 );
312
313 // A failed workflow run emails the person who pushed.
314 if (!existsSync(AGENT)) return assert.fail(`build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
315 const src = join(work, "mailbox");
316 mkdirSync(join(src, ".github/workflows"), { recursive: true });
317 writeFileSync(join(src, ".github/workflows/ci.yml"), "name: CI\non: push\njobs:\n test:\n runs-on: [self-hosted, linux]\n steps:\n - name: Unit tests\n run: echo boom && exit 3\n");
318 git(src, ["init", "-q", "-b", "main"]);
319 git(src, ["add", "-A"]);
320 git(src, ["commit", "-q", "-m", "Add CI that fails"]);
321 git(src, ["remote", "add", "origin", `${base}/${owner}/mailbox.git`]);
322 const claim = await claimJob(`/repos/${owner}/mailbox`, gitPush(src));
323 await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000);
324 const failed = await email("run failed", (e) => e.kind === "run" && e.subject.includes(`${owner}/mailbox`));
325 assert.equal(failed.status, "sent");
326 assert.match(failed.subject, new RegExp(`^\\[${owner}/mailbox\\] Run failed: CI - main \\([0-9a-f]{7}\\)$`));
327 assert.equal(failed.dev_copy.headers["X-Diffinery-Reason"], "ci_activity");
328 assert.match(failed.dev_copy.text, /test/);
329
330 // Ignoring the repository silences its runs too, even the ones you pushed.
331 const runOnce = async (file) => {
332 writeFileSync(join(src, file), `${file}\n`);
333 git(src, ["add", "-A"]);
334 git(src, ["commit", "-q", "-m", `Add ${file}`]);
335 const c = await claimJob(`/repos/${owner}/mailbox`, gitPush(src));
336 await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: c.api_url, DIFFINERY_JOB_ID: c.job_id, DIFFINERY_JOB_TOKEN: c.token, DIFFINERY_WORK_ROOT: join(work, `job-${c.job_id}`) }, 180_000);
337 };
338 await ok("PUT", `/repos/${owner}/mailbox/subscription`, { ignored: true });
339 await runOnce("ignored.txt");
340 const ignoredSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim();
341 await until("the ignored run to finish", async () => {
342 const r = await ok("GET", `/repos/${owner}/mailbox/actions/runs?head_sha=${git(src, ["rev-parse", "HEAD"]).trim()}`);
343 return r.workflow_runs[0]?.status === "completed" ? r : null;
344 });
345 // The run's event goes through two queues (1 s batches here); give them a few rounds.
346 await new Promise((r) => setTimeout(r, 8000));
347 const ignoredMails = (await emails("&kind=run")).filter((e) => e.subject.includes(`(${ignoredSha})`));
348 assert.deepEqual(ignoredMails, [], "no email for a run in an ignored repository");
349 // Back to the default, and run emails come again.
350 await ok("DELETE", `/repos/${owner}/mailbox/subscription`);
351 await runOnce("watched.txt");
352 const watchedSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim();
353 await email("run failed after un-ignoring", (e) => e.kind === "run" && e.subject.includes(`(${watchedSha})`));
354});
355
356test("email: a commit blocked by push protection, made without git, is emailed like a blocked push", async () => {
357 await ok("POST", "/user/repos", { name: "leak-mail", auto_init: true });
358 // Built at run time so this file holds no credential-shaped string.
359 const leak = `AWS_ACCESS_KEY_ID=${"AKIA"}IOSFODNN7EXAMPL3\n`;
360 const r = await api("PUT", `/repos/${me.login}/leak-mail/contents/config.env`, { message: "leak", content: Buffer.from(leak).toString("base64") });
361 assert.equal(r.status, 422, JSON.stringify(r.json));
362 const blocked = await email("blocked commit", (e) => e.kind === "push_blocked" && /leak-mail/.test(e.subject));
363 assert.equal(blocked.to_addr, me.email);
364 assert.match(blocked.dev_copy.text, /config\.env/);
365 assert.match(blocked.dev_copy.text, /AWS access key/);
366});
367
368/** POST bounce or complaint events to the signed intake; `sign` false sends no signature, a string signs with that secret. */
369function postEvents(events, sign = eventsSecret, t = Math.floor(Date.now() / 1000)) {
370 const body = JSON.stringify(events);
371 const headers = { "content-type": "application/json" };
372 if (sign) headers["x-diffinery-signature"] = `t=${t},v1=${createHmac("sha256", sign).update(`${t}.${body}`).digest("hex")}`;
373 return fetch(`${base}/-/email/events`, { method: "POST", headers, body });
374}
375
376test("email: bounces and complaints suppress an address; its owner lifts it by verifying, an admin removes it", async () => {
377 const bounce = (recipient) => ({ type: "cf.email.sending.message.bounced", source: { type: "email.sending" }, payload: { recipient, bounce: { type: "hard", classification: "permanent_failure", reason: "550 5.1.1 User unknown" } } });
378
379 // The intake refuses anything without a good, recent signature, and changes nothing.
380 for (const r of [await postEvents([bounce(me.email)], false), await postEvents([bounce(me.email)], "not-the-secret"), await postEvents([bounce(me.email)], eventsSecret, Math.floor(Date.now() / 1000) - 3600)]) {
381 assert.equal(r.status, 401, await r.text());
382 }
383 assert.equal((await ok("GET", "/diffinery/email")).suppressed, null);
384 assert.equal((await ok("POST", "/diffinery/emails/test")).status, "sent");
385
386 // An authenticated permanent bounce suppresses my address; a soft one and a delivery change nothing.
387 const soft = { type: "cf.email.sending.message.deferred", payload: { recipient: "soft@example.com", bounce: { type: "soft" } } };
388 const r = await postEvents({ events: [bounce(me.email.toUpperCase()), soft, { type: "cf.email.sending.message.delivered", payload: { recipient: "x@example.com" } }] });
389 assert.equal(r.status, 200, await r.clone().text());
390 assert.deepEqual(await r.json(), { suppressed: 1, ignored: 2 });
391 const mine = await ok("GET", "/diffinery/email");
392 assert.equal(mine.suppressed.reason, "hard-bounce");
393 assert.equal(mine.suppressed.detail, "550 5.1.1 User unknown");
394 assert.equal(mine.suppressed.owner_can_lift, true);
395
396 // Then nothing is sent to it: not the test email, not a security email.
397 const test = await ok("POST", "/diffinery/emails/test");
398 assert.deepEqual(test, { status: "skipped", detail: "address is suppressed: email to it bounced permanently" });
399 await ok("POST", "/diffinery/tokens", { name: "after-bounce", scopes: ["read"] }, null);
400 const skipped = await email("token email skipped", (e) => e.kind === "token_created" && e.status === "skipped");
401 assert.match(skipped.detail, /address is suppressed/);
402 assert.equal((await emails("&kind=token_created")).filter((e) => e.status === "sent" && e.created_at >= skipped.created_at).length, 0);
403
404 // The settings pages say so; admins see it in the list and the audit log.
405 assert.match(await (await fetch(`${base}/settings/email`)).text(), /email to it bounced permanently[\s\S]*Send a verification email/);
406 assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /Settings → Email/);
407 const list = await ok("GET", "/diffinery/admin/email/suppressions");
408 assert.ok(list.some((x) => x.email === me.email.toLowerCase() && x.source === "webhook"));
409 assert.match(await (await fetch(`${base}/settings/admin/email`)).text(), /Suppressed addresses/);
410
411 // Re-verifying lifts it: the verification email is the one that goes through.
412 const v = await ok("POST", "/diffinery/email/verification");
413 assert.equal(v.status, "sent", JSON.stringify(v));
414 const vmail = await email("verification email", (e) => e.kind === "email_verify" && e.status === "sent");
415 const verifyUrl = /(http\S+\/settings\/email\/verify\/\S+)/.exec(vmail.dev_copy.text)[1];
416 const form = await fetch(verifyUrl);
417 const html = await form.text();
418 assert.equal(form.status, 200, html);
419 const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1];
420 const cookie = /dfy_csrf=([^;]+)/.exec(form.headers.get("set-cookie") ?? "")?.[1];
421 const lift = await fetch(verifyUrl, { method: "POST", body: new URLSearchParams({ _csrf: csrf }), redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" } });
422 assert.equal(lift.status, 303);
423 assert.equal((await ok("GET", "/diffinery/email")).suppressed, null);
424 assert.equal((await ok("POST", "/diffinery/emails/test")).status, "sent");
425 assert.equal((await fetch(`${verifyUrl.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused");
426
427 // A complaint about an address without an account; an admin removes it, audited.
428 const complaint = await postEvents({ type: "cf.email.sending.message.complained", payload: { recipient: "friend@example.com", complaint: { type: "abuse" } } });
429 assert.deepEqual(await complaint.json(), { suppressed: 1, ignored: 0 });
430 const invite = await ok("POST", "/invitations", { email: "friend@example.com" });
431 assert.ok(invite);
432 const inviteMail = await until("invite email skipped", async () => (await ok("GET", "/diffinery/emails?all=1&kind=invite&per_page=50")).find((e) => e.to_addr === "friend@example.com"));
433 assert.equal(inviteMail.status, "skipped");
434 assert.match(inviteMail.detail, /reported as spam/);
435 assert.equal((await api("DELETE", "/diffinery/admin/email/suppressions/friend@example.com")).status, 204);
436 assert.equal((await api("DELETE", "/diffinery/admin/email/suppressions/friend@example.com")).status, 404);
437 assert.ok(!(await ok("GET", "/diffinery/admin/email/suppressions")).some((x) => x.email === "friend@example.com"));
438 const audit = await (await fetch(`${base}/settings/audit`)).text();
439 for (const action of ["email.suppression.add", "email.suppression.lift", "email.suppression.remove"]) assert.match(audit, new RegExp(action.replaceAll(".", "\\.")));
440});
441
442test("email: the morning digest on its schedule, one-click unsubscribe, and Today on a phone (phase 5c)", async () => {
443 const S = "/diffinery/notification-settings";
444 // The template gallery in the first test already sent a sample digest; count from here.
445 const sample = (await emails()).filter((e) => e.kind === "digest").map((e) => e.id ?? `${e.created_at} ${e.subject}`);
446 const digests = async () => (await emails()).filter((e) => e.kind === "digest" && !sample.includes(e.id ?? `${e.created_at} ${e.subject}`));
447 assert.deepEqual((await ok("GET", S)).digest, { email: false, hour: 8, timeZone: "UTC", weekends: false });
448 assert.equal((await api("PUT", S, { digest: { hour: 24 } })).status, 422);
449 assert.equal((await api("PUT", S, { digest: { timeZone: "Mars/Olympus" } })).status, 422);
450
451 // On, for the next hour in Tokyo, every day.
452 const hour = (Number(new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Tokyo", hour: "numeric", hourCycle: "h23" }).format(new Date())) + 1) % 24;
453 const saved = await ok("PUT", S, { digest: { email: true, hour, timeZone: "Asia/Tokyo", weekends: true } });
454 assert.deepEqual(saved.digest, { email: true, hour, timeZone: "Asia/Tokyo", weekends: true });
455 const next = /The next one goes out <time datetime="([^"]+)"/.exec(await (await fetch(`${base}/settings/notifications`)).text())?.[1];
456 assert.ok(next && Date.parse(next) > Date.now() && Date.parse(next) <= Date.now() + 3600_000, `next digest at ${next}`);
457 assert.equal(new Date(next).getUTCMinutes(), 0);
458
459 // The five-minute cron sends nothing before the hour, then queues it once; it goes out like any email.
460 const cron = (t) => fetch(`${base}/cdn-cgi/local/scheduled?cron=${encodeURIComponent("*/5 * * * *")}&time=${t}`);
461 assert.equal((await cron(Date.parse(next) - 10 * 60_000)).status, 200);
462 await sleep(2000);
463 assert.equal((await digests()).length, 0);
464 assert.equal((await cron(Date.parse(next) + 2 * 60_000)).status, 200);
465 const d = await until("the morning digest", async () => (await digests())[0]);
466 assert.equal(d.status, "sent", d.detail);
467 assert.match(d.subject, /^\[Diffinery\] Today: /);
468 assert.equal(d.dev_copy.headers["X-Diffinery-Kind"], "digest");
469 assert.equal(d.dev_copy.headers["X-Diffinery-Reason"], "digest");
470 assert.match(d.dev_copy.text, /Failed runs · \d+/);
471 assert.ok(d.dev_copy.text.includes(`Open Today: ${base}/`));
472 assert.match(d.dev_copy.text, /you turned on the morning digest/);
473 assert.equal((await cron(Date.parse(next) + 7 * 60_000)).status, 200);
474 await sleep(3000);
475 assert.equal((await digests()).length, 1, "the next run doesn't send it again");
476 assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/);
477
478 // The mail app's one-click unsubscribe turns off only the digest.
479 const link = /^<(.+)>$/.exec(d.dev_copy.headers["List-Unsubscribe"])[1];
480 const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
481 assert.equal(unsub.status, 200);
482 const off = await ok("GET", S);
483 assert.equal(off.digest.email, false);
484 assert.equal(off.participating.email, true);
485 assert.doesNotMatch(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/);
486
487 // Asked for, it is sent now even while off.
488 const now = await ok("POST", "/diffinery/emails/digest");
489 assert.equal(now.status, "sent", JSON.stringify(now));
490 assert.equal((await digests()).length, 2);
491
492 // The settings form: the browser's time zone, filled in by the page.
493 const formPage = await fetch(`${base}/settings/notifications`);
494 const csrf = /name="_csrf" value="([^"]+)"/.exec(await formPage.text())?.[1];
495 const cookie = /dfy_csrf=([^;]+)/.exec(formPage.headers.get("set-cookie") ?? "")?.[1];
496 const fields = [["_csrf", csrf], ...["participating.web", "participating.email", "watching.web", "watching.email", "actions.web", "actions.email", "actions.failuresOnly", "repos.email", "orgs.email", "autoWatch"].map((k) => [k, "1"]), ["digest.email", "1"], ["digest.hour", "7"], ["digest.timeZone", "browser"], ["digest.browserTimeZone", "Europe/Istanbul"]];
497 const posted = await fetch(`${base}/settings/notifications`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams(fields) });
498 assert.equal(posted.status, 303);
499 assert.deepEqual((await ok("GET", S)).digest, { email: true, hour: 7, timeZone: "Europe/Istanbul", weekends: false });
500
501 // On a phone: the page fills in the browser's zone, nothing scrolls sideways, and Today comes before the repository list.
502 const browser = await chromium.launch();
503 try {
504 const page = await (await browser.newContext({ viewport: { width: 390, height: 844 }, timezoneId: "America/Sao_Paulo", isMobile: true, hasTouch: true })).newPage();
505 const overflow = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
506 await page.goto(`${base}/settings/notifications`);
507 assert.equal(await page.locator("input[data-browser-tz]").inputValue(), "America/Sao_Paulo");
508 assert.equal(await overflow(), 0, "the settings page scrolls sideways");
509 if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "notifications-390.png"), fullPage: true });
510 await page.goto(`${base}/`);
511 assert.equal(await overflow(), 0, "Today scrolls sideways");
512 const today = await page.locator("h1", { hasText: "Today" }).boundingBox();
513 const side = await page.locator("h3", { hasText: /repositories/i }).first().boundingBox();
514 assert.ok(today && side && today.y < side.y, "Today comes first");
515 if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "today-390.png"), fullPage: true });
516 } finally {
517 await browser.close();
518 }
519});