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.

Email notifications on Cloudflare Email Sending (#18)

* Probe Cloudflare Email Sending status, read-only (temporary)

* Email: templates, settings, queue and hooks on Cloudflare Email Sending (WIP)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179NAjfJDTGSXDDZ1nPq8nW

* Probe: read mail DNS records directly (temporary)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179NAjfJDTGSXDDZ1nPq8nW

* Email: settings, watch and subscribe controls, unsubscribe, admin page, API, tests and docs

- Settings > Notifications: per-group web and email switches, watched
  repositories, a test email and recent emails.
- Watch menu on every repository; Subscribe / Unsubscribe on issues and PRs.
- Signed one-click unsubscribe links (RFC 8058) with undo.
- Settings > Email for site admins: delivery status, log, template previews
  and sending every template.
- REST: repository subscription (GitHub shape), notification settings,
  email log, test email, admin status and samples.
- New sign-in emails carry device, IP and location; push protection emails
  carry the refs; sign-in links and invites are emailed.
- Unit tests for every template, mentions, settings and unsubscribe tokens;
  an end-to-end test through Miniflare's Email Sending binding.
- Deploy checks the sending domain's DNS; the live test checks the bindings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179NAjfJDTGSXDDZ1nPq8nW

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 31fe8fbcommit 7cf0a49ccc0f075ea86c07015551b888555823eeBrowse files
33 files changed+3056−69
Files (33)
modified.github/workflows/live-test.yml+13−2View file
@@ -1,7 +1,7 @@
11 name: Live test
22
3-# End-to-end checks against the real diffinery.com after each deploy: hosted
4-# Actions jobs in Cloudflare Containers (with Docker builds and services), the container registry, agent sandboxes,
3+# End-to-end checks against the real diffinery.com after each deploy: email
4+# sending, hosted Actions jobs in Cloudflare Containers (with Docker builds and services), the container registry, agent sandboxes,
55 # webhooks, scheduled workflows, Git LFS, and pull requests (test merge commits, pull_request_target and the merge
66 # queue). Each run works in a throwaway private repository that it
77 # deletes at the end.
@@ -46,6 +46,17 @@
4646 echo "OWNER=$login" >> "$GITHUB_ENV"
4747 echo "testing as $login in $login/$REPO"
4848
49+ - name: Email sending is configured
50+ if: steps.token.outputs.skip != 'true'
51+ run: |
52+ set -euo pipefail
53+ s=$(curl -fsS "$DIFFINERY/api/v3/diffinery/admin/email" -H "Authorization: Bearer $TOKEN")
54+ echo "$s" | jq '{sending, queue, from, last_24h}'
55+ [ "$(jq -r .sending <<<"$s")" = true ] || { echo "::error::the Worker has no Email Sending binding"; exit 1; }
56+ [ "$(jq -r .queue <<<"$s")" = true ] || { echo "::error::the Worker has no mail queue binding"; exit 1; }
57+ failed=$(jq -r .last_24h.failed <<<"$s")
58+ [ "$failed" = 0 ] || echo "::warning::$failed emails failed in the last 24 hours; see Settings -> Email in site administration"
59+
4960 - name: Create a throwaway repository with a webhook
5061 if: steps.token.outputs.skip != 'true'
5162 run: |
modifiedREADME.md+52−4View file
@@ -87,11 +87,11 @@
8787 code, and add a passkey on the page that follows.
8888 2. **Inviting someone.** An admin or org owner creates an invite from
8989 *Settings → Site administration*, an organization's *People* tab, or
90- `diffinery invite friend@example.com --org codician`. The person opens the
91- link, picks a username and creates a passkey, and they're in.
90+ `diffinery invite friend@example.com --org codician`. Diffinery emails them
91+ the link; they open it, pick a username and create a passkey, and they're in.
9292 3. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in
93- *Settings → Site administration* and sends them the one-time link, which
94- works for 24 hours. People can also recover through `/auth/access` by email.
93+ *Settings → Site administration*; Diffinery emails them the one-time link,
94+ which works for 24 hours. People can also recover through `/auth/access` by email.
9595 4. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or
9696 keep one in a password manager that syncs. The same page signs out other
9797 browsers.
@@ -106,6 +106,54 @@
106106 diffinery login && diffinery auth setup-git # or let the CLI handle credentials
107107 ```
108108
109+## Email
110+
111+Diffinery sends email through [Cloudflare Email Sending](https://developers.cloudflare.com/email-service/)
112+from `notifications@diffinery.com` (`MAIL_FROM` in `wrangler.jsonc`). Every
113+email has an HTML and a plain-text version, and they work like GitHub's:
114+
115+| Group | Emails | Can be turned off |
116+| --- | --- | --- |
117+| Security | New sign-in from an unknown browser or device, passkey added or removed, access token created or about to expire, agent added, push protection bypassed or a push blocked | No |
118+| Account | Invitations, sign-in links, welcome, account disabled or enabled, test email | No |
119+| Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, asked to review, @mentioned in or subscribed to: new comments, reviews, pushes, merges, closes and reopens | Yes |
120+| Watching | Everything in repositories you watch with *All activity* | Yes |
121+| Actions | Failed workflow runs you started (all results if you ask) | Yes |
122+| Repositories | Repositories created or deleted in your organizations, repository access granted | Yes |
123+| Organizations | Organization role changes, accepted invitations, access requests (admins) | Yes |
124+
125+- **Settings.** *Settings → Notifications* turns each group on or off, on
126+ Diffinery and by email, lists the repositories you watch and sends a test
127+ email. Each repository has a *Watch* menu (participating, all activity,
128+ ignore) and each issue and pull request a *Subscribe / Unsubscribe* button.
129+ Your own comments and changes don't email you unless you ask.
130+- **Unsubscribing.** Emails carry `List-Unsubscribe` and one-click
131+ unsubscribe headers (RFC 8058), so mail apps show an Unsubscribe button, and
132+ the footer links mute the thread, stop watching the repository or turn off
133+ that group. Links are signed, so they work without signing in.
134+- **Threading.** Emails about one issue or pull request share
135+ `References`/`In-Reply-To` headers and a `[owner/repo]` subject, and carry
136+ `X-Diffinery-Reason` (like `X-GitHub-Reason`) for filters.
137+- **Delivery.** Emails go through the `diffinery-mail` queue (retries with
138+ backoff, then the `diffinery-mail-dlq` dead-letter queue) and are logged for
139+ 90 days. Site admins see delivery status, the log and a preview of every
140+ template under *Settings → Email*, and can send every template to
141+ themselves.
142+- **API.** `GET/PUT/DELETE /api/v3/repos/{owner}/{repo}/subscription`,
143+ `GET/PUT /api/v3/diffinery/notification-settings`,
144+ `GET /api/v3/diffinery/emails`, `POST /api/v3/diffinery/emails/test`; for
145+ site admins `GET /api/v3/diffinery/admin/email` and
146+ `POST /api/v3/diffinery/admin/emails/samples`.
147+
148+**Setting it up.** Onboard the sending domain once in the Cloudflare dashboard
149+(*Compute → Email Service → Email Sending → Onboard Domain*); Cloudflare adds
150+the bounce MX, SPF, DKIM and DMARC records. Each deploy runs
151+`scripts/email-sending.sh`, which checks those records (with the *Email
152+Sending: Read* permission on the API token, or the public DNS records without
153+it) and warns if mail can't be delivered. In `wrangler dev`, Miniflare accepts
154+emails without sending them; `GET /api/v3/diffinery/emails` shows the full
155+message there.
156+
109157 ## Agents
110158
111159 Create an agent under *Settings → Agents* or with `diffinery agent create reviewer-bot --org codician`.
addedapps/diffinery/migrations/0004_email.sql+60−0View file
@@ -1,0 +1,60 @@
1+-- Email notifications: per-person settings, watching, thread subscriptions,
2+-- recognised sign-in devices and a log of every email sent.
3+
4+-- One row per person who changed a default. JSON, see src/mail/prefs.ts.
5+CREATE TABLE notification_prefs (
6+ account_id TEXT PRIMARY KEY REFERENCES accounts(id),
7+ prefs TEXT NOT NULL,
8+ updated_at TEXT NOT NULL
9+);
10+
11+-- Watching a repository: 'all' (every issue, pull request and failed run),
12+-- 'participating' (the default: only threads you take part in) or 'ignore'.
13+CREATE TABLE repo_watches (
14+ account_id TEXT NOT NULL REFERENCES accounts(id),
15+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
16+ level TEXT NOT NULL CHECK (level IN ('all', 'participating', 'ignore')),
17+ updated_at TEXT NOT NULL,
18+ PRIMARY KEY (account_id, repo_id)
19+);
20+CREATE INDEX repo_watches_repo ON repo_watches(repo_id, level);
21+
22+-- Subscribe to (1) or mute (0) one issue or pull request.
23+CREATE TABLE thread_subscriptions (
24+ account_id TEXT NOT NULL REFERENCES accounts(id),
25+ repo_id TEXT NOT NULL,
26+ number INTEGER NOT NULL,
27+ subscribed INTEGER NOT NULL,
28+ updated_at TEXT NOT NULL,
29+ PRIMARY KEY (account_id, repo_id, number)
30+);
31+CREATE INDEX thread_subscriptions_thread ON thread_subscriptions(repo_id, number);
32+
33+-- Browsers and passkeys someone has signed in with, so only new ones raise an alert.
34+CREATE TABLE known_devices (
35+ account_id TEXT NOT NULL REFERENCES accounts(id),
36+ fingerprint TEXT NOT NULL,
37+ label TEXT NOT NULL,
38+ first_seen TEXT NOT NULL,
39+ last_seen TEXT NOT NULL,
40+ PRIMARY KEY (account_id, fingerprint)
41+);
42+
43+-- Every email Diffinery tried to send (whole messages are kept only in local development).
44+CREATE TABLE email_log (
45+ id TEXT PRIMARY KEY,
46+ account_id TEXT,
47+ to_addr TEXT NOT NULL,
48+ kind TEXT NOT NULL,
49+ subject TEXT NOT NULL,
50+ status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'skipped')),
51+ detail TEXT,
52+ message_id TEXT,
53+ dev_copy TEXT, -- the whole message, kept only in local development
54+ created_at TEXT NOT NULL
55+);
56+CREATE INDEX email_log_at ON email_log(created_at);
57+CREATE INDEX email_log_account ON email_log(account_id, created_at);
58+
59+-- Reminders sent once per token, a week before it expires.
60+ALTER TABLE tokens ADD COLUMN expiry_notified_at TEXT;
modifiedapps/diffinery/package.json+1−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit && tsc --noEmit -p islands",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
1414 "build": "node islands/build.mjs",
1515 "predev": "node islands/build.mjs"
modifiedapps/diffinery/src/app.ts+18−33View file
@@ -12,10 +12,14 @@
1212 import { git } from "./http/git.js";
1313 import { runner } from "./http/runner.js";
1414 import { api, wellKnown } from "./http/api.js";
15+import { mailApi } from "./mail/api.js";
1516 import { mcp } from "./mcp/server.js";
1617 import { registry } from "./registry/http.js";
1718 import { packagesApi } from "./registry/api.js";
1819 import { RegistryStore } from "./registry/store.js";
20+import { mailCron, notifyEvent } from "./mail/notify.js";
21+import { mailQueue } from "./mail/send.js";
22+import type { MailJob } from "./mail/types.js";
1923
2024 export const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
2125
@@ -63,6 +67,7 @@
6367 app.route("/", wellKnown);
6468 app.route("/", git);
6569 app.route("/", runner);
70+app.route("/", mailApi);
6671 app.route("/", registry);
6772 app.route("/", packagesApi);
6873 app.route("/", api);
@@ -149,34 +154,6 @@
149154 );
150155 }
151156
152-/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */
153-async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
154- if (ev.type === "workflow_run") {
155- if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return;
156- await dir.notify([ev.actor.id], { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}` });
157- return;
158- }
159- if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return;
160- // Queue removals already notify through the merge queue's comment on the pull request.
161- if (ev.type === "pull_request" && (ev.action === "synchronize" || ev.action === "enqueued" || ev.action === "dequeued")) return;
162- const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number);
163- const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]);
164- for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) {
165- const a = await dir.accountByLogin(login);
166- if (a) ids.add(a.id);
167- }
168- ids.delete(ev.actor.id);
169- if (!ids.size) return;
170- const kind = issue.kind === "pull" ? "pull" : "issues";
171- const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action;
172- await dir.notify([...ids].slice(0, 100), {
173- repoId: ev.repoId,
174- kind: ev.type,
175- title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`,
176- url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`,
177- });
178-}
179-
180157 async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
181158 if (ev.type === "audit") {
182159 await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at });
@@ -188,10 +165,16 @@
188165 if (ev.type === "pull_request" && ev.action === "merged") {
189166 await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: "pull.merge", target: `${ev.owner}/${ev.repo}#${ev.number}`, at: ev.at });
190167 }
191- await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]);
168+ await Promise.all([deliverWebhooks(env, ev), notifyEvent(env, dir, ev)]);
192169 }
193170
194-export async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> {
171+export async function queue(batch: MessageBatch<DiffineryEvent | MailJob>, env: Env): Promise<void> {
172+ // Email has its own queue (diffinery-mail): its jobs can hold sign-in and invite links, so they stay out of the event archive.
173+ if (batch.queue.startsWith("diffinery-mail")) return mailQueue(batch as MessageBatch<MailJob>, env);
174+ return events(batch as MessageBatch<DiffineryEvent>, env);
175+}
176+
177+async function events(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> {
195178 const dir = new Directory(env);
196179 // Keep an append-only copy of every event in R2 for long-term audit retention.
197180 const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/");
@@ -219,13 +202,15 @@
219202 end.setUTCSeconds(0, 0);
220203 const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000));
221204 const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>();
222- await Promise.all(
223- results.map(async (s) => {
205+ await Promise.all([
206+ ...results.map(async (s) => {
224207 try {
225208 if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron);
226209 } catch (e) {
227210 console.error("schedule failed", s.repo_id, s.workflow_path, e);
228211 }
229212 }),
230- );
213+ // Token expiry reminders and email log pruning.
214+ mailCron(env, new Directory(env), end).catch((e) => console.error("mail cron failed", e)),
215+ ]);
231216 }
modifiedapps/diffinery/src/auth/sessions.ts+4−1View file
@@ -1,6 +1,7 @@
11 import type { Env } from "../env.js";
22 import type { Account } from "../lib/directory.js";
33 import { newId, now, randomToken, sha256 } from "../lib/util.js";
4+import { enqueueMail } from "../mail/send.js";
45
56 /** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */
67 export const SESSION_COOKIE = "__Host-dfy_session";
@@ -180,7 +181,9 @@
180181 .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
181182 .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt)
182183 .run();
183- return { url: `${this.env.PUBLIC_URL}/signin/${token}`, expiresAt };
184+ const url = `${this.env.PUBLIC_URL}/signin/${token}`;
185+ await enqueueMail(this.env, [{ kind: "signin_link", data: { url, admin: by.login, expiresAt }, to: { accountId: account.id }, reason: "account", at: now() }]);
186+ return { url, expiresAt };
184187 }
185188
186189 async signinLink(token: string): Promise<{ hash: string; account: Account } | null> {
modifiedapps/diffinery/src/env.ts+7−0View file
@@ -1,6 +1,7 @@
11 import type { DiffineryEvent } from "@diffinery/shared";
22 import type { RepoDO } from "./repo/RepoDO.js";
33 import type { RunnerContainer, SandboxContainer } from "./runner/containers.js";
4+import type { MailJob } from "./mail/types.js";
45
56 export interface Env {
67 DB: D1Database;
@@ -10,9 +11,15 @@
1011 RUNNER: DurableObjectNamespace<RunnerContainer>;
1112 SANDBOX: DurableObjectNamespace<SandboxContainer>;
1213 RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>;
14+ /** Cloudflare Email Sending. Missing when sending isn't set up; emails are then logged as skipped. */
15+ EMAIL?: SendEmail;
16+ /** Email jobs, sent by the queue consumer in mail/send.ts. */
17+ MAIL?: Queue<MailJob>;
1318
1419 /** Canonical origin, e.g. https://diffinery.com */
1520 PUBLIC_URL: string;
21+ /** Sender address for every email, e.g. notifications@diffinery.com. */
22+ MAIL_FROM?: string;
1623 /** Comma-separated emails or logins that become site admins on first sign-in. */
1724 BOOTSTRAP_ADMINS: string;
1825 /** e.g. codician.cloudflareaccess.com — empty disables Access verification. */
modifiedapps/diffinery/src/http/api.ts+9−0View file
@@ -10,6 +10,7 @@
1010 import type { RunInfo } from "../repo/actions.js";
1111 import type { QueueEntry } from "../repo/queue.js";
1212 import { loadRepo, repoStub } from "./common.js";
13+import { repoCreated } from "../mail/notify.js";
1314
1415 /**
1516 * REST API. Paths and payloads follow GitHub's REST API v3 where practical so
@@ -50,6 +51,11 @@
5051 }
5152 }
5253
54+/** Called with an access token (scripts, the CLI, agents) rather than from a browser. */
55+const viaToken = (c: AppContext) => {
56+ const p = c.get("principal");
57+ return p.type === "account" && p.via === "token";
58+};
5359 const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! });
5460 const num = (c: AppContext, name = "number") => {
5561 const n = Number(c.req.param(name));
@@ -119,6 +125,7 @@
119125 const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
120126 const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a;
121127 const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
128+ c.executionCtx.waitUntil(repoCreated(c.env, c.get("dir"), r, a, { viaToken: viaToken(c) }));
122129 return c.json(repoJson(c, r), 201);
123130 });
124131 api.get("/users/:login/repos", async (c) => {
@@ -139,6 +146,7 @@
139146 if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
140147 const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
141148 const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
149+ c.executionCtx.waitUntil(repoCreated(c.env, c.get("dir"), r, a, { viaToken: viaToken(c) }));
142150 return c.json(repoJson(c, r), 201);
143151 });
144152 api.get("/repos/:owner/:repo", async (c) => {
@@ -154,6 +162,7 @@
154162 const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
155163 if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories");
156164 await c.get("dir").deleteRepo(repo);
165+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "repo.delete", target: `${repo.owner_login}/${repo.name}` }));
157166 return c.body(null, 204);
158167 });
159168 api.get("/repos/:owner/:repo/collaborators", async (c) => {
modifiedapps/diffinery/src/index.ts+2−1View file
@@ -1,5 +1,6 @@
11 import type { DiffineryEvent } from "@diffinery/shared";
22 import type { Env } from "./env.js";
3+import type { MailJob } from "./mail/types.js";
34 import { app, queue, scheduled } from "./app.js";
45 import { web } from "./web/routes.js";
56
@@ -14,4 +15,4 @@
1415 fetch: app.fetch,
1516 queue,
1617 scheduled,
17-} satisfies ExportedHandler<Env, DiffineryEvent>;
18+} satisfies ExportedHandler<Env, DiffineryEvent | MailJob>;
modifiedapps/diffinery/src/lib/directory.ts+45−1View file
@@ -2,6 +2,8 @@
22 import type { RepoMeta, Role } from "../repo/types.js";
33 import { ROLE_LEVEL } from "../repo/types.js";
44 import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js";
5+import { mailForAudit, tokenCreated } from "../mail/notify.js";
6+import { enqueueMail } from "../mail/send.js";
57
68 export interface Account {
79 id: string;
@@ -134,6 +136,15 @@
134136 }
135137
136138 async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
139+ const previous = await this.orgRole(orgId, userId);
140+ await this.writeOrgMember(orgId, userId, role);
141+ if (previous !== role) {
142+ const org = await this.account(orgId);
143+ if (org) await enqueueMail(this.env, [{ kind: "org_membership", data: { org: org.login, role, previous }, to: { accountId: userId }, reason: "account", at: now() }]);
144+ }
145+ }
146+
147+ private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
137148 if (role === null) {
138149 const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>();
139150 if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner");
@@ -149,8 +160,13 @@
149160 }
150161
151162 async setStatus(id: string, status: Account["status"]): Promise<void> {
163+ const before = await this.account(id);
152164 await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run();
153165 if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run();
166+ // People hear when they are disabled or enabled again (not when an invite activates them).
167+ if (before?.kind === "user" && before.status !== status && (status === "disabled" || before.status === "disabled")) {
168+ await enqueueMail(this.env, [{ kind: "account_status", data: { status: status === "disabled" ? "disabled" : "active" }, to: { accountId: id }, reason: "account", at: now() }]);
169+ }
154170 }
155171
156172 // ---------------------------------------------------------------- agents
@@ -190,6 +206,7 @@
190206 .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
191207 .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now())
192208 .run();
209+ await tokenCreated(this.env, this, account, { name: input.name || "token", scopes: input.scopes?.length ? input.scopes : ["write"] }).catch((e) => console.error("token email failed", e));
193210 return { token, id, expiresAt };
194211 }
195212
@@ -225,7 +242,19 @@
225242 // If the person already exists, activate them now so the invite also works from the dashboard.
226243 const existing = await this.accountByEmail(input.email);
227244 if (existing?.status === "pending") await this.setStatus(existing.id, "active");
228- return { id, url: `${this.env.PUBLIC_URL}/invite/${token}`, token };
245+ const url = `${this.env.PUBLIC_URL}/invite/${token}`;
246+ const org = input.orgId ? await this.account(input.orgId) : null;
247+ const repo = input.repoId ? await this.repoById(input.repoId) : null;
248+ await enqueueMail(this.env, [
249+ {
250+ kind: "invite",
251+ data: { inviter: inviter.login, url, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: input.role, expiresAt: new Date(Date.now() + 14 * 86400_000).toISOString() },
252+ to: existing ? { accountId: existing.id } : { email: input.email },
253+ reason: "invitation",
254+ at: now(),
255+ },
256+ ]);
257+ return { id, url, token };
229258 }
230259
231260 async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> {
@@ -252,10 +281,20 @@
252281 if (inv.org_id) stmts.push(this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO NOTHING").bind(inv.org_id, user.id, inv.role === "admin" || inv.role === "owner" ? inv.role : "member", now()));
253282 if (inv.repo_id) stmts.push(this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(inv.repo_id, user.id, inv.role));
254283 await this.db.batch(stmts);
284+ const org = inv.org_id ? await this.account(inv.org_id) : null;
285+ const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null;
286+ if (inv.invited_by && inv.invited_by !== user.id) {
287+ await enqueueMail(this.env, [
288+ { kind: "invite_accepted", data: { login: user.login, email: user.email ?? inv.email ?? "", org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined }, to: { accountId: inv.invited_by }, reason: "account", at: now() },
289+ ]);
290+ }
255291 }
256292
257293 async requestAccess(accountId: string, note: string): Promise<void> {
258294 await this.db.prepare("INSERT OR REPLACE INTO access_requests (account_id, note, created_at) VALUES (?, ?, ?)").bind(accountId, note.slice(0, 500), now()).run();
295+ const who = await this.account(accountId);
296+ const admins = await this.db.prepare("SELECT id FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' LIMIT 20").all<{ id: string }>();
297+ if (who) await enqueueMail(this.env, admins.results.map((a) => ({ kind: "access_request" as const, data: { email: who.email ?? "", login: who.login, note: note.slice(0, 500) }, to: { accountId: a.id }, reason: "admin" as const, at: now() })));
259298 }
260299
261300 async accessRequests(): Promise<Array<{ account: Account; note: string; createdAt: string }>> {
@@ -348,8 +387,11 @@
348387 }
349388
350389 async setCollaborator(repoId: string, accountId: string, role: Role | null): Promise<void> {
390+ const previous = (await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).first<{ role: Role }>())?.role ?? null;
351391 if (role) await this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(repoId, accountId, role).run();
352392 else await this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).run();
393+ const repo = previous !== role ? await this.repoById(repoId) : null;
394+ if (repo) await enqueueMail(this.env, [{ kind: "repo_access", data: { repo: `${repo.owner_login}/${repo.name}`, role, previous }, to: { accountId }, reason: "account", at: now() }]);
353395 }
354396
355397 /** Effective role of an account on a repository (null = no access). */
@@ -405,6 +447,8 @@
405447 .prepare("INSERT INTO audit_log (id, at, actor_id, actor_login, on_behalf_of, action, target, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
406448 .bind(newId(), entry.at ?? now(), entry.actorId ?? null, entry.actorLogin ?? null, entry.onBehalfOf ?? null, entry.action, entry.target ?? null, entry.detail ? JSON.stringify(entry.detail) : null)
407449 .run();
450+ // Security emails (sign-ins, passkeys, tokens, push protection…) hang off the audit log.
451+ await mailForAudit(this.env, this, entry).catch((e) => console.error("security email failed", entry.action, e));
408452 }
409453
410454 async auditLog(limit = 200): Promise<Array<Record<string, string | null>>> {
addedapps/diffinery/src/mail/api.ts+144−0View file
@@ -1,0 +1,144 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4+import type { Account } from "../lib/directory.js";
5+import { loadRepo } from "../http/common.js";
6+import { HttpError, now } from "../lib/util.js";
7+import { MailStore, mergePrefs, type WatchLevel } from "./prefs.js";
8+import { sampleJobs } from "./samples.js";
9+import { deliver } from "./send.js";
10+
11+/**
12+ * Email over the REST API: repository watching in GitHub's shape
13+ * (`/repos/{owner}/{repo}/subscription`), plus Diffinery's own notification
14+ * settings, email log, test email and, for site admins, delivery status and
15+ * "send every template".
16+ */
17+export const mailApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
18+
19+const user = (c: AppContext): Account => {
20+ const a = requireAccount(c);
21+ if (a.kind !== "user") throw new HttpError(403, "only people have notification settings");
22+ return a;
23+};
24+
25+/** Changing settings needs a browser session or a write (or admin) token. */
26+const writer = (c: AppContext): Account => {
27+ const a = user(c);
28+ const p = c.get("principal");
29+ if (p.type === "account" && p.via === "token" && !p.scopes.some((s) => s === "write" || s === "admin")) throw new HttpError(403, "this needs a write or admin token");
30+ return a;
31+};
32+
33+const siteAdmin = (c: AppContext, scope: "read" | "admin"): Account => {
34+ const a = user(c);
35+ if (!a.site_admin) throw new HttpError(403, "site administrators only");
36+ const p = c.get("principal");
37+ if (scope === "admin" && p.type === "account" && p.via === "token" && !p.scopes.includes("admin")) throw new HttpError(403, "this needs an admin token");
38+ return a;
39+};
40+
41+async function json<T>(c: AppContext): Promise<T> {
42+ try {
43+ return (await c.req.json()) as T;
44+ } catch {
45+ throw new HttpError(400, "request body must be JSON");
46+ }
47+}
48+
49+const subscriptionJson = (c: AppContext, owner: string, name: string, level: WatchLevel) => ({
50+ subscribed: level === "all",
51+ ignored: level === "ignore",
52+ reason: null,
53+ created_at: null,
54+ url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${name}/subscription`,
55+ repository_url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${name}`,
56+});
57+
58+// GitHub: 404 when you have not watched or ignored the repository.
59+mailApi.get("/repos/:owner/:repo/subscription", async (c) => {
60+ const me = user(c);
61+ const l = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
62+ const level = await new MailStore(c.env.DB).watchLevel(me.id, l.repo.id);
63+ if (level === "participating") throw new HttpError(404, "not subscribed");
64+ return c.json(subscriptionJson(c, l.repo.owner_login, l.repo.name, level));
65+});
66+
67+mailApi.put("/repos/:owner/:repo/subscription", async (c) => {
68+ const me = writer(c);
69+ const l = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
70+ const b = await json<{ subscribed?: boolean; ignored?: boolean }>(c);
71+ const level: WatchLevel = b.ignored ? "ignore" : b.subscribed === false ? "participating" : "all";
72+ await new MailStore(c.env.DB).setWatch(me.id, l.repo.id, level);
73+ return c.json(subscriptionJson(c, l.repo.owner_login, l.repo.name, level));
74+});
75+
76+mailApi.delete("/repos/:owner/:repo/subscription", async (c) => {
77+ const me = writer(c);
78+ const l = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
79+ await new MailStore(c.env.DB).setWatch(me.id, l.repo.id, "participating");
80+ return c.body(null, 204);
81+});
82+
83+mailApi.get("/diffinery/notification-settings", async (c) => c.json(await new MailStore(c.env.DB).prefs(user(c).id)));
84+
85+/** Partial update: send only the groups to change. */
86+mailApi.put("/diffinery/notification-settings", async (c) => {
87+ const me = writer(c);
88+ const store = new MailStore(c.env.DB);
89+ const cur = await store.prefs(me.id);
90+ const b = await json<Record<string, unknown>>(c);
91+ const merged: Record<string, unknown> = { ...cur };
92+ for (const [k, v] of Object.entries(b)) {
93+ const base = (cur as unknown as Record<string, unknown>)[k];
94+ if (base === undefined) throw new HttpError(422, `unknown setting ${k}`);
95+ merged[k] = base && typeof base === "object" && v && typeof v === "object" ? { ...base, ...v } : v;
96+ }
97+ const next = mergePrefs(merged);
98+ await store.setPrefs(me.id, next);
99+ return c.json(next);
100+});
101+
102+/** Your recent emails; site admins can pass ?all=1 for everyone's. */
103+mailApi.get("/diffinery/emails", async (c) => {
104+ const me = user(c);
105+ const all = c.req.query("all") === "1";
106+ if (all && !me.site_admin) throw new HttpError(403, "site administrators only");
107+ const rows = await new MailStore(c.env.DB).recent({
108+ accountId: all ? undefined : me.id,
109+ kind: c.req.query("kind") || undefined,
110+ since: c.req.query("since") || undefined,
111+ limit: Number(c.req.query("per_page") ?? 50) || 50,
112+ });
113+ // The full message is kept only in local development, for tests.
114+ return c.json(rows.map((r) => ({ ...r, dev_copy: c.env.DEV_AUTH && r.dev_copy ? JSON.parse(r.dev_copy) : undefined })));
115+});
116+
117+/** Send yourself a test email right away; returns what Cloudflare said. */
118+mailApi.post("/diffinery/emails/test", async (c) => {
119+ const me = writer(c);
120+ return c.json(await deliver(c.env, { kind: "test", data: { by: me.login }, to: { accountId: me.id }, reason: "account", at: now() }));
121+});
122+
123+mailApi.get("/diffinery/admin/email", async (c) => {
124+ siteAdmin(c, "read");
125+ const store = new MailStore(c.env.DB);
126+ const [day, last] = await Promise.all([store.counts(new Date(Date.now() - 86400_000).toISOString()), store.recent({ limit: 1 })]);
127+ return c.json({ sending: !!c.env.EMAIL, queue: !!c.env.MAIL, from: c.env.MAIL_FROM || null, last_24h: day, last: last[0] ? { ...last[0], dev_copy: undefined } : null });
128+});
129+
130+/**
131+ * Send every template (or `only` some) to yourself now, ignoring your
132+ * notification settings, and return each result with Cloudflare's message id.
133+ */
134+mailApi.post("/diffinery/admin/emails/samples", async (c) => {
135+ const me = siteAdmin(c, "admin");
136+ const b = await json<{ only?: string[] }>(c).catch(() => ({}) as { only?: string[] });
137+ const samples = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).filter((s) => !b.only?.length || b.only.includes(s.id));
138+ const results = [];
139+ for (const s of samples) {
140+ const r = await deliver(c.env, { ...s.job, force: true }).catch((e: Error) => ({ status: "failed" as const, detail: e.message, messageId: undefined }));
141+ results.push({ id: s.id, label: s.label, ...r });
142+ }
143+ return c.json({ to: me.email, sent: results.filter((r) => r.status === "sent").length, total: results.length, results });
144+});
addedapps/diffinery/src/mail/notify.ts+369−0View file
@@ -1,0 +1,369 @@
1+/**
2+ * Who hears about what. GitHub's rules, applied to Diffinery's events:
3+ *
4+ * - Participating: you opened the thread, commented or reviewed, are assigned
5+ * or asked to review, were @mentioned, or subscribed by hand.
6+ * - Watching: "All activity" on a repository adds every new issue, pull
7+ * request, comment, review and state change there.
8+ * - Ignoring a repository or muting a thread stops them (a direct @mention,
9+ * assignment or review request in the same event still gets through a mute).
10+ * - Your own actions never notify you, unless you turn on "Include your own updates" for email.
11+ * - Workflow runs notify whoever triggered them (an agent's owner for agents;
12+ * watchers for scheduled runs), for failures unless they ask for every run.
13+ * - Security emails (sign-ins, passkeys, tokens, agents, push protection) can't be turned off.
14+ *
15+ * Every recipient must still be able to read the repository.
16+ */
17+import type { DiffineryEvent } from "@diffinery/shared";
18+import type { Env } from "../env.js";
19+import { repoStub } from "../http/common.js";
20+import type { Account, Directory, Repo } from "../lib/directory.js";
21+import { sha256 } from "../lib/util.js";
22+import { MailStore, type NotificationPrefs } from "./prefs.js";
23+import { enqueueMail } from "./send.js";
24+import type { JobInfo, RunInfo } from "../repo/actions.js";
25+import type { MailJob, Reason, ThreadData } from "./types.js";
26+
27+type ThreadEvent = Extract<DiffineryEvent, { type: "pull_request" | "issues" | "issue_comment" | "pull_request_review" }>;
28+
29+/** Logins @mentioned in Markdown, ignoring code. */
30+export function mentions(md: string | undefined | null): string[] {
31+ if (!md) return [];
32+ const text = md.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
33+ const out = new Set<string>();
34+ for (const m of text.matchAll(/(?:^|[^\w`/@.-])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))(?![\w@-]|\.\w)/g)) {
35+ out.add(m[1]!.toLowerCase());
36+ if (out.size >= 50) break;
37+ }
38+ return [...out];
39+}
40+
41+/** Reasons in priority order: the first that applies is the one the email gives. */
42+const PRIORITY: Reason[] = ["review_requested", "assign", "mention", "author", "comment", "manual", "state_change", "subscribed"];
43+const DIRECT: Reason[] = ["review_requested", "assign", "mention"];
44+
45+class Recipients {
46+ readonly map = new Map<string, Reason>();
47+ add(id: string | undefined | null, reason: Reason): void {
48+ if (!id) return;
49+ const cur = this.map.get(id);
50+ if (!cur || PRIORITY.indexOf(reason) < PRIORITY.indexOf(cur)) this.map.set(id, reason);
51+ }
52+}
53+
54+async function accountsByLogin(dir: Directory, logins: string[]): Promise<Account[]> {
55+ const out: Account[] = [];
56+ for (const l of [...new Set(logins.map((x) => x.toLowerCase()))].slice(0, 60)) {
57+ const a = await dir.accountByLogin(l);
58+ if (a) out.push(a);
59+ }
60+ return out;
61+}
62+
63+/** Recipients who may still read the repository, with their accounts. */
64+async function readable(dir: Directory, repo: Repo, ids: string[]): Promise<Account[]> {
65+ const out: Account[] = [];
66+ for (const id of ids.slice(0, 200)) {
67+ const a = await dir.account(id);
68+ if (!a || a.kind === "org" || a.status !== "active") continue;
69+ if (await dir.roleFor(a, repo)) out.push(a);
70+ }
71+ return out;
72+}
73+
74+async function threadEvent(env: Env, dir: Directory, ev: ThreadEvent): Promise<void> {
75+ if (ev.type === "pull_request" && (ev.action === "enqueued" || ev.action === "dequeued" || ev.action === "edited")) return;
76+ if (ev.type === "issues" && ev.action === "edited") return;
77+ const repo = await dir.repoById(ev.repoId);
78+ if (!repo) return;
79+ const stub = repoStub(env, ev.repoId);
80+ const { issue, comments, reviews } = await stub.issue(ev.number);
81+ const isPull = issue.kind === "pull";
82+ const threadUrl = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${isPull ? "pull" : "issues"}/${ev.number}`;
83+ const d: ThreadData = { repo: `${ev.owner}/${ev.repo}`, repoId: ev.repoId, number: ev.number, kind: issue.kind, title: issue.title, event: "opened", actor: ev.actor.login, url: threadUrl, base: issue.pull?.base, head: issue.pull?.head, intent: issue.pull?.intent ?? null, labels: issue.labels };
84+ const r = new Recipients();
85+ let text: string | undefined;
86+ let participantsOnly = false;
87+
88+ if ((ev.type === "issues" || ev.type === "pull_request") && ev.action === "opened") {
89+ d.event = "opened";
90+ d.body = issue.body;
91+ d.assignees = issue.assignees;
92+ d.reviewers = issue.pull?.requestedReviewers;
93+ text = issue.body;
94+ for (const a of await accountsByLogin(dir, issue.assignees)) r.add(a.id, "assign");
95+ for (const a of await accountsByLogin(dir, issue.pull?.requestedReviewers ?? [])) r.add(a.id, "review_requested");
96+ if (isPull) d.commits = (await stub.pullCommits(ev.number).catch(() => [])).slice(-10).map((c) => ({ sha: c.sha, message: c.message }));
97+ } else if (ev.type === "issue_comment") {
98+ const cm = comments.find((c) => c.id === ev.commentId);
99+ if (!cm) return;
100+ d.event = "comment";
101+ d.body = cm.body;
102+ d.url = `${threadUrl}#c-${cm.id}`;
103+ text = cm.body;
104+ } else if (ev.type === "pull_request_review") {
105+ const rv = [...reviews].reverse().find((x) => x.author.id === ev.actor.id);
106+ d.event = "review";
107+ d.state = ev.state;
108+ const lineComments = rv ? comments.filter((c) => c.reviewId === rv.id) : [];
109+ d.body = [rv?.body ?? "", ...lineComments.slice(0, 20).map((c) => `**${c.path}${c.line ? `:${c.line}` : ""}**\n\n${c.body}`)].filter((x) => x.trim()).join("\n\n---\n\n");
110+ text = d.body;
111+ } else if (ev.action === "closed" || ev.action === "merged" || ev.action === "reopened") {
112+ if (ev.type === "pull_request" && ev.action === "closed" && issue.pull?.merged) return; // the "merged" event covers it
113+ d.event = ev.action;
114+ d.stateReason = issue.stateReason;
115+ } else if (ev.type === "pull_request" && ev.action === "synchronize") {
116+ d.event = "pushed";
117+ d.commits = (await stub.pullCommits(ev.number).catch(() => [])).slice(-10).map((c) => ({ sha: c.sha, message: c.message }));
118+ participantsOnly = true;
119+ } else if (ev.action === "assigned") {
120+ d.event = "assigned";
121+ d.assignees = ev.assignees ?? [];
122+ for (const a of await accountsByLogin(dir, d.assignees)) r.add(a.id, "assign");
123+ participantsOnly = true;
124+ } else if (ev.type === "pull_request" && ev.action === "review_requested") {
125+ d.event = "review_requested";
126+ d.reviewers = ev.reviewers ?? [];
127+ for (const a of await accountsByLogin(dir, d.reviewers)) r.add(a.id, "review_requested");
128+ participantsOnly = true;
129+ } else return;
130+
131+ for (const a of await accountsByLogin(dir, mentions(text))) r.add(a.id, "mention");
132+ r.add(issue.author.id, "author");
133+ for (const c of comments) r.add(c.author.id, "comment");
134+ for (const rv of reviews) r.add(rv.author.id, "comment");
135+ // Assignees and requested reviewers take part in the thread from then on.
136+ if (d.event !== "assigned") for (const a of await accountsByLogin(dir, issue.assignees)) r.add(a.id, "assign");
137+ if (d.event !== "review_requested") for (const a of await accountsByLogin(dir, issue.pull?.requestedReviewers ?? [])) r.add(a.id, "review_requested");
138+
139+ const store = new MailStore(env.DB);
140+ const [subs, watch] = await Promise.all([store.threadSubs(ev.repoId, ev.number), store.watchers(ev.repoId)]);
141+ for (const [id, on] of subs) if (on) r.add(id, "manual");
142+ // Assignments, review requests and pushes go to the people involved, not to every watcher.
143+ if (!participantsOnly) for (const id of watch.all) r.add(id, "subscribed");
144+ const direct = new Set([...(ev.type === "pull_request" && ev.action === "review_requested" ? d.reviewers ?? [] : []), ...(ev.action === "assigned" ? d.assignees ?? [] : []), ...mentions(text)].map((x) => x.toLowerCase()));
145+
146+ const candidates = [...r.map.keys()].filter((id) => !watch.ignore.has(id));
147+ const accounts = await readable(dir, repo, candidates);
148+ const prefs = await store.prefsFor(accounts.map((a) => a.id));
149+ const web = new Map<Reason, string[]>();
150+ const jobs: MailJob[] = [];
151+ for (const a of accounts) {
152+ let reason = r.map.get(a.id)!;
153+ if (subs.get(a.id) === false && !(DIRECT.includes(reason) && direct.has(a.login.toLowerCase()))) continue;
154+ const p = prefs.get(a.id)!;
155+ const own = a.id === ev.actor.id;
156+ if (own) reason = "your_activity";
157+ const group: keyof NotificationPrefs = reason === "subscribed" ? "watching" : "participating";
158+ if (!own && p[group].web) web.set(reason, [...(web.get(reason) ?? []), a.id]);
159+ if (a.kind === "user" && (!own || p.ownActivity)) jobs.push({ kind: "thread", data: d, to: { accountId: a.id }, reason, at: ev.at });
160+ }
161+ const verb: Record<ThreadData["event"], string> = { opened: "opened", comment: "commented on", review: "reviewed", closed: "closed", merged: "merged", reopened: "reopened", assigned: "assigned", review_requested: "requested review on", pushed: "pushed to" };
162+ const title = `${ev.actor.login} ${d.event === "review" ? `reviewed (${(ev as { state?: string }).state?.toLowerCase().replace(/_/g, " ")})` : verb[d.event]} ${d.repo}#${ev.number}: ${issue.title}`;
163+ const webIds = [...web.values()].flat();
164+ await Promise.all([webIds.length ? dir.notify(webIds.slice(0, 200), { repoId: ev.repoId, kind: ev.type, title, url: d.url }) : null, enqueueMail(env, jobs)]);
165+}
166+
167+async function workflowRun(env: Env, dir: Directory, ev: Extract<DiffineryEvent, { type: "workflow_run" }>): Promise<void> {
168+ if (ev.action !== "completed") return;
169+ const failed = ev.conclusion === "failure" || ev.conclusion === "timed_out";
170+ if (!failed && ev.conclusion !== "success") return;
171+ const repo = await dir.repoById(ev.repoId);
172+ if (!repo) return;
173+ const store = new MailStore(env.DB);
174+ let ids: string[] = [];
175+ if (ev.actor.kind === "user") ids = [ev.actor.id];
176+ else if (ev.actor.kind === "agent" && ev.actor.onBehalfOf) {
177+ const owner = await dir.accountByLogin(ev.actor.onBehalfOf);
178+ if (owner?.kind === "user") ids = [owner.id];
179+ } else {
180+ const w = await store.watchers(ev.repoId);
181+ ids = w.all;
182+ }
183+ const accounts = await readable(dir, repo, ids);
184+ if (!accounts.length) return;
185+ const prefs = await store.prefsFor(accounts.map((a) => a.id));
186+ const url = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}`;
187+ const webIds = failed ? accounts.filter((a) => prefs.get(a.id)!.actions.web).map((a) => a.id) : [];
188+ const mailTo = accounts.filter((a) => a.kind === "user" && (failed || !prefs.get(a.id)!.actions.failuresOnly));
189+ let jobs: MailJob[] = [];
190+ if (mailTo.length) {
191+ // The RPC return type is too deep for TypeScript to instantiate; it is exactly getRun()'s.
192+ const run = (await (repoStub(env, ev.repoId).run as unknown as (id: string) => Promise<unknown>)(ev.runId)) as RunInfo & { jobs: JobInfo[] };
193+ const data = {
194+ repo: `${ev.owner}/${ev.repo}`,
195+ repoId: ev.repoId,
196+ runId: ev.runId,
197+ runNumber: run.number,
198+ workflow: run.workflowName,
199+ title: run.title,
200+ conclusion: ev.conclusion ?? "",
201+ event: run.event,
202+ branch: run.headBranch || run.ref.replace(/^refs\/(heads|tags)\//, ""),
203+ sha: run.headSha || run.sha,
204+ actor: run.actor.login,
205+ url,
206+ jobs: run.jobs.map((j) => ({ name: j.name, conclusion: j.conclusion, url: `${url}/job/${j.id}`, failure: j.failure?.message ?? null })),
207+ };
208+ jobs = mailTo.map((a) => ({ kind: "run" as const, data, to: { accountId: a.id }, reason: "ci_activity" as const, at: ev.at }));
209+ }
210+ await Promise.all([webIds.length ? dir.notify(webIds, { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url }) : null, enqueueMail(env, jobs)]);
211+}
212+
213+/** Web notifications and emails for one event from the events queue. */
214+export async function notifyEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
215+ if (ev.type === "workflow_run") return workflowRun(env, dir, ev);
216+ if (ev.type === "pull_request" || ev.type === "issues" || ev.type === "issue_comment" || ev.type === "pull_request_review") return threadEvent(env, dir, ev);
217+}
218+
219+// ---------------------------------------------------------------- account and security emails
220+
221+/** People who administer a repository: its owner (or the org's owners and admins) and admin collaborators. */
222+export async function repoAdmins(dir: Directory, repo: Repo): Promise<string[]> {
223+ const ids = new Set<string>();
224+ if (repo.owner_kind === "org") for (const m of await dir.orgMembers(repo.owner_id)) if (m.role === "owner" || m.role === "admin") ids.add(m.id);
225+ if (repo.owner_kind === "user") ids.add(repo.owner_id);
226+ for (const c of await dir.collaborators(repo.id)) if (c.role === "admin" && c.account.kind === "user") ids.add(c.account.id);
227+ return [...ids];
228+}
229+
230+/** The people an account's security emails go to: the person, or an agent's owner (or its org's owners). */
231+export async function humansFor(dir: Directory, accountId: string | undefined | null): Promise<string[]> {
232+ if (!accountId) return [];
233+ const a = await dir.account(accountId);
234+ if (!a) return [];
235+ if (a.kind === "user") return [a.id];
236+ if (a.kind === "agent" && a.owner_id) {
237+ const o = await dir.account(a.owner_id);
238+ if (o?.kind === "user") return [o.id];
239+ if (o?.kind === "org") return (await dir.orgMembers(o.id)).filter((m) => m.role === "owner" || m.role === "admin").map((m) => m.id);
240+ }
241+ return [];
242+}
243+
244+export interface AuditEntry {
245+ actorId?: string;
246+ actorLogin?: string;
247+ action: string;
248+ target?: string;
249+ detail?: unknown;
250+ at?: string;
251+}
252+
253+/** A sign-in device: the passkey (when there is one) plus the browser and OS. */
254+export async function deviceFingerprint(device: string, passkeyId?: string | null): Promise<string> {
255+ return (await sha256(`${passkeyId ?? "-"}|${device}`)).slice(0, 32);
256+}
257+
258+/** Security emails for audited actions. Called for every audit entry; ignores the rest. */
259+export async function mailForAudit(env: Env, dir: Directory, e: AuditEntry): Promise<void> {
260+ const d = (e.detail && typeof e.detail === "object" ? e.detail : {}) as Record<string, unknown>;
261+ const s = (k: string) => (typeof d[k] === "string" ? (d[k] as string) : undefined);
262+ const at = e.at ?? new Date().toISOString();
263+ const to = (ids: string[], body: Omit<MailJob, "to" | "at" | "reason">, reason: Reason = "security_alert") => ids.map((id) => ({ ...body, to: { accountId: id }, reason, at }) as MailJob);
264+ const store = new MailStore(env.DB);
265+ let jobs: MailJob[] = [];
266+ switch (e.action) {
267+ case "session.create": {
268+ if (!e.actorId) return;
269+ const device = s("device") ?? "an unknown browser";
270+ const method = (s("method") ?? "passkey") as "passkey" | "access" | "link";
271+ const fresh = await store.seeDevice(e.actorId, await deviceFingerprint(device, s("passkeyId")), device);
272+ if (!fresh && method === "passkey") return;
273+ jobs = to([e.actorId], { kind: "signin_new", data: { method, device, ip: s("ip"), location: s("location"), passkey: s("passkey") } });
274+ break;
275+ }
276+ case "passkey.add":
277+ if (!e.actorId) return;
278+ if (s("device")) await store.seeDevice(e.actorId, await deviceFingerprint(s("device")!, s("passkeyId")), s("device")!);
279+ jobs = to([e.actorId], { kind: "passkey_added", data: { name: s("name") ?? "passkey", via: s("via") === "signin-link" ? "signin-link" : "settings" } });
280+ break;
281+ case "passkey.remove":
282+ if (!e.actorId) return;
283+ jobs = to([e.actorId], { kind: "passkey_removed", data: { name: s("name") ?? "passkey" } });
284+ break;
285+ case "agent.create":
286+ if (!e.actorId || !e.target) return;
287+ jobs = to([e.actorId], { kind: "agent_created", data: { agent: e.target } });
288+ break;
289+ case "push.secret_bypassed":
290+ case "push.blocked_secret": {
291+ const [owner, name] = (e.target ?? "").split(":")[0]!.split("/");
292+ if (!owner || !name) return;
293+ const repo = await dir.repo(owner, name);
294+ if (!repo) return;
295+ const kinds = Array.isArray(d.kinds) ? [...new Set(d.kinds as string[])] : [];
296+ const refs = Array.isArray(d.refs) ? (d.refs as string[]) : undefined;
297+ const pusher = await humansFor(dir, e.actorId);
298+ if (e.action === "push.blocked_secret") jobs = to(pusher, { kind: "push_blocked", data: { repo: `${owner}/${name}`, kinds, refs } });
299+ else jobs = to([...new Set([...pusher, ...(await repoAdmins(dir, repo))])], { kind: "push_bypassed", data: { repo: `${owner}/${name}`, pusher: e.actorLogin ?? "someone", kinds, reason: s("reason") ?? "", refs } });
300+ break;
301+ }
302+ case "invite.accept":
303+ // A new account made from an invite (passkey sign-up) gets a welcome email.
304+ if (e.actorId && s("method") === "passkey") {
305+ if (s("device")) await store.seeDevice(e.actorId, await deviceFingerprint(s("device")!, s("passkeyId")), s("device")!);
306+ jobs = to([e.actorId], { kind: "welcome", data: { login: e.actorLogin ?? "" } }, "account");
307+ }
308+ break;
309+ case "repo.delete": {
310+ const [owner, name] = (e.target ?? "").split("/");
311+ const ownerAcc = owner ? await dir.accountByLogin(owner) : null;
312+ if (!ownerAcc || !name) return;
313+ // The person who deleted it already knows.
314+ const ids = (ownerAcc.kind === "org" ? (await dir.orgMembers(ownerAcc.id)).filter((m) => m.role === "owner" || m.role === "admin").map((m) => m.id) : [ownerAcc.id]).filter((id) => id !== e.actorId);
315+ jobs = to(ids, { kind: "repo_deleted", data: { repo: `${owner}/${name}`, actor: e.actorLogin ?? "someone" } }, "admin");
316+ break;
317+ }
318+ default:
319+ return;
320+ }
321+ await enqueueMail(env, jobs);
322+}
323+
324+/** A token was created for a person or an agent (from the web, the API or the CLI). */
325+export async function tokenCreated(env: Env, dir: Directory, account: Account, t: { name: string; scopes: string[] }): Promise<void> {
326+ const at = new Date().toISOString();
327+ const ids = await humansFor(dir, account.id);
328+ await enqueueMail(env, ids.map((id) => ({ kind: "token_created" as const, data: { name: t.name, scopes: t.scopes, agent: account.kind === "agent" ? account.login : undefined }, to: { accountId: id }, reason: "security_alert" as const, at })));
329+}
330+
331+/** A repository was created: tell its creator (unless a script did it with a token) and the org's other owners and admins; the creator watches it. */
332+export async function repoCreated(env: Env, dir: Directory, repo: Repo, creator: Account, opts: { viaToken: boolean }): Promise<void> {
333+ const store = new MailStore(env.DB);
334+ const human = creator.kind === "user";
335+ if (human && (await store.prefs(creator.id)).autoWatch) await store.setWatch(creator.id, repo.id, "all");
336+ const data = { repo: `${repo.owner_login}/${repo.name}`, creator: creator.login, visibility: repo.visibility, defaultBranch: repo.default_branch, description: repo.description };
337+ const at = new Date().toISOString();
338+ const jobs: MailJob[] = [];
339+ if (human && !opts.viaToken) jobs.push({ kind: "repo_created", data, to: { accountId: creator.id }, reason: "your_activity", at });
340+ if (repo.owner_kind === "org") {
341+ for (const m of await dir.orgMembers(repo.owner_id)) {
342+ if ((m.role === "owner" || m.role === "admin") && m.id !== creator.id && m.id !== creator.owner_id) jobs.push({ kind: "repo_created", data, to: { accountId: m.id }, reason: "admin", at });
343+ }
344+ }
345+ await enqueueMail(env, jobs);
346+}
347+
348+/** Daily-ish jobs from the cron trigger: token expiry reminders and log pruning. */
349+export async function mailCron(env: Env, dir: Directory, at: Date): Promise<void> {
350+ const store = new MailStore(env.DB);
351+ const now = at.toISOString();
352+ const soon = new Date(at.getTime() + 7 * 86400_000).toISOString();
353+ const { results } = await env.DB.prepare(
354+ "SELECT id, account_id, name, prefix, expires_at FROM tokens WHERE revoked_at IS NULL AND expiry_notified_at IS NULL AND expires_at > ? AND expires_at <= ? AND julianday(expires_at) - julianday(created_at) > 8 LIMIT 50",
355+ )
356+ .bind(now, soon)
357+ .all<{ id: string; account_id: string; name: string; prefix: string; expires_at: string }>();
358+ const jobs: MailJob[] = [];
359+ for (const t of results) {
360+ const a = await dir.account(t.account_id);
361+ if (a && a.status === "active") {
362+ const ids = await humansFor(dir, a.id);
363+ for (const id of ids) jobs.push({ kind: "token_expiring", data: { name: t.name, prefix: t.prefix, expiresAt: t.expires_at, agent: a.kind === "agent" ? a.login : undefined }, to: { accountId: id }, reason: "security_alert", at: now });
364+ }
365+ await env.DB.prepare("UPDATE tokens SET expiry_notified_at = ? WHERE id = ?").bind(now, t.id).run();
366+ }
367+ await enqueueMail(env, jobs);
368+ if (at.getUTCHours() === 3 && at.getUTCMinutes() < 5) await store.prune();
369+}
addedapps/diffinery/src/mail/prefs.ts+212−0View file
@@ -1,0 +1,212 @@
1+import { newId, now } from "../lib/util.js";
2+import type { Category } from "./types.js";
3+
4+/** Per-person notification settings. Missing keys fall back to DEFAULT_PREFS. */
5+export interface NotificationPrefs {
6+ /** Issues and pull requests you take part in: authored, commented, reviewed, assigned, review requested, @mentioned or subscribed. */
7+ participating: { web: boolean; email: boolean };
8+ /** Everything else in repositories you watch. */
9+ watching: { web: boolean; email: boolean };
10+ /** Workflow runs you started (or the repositories you watch, for scheduled runs). */
11+ actions: { web: boolean; email: boolean; failuresOnly: boolean };
12+ /** Repositories created, deleted, or access granted to you. */
13+ repos: { email: boolean };
14+ /** Organization membership, accepted invitations and (for admins) access requests. */
15+ orgs: { email: boolean };
16+ /** Also email me about my own comments and changes (GitHub's "Include your own updates"). */
17+ ownActivity: boolean;
18+ /** Watch repositories I create. */
19+ autoWatch: boolean;
20+}
21+
22+export const DEFAULT_PREFS: NotificationPrefs = {
23+ participating: { web: true, email: true },
24+ watching: { web: true, email: true },
25+ actions: { web: true, email: true, failuresOnly: true },
26+ repos: { email: true },
27+ orgs: { email: true },
28+ ownActivity: false,
29+ autoWatch: true,
30+};
31+
32+export function mergePrefs(stored: unknown): NotificationPrefs {
33+ const s = (stored && typeof stored === "object" ? stored : {}) as Record<string, unknown>;
34+ const pick = <T extends object>(d: T, v: unknown): T => {
35+ const o = (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
36+ const out = { ...d } as Record<string, unknown>;
37+ for (const k of Object.keys(d)) if (typeof o[k] === "boolean") out[k] = o[k];
38+ return out as T;
39+ };
40+ return {
41+ participating: pick(DEFAULT_PREFS.participating, s.participating),
42+ watching: pick(DEFAULT_PREFS.watching, s.watching),
43+ actions: pick(DEFAULT_PREFS.actions, s.actions),
44+ repos: pick(DEFAULT_PREFS.repos, s.repos),
45+ orgs: pick(DEFAULT_PREFS.orgs, s.orgs),
46+ ownActivity: typeof s.ownActivity === "boolean" ? s.ownActivity : DEFAULT_PREFS.ownActivity,
47+ autoWatch: typeof s.autoWatch === "boolean" ? s.autoWatch : DEFAULT_PREFS.autoWatch,
48+ };
49+}
50+
51+/** Whether a settings group wants email (security and account emails always send). */
52+export function wantsEmail(p: NotificationPrefs, c: Category): boolean {
53+ switch (c) {
54+ case "security":
55+ case "account":
56+ return true;
57+ case "participating":
58+ return p.participating.email;
59+ case "watching":
60+ return p.watching.email;
61+ case "actions":
62+ return p.actions.email;
63+ case "repos":
64+ return p.repos.email;
65+ case "orgs":
66+ return p.orgs.email;
67+ }
68+}
69+
70+export type WatchLevel = "all" | "participating" | "ignore";
71+export const WATCH_LEVELS: WatchLevel[] = ["participating", "all", "ignore"];
72+
73+export interface EmailLogRow {
74+ id: string;
75+ account_id: string | null;
76+ to_addr: string;
77+ kind: string;
78+ subject: string;
79+ status: "sent" | "failed" | "skipped";
80+ detail: string | null;
81+ message_id: string | null;
82+ dev_copy: string | null;
83+ created_at: string;
84+}
85+
86+/** D1 access for notification settings, watching, thread subscriptions, devices and the email log. */
87+export class MailStore {
88+ constructor(private db: D1Database) {}
89+
90+ async prefs(accountId: string): Promise<NotificationPrefs> {
91+ const r = await this.db.prepare("SELECT prefs FROM notification_prefs WHERE account_id = ?").bind(accountId).first<{ prefs: string }>();
92+ let stored: unknown = null;
93+ try {
94+ stored = r ? JSON.parse(r.prefs) : null;
95+ } catch {
96+ stored = null;
97+ }
98+ return mergePrefs(stored);
99+ }
100+
101+ async prefsFor(accountIds: string[]): Promise<Map<string, NotificationPrefs>> {
102+ const out = new Map<string, NotificationPrefs>(accountIds.map((id) => [id, mergePrefs(null)]));
103+ for (let i = 0; i < accountIds.length; i += 90) {
104+ const ids = accountIds.slice(i, i + 90);
105+ const r = await this.db.prepare(`SELECT account_id, prefs FROM notification_prefs WHERE account_id IN (${ids.map(() => "?").join(",")})`).bind(...ids).all<{ account_id: string; prefs: string }>();
106+ for (const row of r.results) {
107+ try {
108+ out.set(row.account_id, mergePrefs(JSON.parse(row.prefs)));
109+ } catch {
110+ /* keep defaults */
111+ }
112+ }
113+ }
114+ return out;
115+ }
116+
117+ async setPrefs(accountId: string, prefs: NotificationPrefs): Promise<void> {
118+ await this.db
119+ .prepare("INSERT INTO notification_prefs (account_id, prefs, updated_at) VALUES (?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET prefs = excluded.prefs, updated_at = excluded.updated_at")
120+ .bind(accountId, JSON.stringify(mergePrefs(prefs)), now())
121+ .run();
122+ }
123+
124+ async watchLevel(accountId: string, repoId: string): Promise<WatchLevel> {
125+ const r = await this.db.prepare("SELECT level FROM repo_watches WHERE account_id = ? AND repo_id = ?").bind(accountId, repoId).first<{ level: WatchLevel }>();
126+ return r?.level ?? "participating";
127+ }
128+
129+ async setWatch(accountId: string, repoId: string, level: WatchLevel): Promise<void> {
130+ if (level === "participating") {
131+ await this.db.prepare("DELETE FROM repo_watches WHERE account_id = ? AND repo_id = ?").bind(accountId, repoId).run();
132+ return;
133+ }
134+ await this.db
135+ .prepare("INSERT INTO repo_watches (account_id, repo_id, level, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, repo_id) DO UPDATE SET level = excluded.level, updated_at = excluded.updated_at")
136+ .bind(accountId, repoId, level, now())
137+ .run();
138+ }
139+
140+ /** Accounts watching a repository, by level ('all' or 'ignore'). */
141+ async watchers(repoId: string): Promise<{ all: string[]; ignore: Set<string> }> {
142+ const r = await this.db.prepare("SELECT account_id, level FROM repo_watches WHERE repo_id = ? LIMIT 2000").bind(repoId).all<{ account_id: string; level: WatchLevel }>();
143+ return { all: r.results.filter((x) => x.level === "all").map((x) => x.account_id), ignore: new Set(r.results.filter((x) => x.level === "ignore").map((x) => x.account_id)) };
144+ }
145+
146+ async watchedRepos(accountId: string): Promise<Array<{ repo_id: string; level: WatchLevel }>> {
147+ return (await this.db.prepare("SELECT repo_id, level FROM repo_watches WHERE account_id = ? ORDER BY updated_at DESC LIMIT 500").bind(accountId).all<{ repo_id: string; level: WatchLevel }>()).results;
148+ }
149+
150+ /** Explicit thread subscriptions: account id → subscribed (true) or muted (false). */
151+ async threadSubs(repoId: string, number: number): Promise<Map<string, boolean>> {
152+ const r = await this.db.prepare("SELECT account_id, subscribed FROM thread_subscriptions WHERE repo_id = ? AND number = ?").bind(repoId, number).all<{ account_id: string; subscribed: number }>();
153+ return new Map(r.results.map((x) => [x.account_id, !!x.subscribed]));
154+ }
155+
156+ async threadSub(accountId: string, repoId: string, number: number): Promise<boolean | null> {
157+ const r = await this.db.prepare("SELECT subscribed FROM thread_subscriptions WHERE account_id = ? AND repo_id = ? AND number = ?").bind(accountId, repoId, number).first<{ subscribed: number }>();
158+ return r ? !!r.subscribed : null;
159+ }
160+
161+ async setThreadSub(accountId: string, repoId: string, number: number, subscribed: boolean): Promise<void> {
162+ await this.db
163+ .prepare("INSERT INTO thread_subscriptions (account_id, repo_id, number, subscribed, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(account_id, repo_id, number) DO UPDATE SET subscribed = excluded.subscribed, updated_at = excluded.updated_at")
164+ .bind(accountId, repoId, number, subscribed ? 1 : 0, now())
165+ .run();
166+ }
167+
168+ /** Forget an explicit choice: the thread notifies as participation and watching say. */
169+ async clearThreadSub(accountId: string, repoId: string, number: number): Promise<void> {
170+ await this.db.prepare("DELETE FROM thread_subscriptions WHERE account_id = ? AND repo_id = ? AND number = ?").bind(accountId, repoId, number).run();
171+ }
172+
173+ /** Counts by status since a time (site administration). */
174+ async counts(since: string): Promise<Record<"sent" | "failed" | "skipped", number>> {
175+ const r = await this.db.prepare("SELECT status, COUNT(*) AS n FROM email_log WHERE created_at >= ? GROUP BY status").bind(since).all<{ status: string; n: number }>();
176+ const out = { sent: 0, failed: 0, skipped: 0 };
177+ for (const x of r.results) if (x.status in out) out[x.status as keyof typeof out] = x.n;
178+ return out;
179+ }
180+
181+ /** Record a sign-in device; true when it was not seen before. */
182+ async seeDevice(accountId: string, fingerprint: string, label: string): Promise<boolean> {
183+ const t = now();
184+ const r = await this.db
185+ .prepare("INSERT INTO known_devices (account_id, fingerprint, label, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) ON CONFLICT(account_id, fingerprint) DO UPDATE SET last_seen = excluded.last_seen RETURNING first_seen")
186+ .bind(accountId, fingerprint, label, t, t)
187+ .first<{ first_seen: string }>();
188+ return r?.first_seen === t;
189+ }
190+
191+ async log(row: Omit<EmailLogRow, "id" | "created_at">): Promise<void> {
192+ await this.db
193+ .prepare("INSERT INTO email_log (id, account_id, to_addr, kind, subject, status, detail, message_id, dev_copy, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
194+ .bind(newId(), row.account_id, row.to_addr, row.kind, row.subject.slice(0, 500), row.status, row.detail?.slice(0, 1000) ?? null, row.message_id, row.dev_copy, now())
195+ .run();
196+ }
197+
198+ async recent(opts: { limit?: number; accountId?: string; kind?: string; since?: string } = {}): Promise<EmailLogRow[]> {
199+ const where: string[] = [];
200+ const args: unknown[] = [];
201+ if (opts.accountId) (where.push("account_id = ?"), args.push(opts.accountId));
202+ if (opts.kind) (where.push("kind = ?"), args.push(opts.kind));
203+ if (opts.since) (where.push("created_at >= ?"), args.push(opts.since));
204+ const q = `SELECT * FROM email_log ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC LIMIT ?`;
205+ return (await this.db.prepare(q).bind(...args, Math.min(Math.max(opts.limit ?? 50, 1), 500)).all<EmailLogRow>()).results;
206+ }
207+
208+ /** Drop log rows older than 90 days. */
209+ async prune(): Promise<void> {
210+ await this.db.prepare("DELETE FROM email_log WHERE created_at < ?").bind(new Date(Date.now() - 90 * 86400_000).toISOString()).run();
211+ }
212+}
addedapps/diffinery/src/mail/samples.ts+83−0View file
@@ -1,0 +1,83 @@
1+import type { MailJob, Recipient, ThreadData } from "./types.js";
2+
3+/**
4+ * One example of every email Diffinery sends, for the template gallery in site
5+ * administration, the "send me every email" check and the template tests.
6+ */
7+export function sampleJobs(pub: string, to: Recipient, me: string, repo = "acme/website"): Array<{ id: string; label: string; job: MailJob }> {
8+ const at = new Date().toISOString();
9+ const repoId = "sample";
10+ const thread = (over: Partial<ThreadData>): ThreadData => ({
11+ repo,
12+ repoId,
13+ number: 42,
14+ kind: "pull",
15+ title: "Cache build artifacts between workflow runs",
16+ event: "opened",
17+ actor: "octo-dev",
18+ url: `${pub}/${repo}/pull/42`,
19+ base: "main",
20+ head: "cache-artifacts",
21+ ...over,
22+ });
23+ const body = "This keeps `node_modules` and the build output in the Actions cache, keyed on the lockfile.\n\n- Cold build: **3m 40s**\n- Warm build: **48s**\n\nCloses #37. cc @" + me;
24+ const item = (id: string, label: string, j: Omit<MailJob, "to" | "at">) => ({ id, label, job: { ...j, to, at } as MailJob });
25+ const exp = new Date(Date.now() + 6 * 86400_000).toISOString();
26+ return [
27+ item("pr-opened", "Pull request opened", { kind: "thread", reason: "review_requested", data: thread({ body, intent: "Make CI twice as fast without changing what it tests.", reviewers: [me], commits: [{ sha: "3f9c2a1d8e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a", message: "Cache node_modules on the lockfile hash" }, { sha: "8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b", message: "Cache the build output too" }] }) }),
28+ item("issue-opened", "Issue opened (watching)", { kind: "thread", reason: "subscribed", data: thread({ kind: "issue", number: 37, title: "CI takes almost four minutes on every push", url: `${pub}/${repo}/issues/37`, body: "Every push reinstalls all dependencies. Could we cache them?", base: undefined, head: undefined }) }),
29+ item("comment", "New comment (mention)", { kind: "thread", reason: "mention", data: thread({ event: "comment", actor: "rivera", body: `Looks good to me. @${me}, can you double-check the cache key on Windows runners?`, url: `${pub}/${repo}/pull/42#c-sample` }) }),
30+ item("review-approved", "Review: approved", { kind: "thread", reason: "author", data: thread({ event: "review", state: "APPROVED", actor: "rivera", body: "Nice speed-up. Ship it." }) }),
31+ item("review-changes", "Review: changes requested", { kind: "thread", reason: "author", data: thread({ event: "review", state: "CHANGES_REQUESTED", actor: "kim", body: "**.github/workflows/ci.yml:18**\n\nThe key should include the runner OS, or Linux and macOS caches will collide." }) }),
32+ item("review-requested", "Review requested", { kind: "thread", reason: "review_requested", data: thread({ event: "review_requested", reviewers: [me], intent: "Make CI twice as fast without changing what it tests." }) }),
33+ item("assigned", "Assigned", { kind: "thread", reason: "assign", data: thread({ kind: "issue", number: 37, title: "CI takes almost four minutes on every push", event: "assigned", assignees: [me], url: `${pub}/${repo}/issues/37` }) }),
34+ item("pushed", "New commits pushed", { kind: "thread", reason: "comment", data: thread({ event: "pushed", commits: [{ sha: "c0ffee1d8e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a", message: "Include the runner OS in the cache key" }] }) }),
35+ item("merged", "Pull request merged", { kind: "thread", reason: "author", data: thread({ event: "merged", actor: "kim" }) }),
36+ item("closed", "Issue closed", { kind: "thread", reason: "author", data: thread({ kind: "issue", number: 37, title: "CI takes almost four minutes on every push", event: "closed", stateReason: "completed", url: `${pub}/${repo}/issues/37` }) }),
37+ item("reopened", "Issue reopened", { kind: "thread", reason: "comment", data: thread({ kind: "issue", number: 37, title: "CI takes almost four minutes on every push", event: "reopened", url: `${pub}/${repo}/issues/37` }) }),
38+ item("run-failed", "Workflow run failed", {
39+ kind: "run",
40+ reason: "ci_activity",
41+ data: {
42+ repo,
43+ repoId,
44+ runId: "sample",
45+ runNumber: 318,
46+ workflow: "CI",
47+ title: "Cache node_modules on the lockfile hash",
48+ conclusion: "failure",
49+ event: "push",
50+ branch: "main",
51+ sha: "3f9c2a1d8e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a",
52+ actor: me,
53+ url: `${pub}/${repo}/actions/runs/sample`,
54+ jobs: [
55+ { name: "Typecheck and test", conclusion: "failure", url: `${pub}/${repo}/actions/runs/sample/job/1`, failure: "Process completed with exit code 1 (step: Unit tests)" },
56+ { name: "Lint", conclusion: "success", url: `${pub}/${repo}/actions/runs/sample/job/2` },
57+ { name: "Deploy", conclusion: "skipped", url: `${pub}/${repo}/actions/runs/sample/job/3` },
58+ ],
59+ },
60+ }),
61+ item("signin", "New sign-in", { kind: "signin_new", reason: "security_alert", data: { method: "passkey", device: "Safari on macOS", ip: "203.0.113.24", location: "Istanbul, TR", passkey: "MacBook Touch ID" } }),
62+ item("passkey-added", "Passkey added", { kind: "passkey_added", reason: "security_alert", data: { name: "iPhone", via: "settings" } }),
63+ item("passkey-removed", "Passkey removed", { kind: "passkey_removed", reason: "security_alert", data: { name: "Old YubiKey" } }),
64+ item("token-created", "Access token created", { kind: "token_created", reason: "security_alert", data: { name: "laptop", scopes: ["write"] } }),
65+ item("token-expiring", "Access token expiring", { kind: "token_expiring", reason: "security_alert", data: { name: "ci-deploy", prefix: "dfy_Ab12Cd", expiresAt: exp } }),
66+ item("agent-created", "Agent added", { kind: "agent_created", reason: "security_alert", data: { agent: "review-bot" } }),
67+ item("push-bypassed", "Push protection bypassed", { kind: "push_bypassed", reason: "security_alert", data: { repo, pusher: me, kinds: ["AWS access key"], reason: "Fake key used by a test fixture", refs: ["refs/heads/main"] } }),
68+ item("push-blocked", "Push blocked", { kind: "push_blocked", reason: "security_alert", data: { repo, kinds: ["GitHub token"], refs: ["refs/heads/feature"] } }),
69+ item("account-disabled", "Account disabled", { kind: "account_status", reason: "account", data: { status: "disabled" } }),
70+ item("account-enabled", "Account enabled", { kind: "account_status", reason: "account", data: { status: "active" } }),
71+ item("invite-org", "Invitation (organization)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, org: repo.split("/")[0], role: "member", expiresAt: exp } }),
72+ item("invite-repo", "Invitation (repository)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, repo, role: "write", expiresAt: exp } }),
73+ item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }),
74+ item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }),
75+ item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
76+ item("access-request", "Access request", { kind: "access_request", reason: "admin", data: { email: "newcomer@example.com", login: "newcomer", note: "I'm joining the platform team next week." } }),
77+ item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member" } }),
78+ item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
79+ item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }),
80+ item("repo-deleted", "Repository deleted", { kind: "repo_deleted", reason: "admin", data: { repo, actor: "kim" } }),
81+ item("test", "Test email", { kind: "test", reason: "account", data: { by: me } }),
82+ ];
83+}
addedapps/diffinery/src/mail/send.ts+155−0View file
@@ -1,0 +1,155 @@
1+import type { Env } from "../env.js";
2+import { Directory } from "../lib/directory.js";
3+import { MailStore, wantsEmail } from "./prefs.js";
4+import { compose, toHtml, toText, type RenderCtx } from "./templates.js";
5+import { categoryOf, isMandatory, type MailJob } from "./types.js";
6+import { unsubUrl } from "./unsubscribe.js";
7+
8+/**
9+ * Sending: jobs go on the `diffinery-mail` queue (so a slow or failing send
10+ * never holds up a request, and failures retry with backoff), and the queue
11+ * consumer checks the recipient's settings, renders and sends each one through
12+ * Cloudflare Email Sending.
13+ */
14+
15+/** Queue emails. Never throws: email must not break the action that caused it. */
16+export async function enqueueMail(env: Env, jobs: MailJob[]): Promise<void> {
17+ if (!jobs.length) return;
18+ if (!env.MAIL) {
19+ console.warn("mail queue not bound; dropping", jobs.length, "email(s)");
20+ return;
21+ }
22+ try {
23+ for (let i = 0; i < jobs.length; i += 50) await env.MAIL.sendBatch(jobs.slice(i, i + 50).map((body) => ({ body })));
24+ } catch (e) {
25+ console.error("could not queue email", e);
26+ }
27+}
28+
29+const RETRYABLE = new Set(["E_RATE_LIMIT_EXCEEDED", "E_INTERNAL_SERVER_ERROR", "E_DELIVERY_FAILED"]);
30+
31+export class RetryableMailError extends Error {}
32+
33+const clean = (s: string) => s.replace(/[\r\n]+/g, " ").trim();
34+const listIdLabel = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "x";
35+
36+export interface Outgoing {
37+ from: { name: string; email: string };
38+ to: { name: string; email: string } | string;
39+ subject: string;
40+ html: string;
41+ text: string;
42+ headers: Record<string, string>;
43+}
44+
45+/** Render a job for one recipient: the message plus the headers mail clients use for threading and unsubscribing. */
46+export async function buildMessage(env: Env, job: MailJob, to: { email: string; name?: string; login?: string; accountId?: string }): Promise<Outgoing> {
47+ const pub = env.PUBLIC_URL;
48+ const email = compose(job, pub, to.login);
49+ const category = categoryOf(job);
50+ const unsubscribe: Array<{ label: string; url: string }> = [];
51+ if (to.accountId && !isMandatory(category)) {
52+ const a = to.accountId;
53+ if (job.kind === "thread") {
54+ const d = job.data;
55+ unsubscribe.push({ label: "Mute this thread", url: await unsubUrl(env, { accountId: a, scope: { t: "thread", repoId: d.repoId, number: d.number, repo: d.repo } }) });
56+ if (job.reason === "subscribed") unsubscribe.push({ label: `Stop watching ${d.repo}`, url: await unsubUrl(env, { accountId: a, scope: { t: "repo", repoId: d.repoId, repo: d.repo } }) });
57+ }
58+ if (category !== "security" && category !== "account") {
59+ unsubscribe.push({ label: "Unsubscribe from emails like this", url: await unsubUrl(env, { accountId: a, scope: { t: "category", category } }) });
60+ }
61+ }
62+ const ctx: RenderCtx = { publicUrl: pub, login: to.login, reason: job.reason, unsubscribe, settingsUrl: to.accountId ? `${pub}/settings/notifications` : undefined };
63+ const host = new URL(pub).hostname;
64+ const headers: Record<string, string> = {
65+ "Auto-Submitted": "auto-generated",
66+ "X-Diffinery-Reason": job.reason,
67+ "X-Diffinery-Kind": job.kind,
68+ };
69+ if (to.login) headers["X-Diffinery-Recipient"] = to.login;
70+ if (!isMandatory(category)) headers.Precedence = "list";
71+ if (email.thread) {
72+ const ref = `<${email.thread}@${host}>`;
73+ headers.References = ref;
74+ if (job.kind === "thread" && job.data.event !== "opened") headers["In-Reply-To"] = ref;
75+ }
76+ if (email.context) {
77+ const [owner, name] = email.context.label.includes("/") ? email.context.label.split("/") : [email.context.label, ""];
78+ headers["List-Id"] = `${clean(email.context.label)} <${name ? `${listIdLabel(name)}.` : ""}${listIdLabel(owner!)}.${host}>`;
79+ headers["X-Diffinery-Repository"] = email.context.label;
80+ }
81+ if (unsubscribe.length) {
82+ headers["List-Unsubscribe"] = `<${unsubscribe[0]!.url}>`;
83+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
84+ }
85+ return {
86+ from: { name: email.fromName ? `${clean(email.fromName)} (Diffinery)` : "Diffinery", email: env.MAIL_FROM || `notifications@${host}` },
87+ to: to.name ? { name: clean(to.name), email: to.email } : to.email,
88+ subject: clean(email.subject).slice(0, 900),
89+ html: toHtml(email, ctx),
90+ text: toText(email, ctx),
91+ headers,
92+ };
93+}
94+
95+/** Send one job now: resolve the recipient, apply their settings, render and send. */
96+export async function deliver(env: Env, job: MailJob): Promise<{ status: "sent" | "skipped" | "failed"; detail?: string; messageId?: string }> {
97+ const dir = new Directory(env);
98+ const store = new MailStore(env.DB);
99+ const category = categoryOf(job);
100+ let to: { email: string; name?: string; login?: string; accountId?: string };
101+ if ("accountId" in job.to) {
102+ const accountId = job.to.accountId;
103+ const a = await dir.account(accountId);
104+ const skip = async (detail: string) => {
105+ await store.log({ account_id: accountId, to_addr: a?.email ?? "", kind: job.kind, subject: "", status: "skipped", detail, message_id: null, dev_copy: null });
106+ return { status: "skipped" as const, detail };
107+ };
108+ if (!a) return skip("no such account");
109+ if (a.kind !== "user") return skip(`${a.kind} accounts have no email`);
110+ if (!a.email) return skip("no email address");
111+ if (a.status === "disabled" && job.kind !== "account_status") return skip("account disabled");
112+ if (!job.force && !isMandatory(category) && !wantsEmail(await store.prefs(a.id), category)) return skip(`turned off ${category} emails`);
113+ to = { email: a.email, name: a.name ?? undefined, login: a.login, accountId: a.id };
114+ } else {
115+ to = { email: job.to.email, name: job.to.name };
116+ }
117+
118+ const msg = await buildMessage(env, job, to);
119+ const devCopy = env.DEV_AUTH ? JSON.stringify(msg) : null;
120+ const base = { account_id: to.accountId ?? null, to_addr: to.email, kind: job.kind, subject: msg.subject, dev_copy: devCopy };
121+ if (!env.EMAIL) {
122+ await store.log({ ...base, status: "skipped", detail: "email sending is not configured (no EMAIL binding)", message_id: null });
123+ return { status: "skipped", detail: "no EMAIL binding" };
124+ }
125+ try {
126+ const res = await env.EMAIL.send(msg);
127+ await store.log({ ...base, status: "sent", detail: null, message_id: res.messageId ?? null });
128+ return { status: "sent", messageId: res.messageId };
129+ } catch (e) {
130+ const code = (e as { code?: string }).code ?? "";
131+ const detail = `${code ? `${code}: ` : ""}${(e as Error).message ?? String(e)}`;
132+ if (RETRYABLE.has(code)) throw new RetryableMailError(detail);
133+ await store.log({ ...base, status: "failed", detail, message_id: null });
134+ console.error("email failed", job.kind, to.email, detail);
135+ return { status: "failed", detail };
136+ }
137+}
138+
139+/** Queue consumer for `diffinery-mail`. */
140+export async function mailQueue(batch: MessageBatch<MailJob>, env: Env): Promise<void> {
141+ for (const msg of batch.messages) {
142+ try {
143+ await deliver(env, msg.body);
144+ msg.ack();
145+ } catch (e) {
146+ console.error("email delivery error", msg.body.kind, e);
147+ if (msg.attempts >= 6) {
148+ await new MailStore(env.DB)
149+ .log({ account_id: "accountId" in msg.body.to ? msg.body.to.accountId : null, to_addr: "email" in msg.body.to ? msg.body.to.email : "", kind: msg.body.kind, subject: "", status: "failed", detail: `gave up after ${msg.attempts} attempts: ${(e as Error).message}`, message_id: null, dev_copy: null })
150+ .catch(() => {});
151+ msg.ack();
152+ } else msg.retry({ delaySeconds: Math.min(600, 15 * 2 ** msg.attempts) });
153+ }
154+ }
155+}
addedapps/diffinery/src/mail/templates.ts+636−0View file
@@ -1,0 +1,636 @@
1+/**
2+ * Email templates. Each email is a list of blocks rendered twice: as HTML
3+ * (table layout and inline styles that hold up in Gmail, Outlook and Apple
4+ * Mail, light and dark) and as plain text. The look follows the site: Kumo's
5+ * neutral palette, blue for actions and the orange mark.
6+ */
7+import { escapeHtml, renderMarkdown } from "../web/markdown.js";
8+import type { MailJob, Reason, RunData, ThreadData } from "./types.js";
9+
10+export type Inline = string | { b: string } | { a: string; href: string } | { code: string };
11+
12+export type Block =
13+ | { p: Inline[]; muted?: boolean }
14+ | { md: string; base?: { owner: string; repo: string; ref: string } }
15+ | { kv: Array<[string, Inline[] | string]> }
16+ | { button: { label: string; url: string }; secondary?: { label: string; url: string } }
17+ | { callout: Inline[]; tone: "info" | "warn" | "danger" }
18+ | { code: string }
19+ | { items: Array<{ mark: "ok" | "fail" | "skip" | "dot"; text: Inline[]; url?: string }> }
20+ | { quote: Inline[] };
21+
22+export interface Email {
23+ subject: string;
24+ /** Hidden preview line shown by mail clients next to the subject. */
25+ preheader: string;
26+ /** Small label above the title (e.g. "Security", "Pull request"). */
27+ eyebrow?: { text: string; tone: "neutral" | "blue" | "green" | "red" | "amber" | "purple" };
28+ title: Inline[];
29+ blocks: Block[];
30+ /** Display name on the From address, e.g. the person who commented. */
31+ fromName?: string;
32+ /** Repository the email is about, shown next to the logo and used for List-Id. */
33+ context?: { label: string; url: string };
34+ /** Thread id for In-Reply-To / References, e.g. "owner/repo/issues/12". */
35+ thread?: string;
36+ /** Main link (also "View it on Diffinery" in the footer). */
37+ url?: string;
38+}
39+
40+export interface RenderCtx {
41+ publicUrl: string;
42+ /** The recipient's login, when they have an account. */
43+ login?: string;
44+ reason: Reason;
45+ /** Footer links: unsubscribe (thread, watch or settings group) and settings. */
46+ unsubscribe?: Array<{ label: string; url: string }>;
47+ settingsUrl?: string;
48+}
49+
50+// ---------------------------------------------------------------- palette (Kumo, as hex for mail clients)
51+
52+const C = {
53+ canvas: "#f4f4f5",
54+ card: "#ffffff",
55+ border: "#e4e4e7",
56+ fg: "#18181b",
57+ fg2: "#52525b",
58+ fg3: "#71717a",
59+ accent: "#1447e6",
60+ brand: "#f6821f",
61+ codeBg: "#f4f4f5",
62+ tones: {
63+ neutral: ["#f4f4f5", "#3f3f46"],
64+ blue: ["#e6edfd", "#1447e6"],
65+ green: ["#e3f6ec", "#007a55"],
66+ red: ["#fdecec", "#c10007"],
67+ amber: ["#fdf3dc", "#a65f00"],
68+ purple: ["#f3e8ff", "#8200db"],
69+ } as Record<string, [string, string]>,
70+};
71+const FONT = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif`;
72+const MONO = `ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono',monospace`;
73+
74+// ---------------------------------------------------------------- rendering
75+
76+const esc = escapeHtml;
77+
78+function inlineHtml(parts: Inline[] | string): string {
79+ const list = typeof parts === "string" ? [parts] : parts;
80+ return list
81+ .map((x) => {
82+ if (typeof x === "string") return esc(x);
83+ if ("b" in x) return `<strong style="font-weight:600">${esc(x.b)}</strong>`;
84+ if ("code" in x) return `<code style="font-family:${MONO};font-size:12.5px;background:${C.codeBg};border-radius:4px;padding:1px 5px">${esc(x.code)}</code>`;
85+ return `<a href="${esc(x.href)}" style="color:${C.accent};text-decoration:none">${esc(x.a)}</a>`;
86+ })
87+ .join("");
88+}
89+
90+function inlineText(parts: Inline[] | string): string {
91+ const list = typeof parts === "string" ? [parts] : parts;
92+ return list.map((x) => (typeof x === "string" ? x : "b" in x ? x.b : "code" in x ? `\`${x.code}\`` : `${x.a} (${x.href})`)).join("");
93+}
94+
95+/** Markdown from issues and comments, with relative links made absolute. */
96+function markdownHtml(md: string, publicUrl: string, base?: { owner: string; repo: string; ref: string }): string {
97+ const html = renderMarkdown(md.length > 20_000 ? `${md.slice(0, 20_000)}\n\n…` : md, base ? { base: { ...base, dir: "" } } : {});
98+ return html.replace(/(href|src)="\/(?!\/)/g, `$1="${publicUrl}/`).replace(/href="#/g, `href="${publicUrl}/#`);
99+}
100+
101+function blockHtml(b: Block, ctx: RenderCtx): string {
102+ const P = `margin:0 0 14px;font-size:15px;line-height:1.6;color:${C.fg}`;
103+ if ("p" in b) return `<p class="${b.muted ? "muted" : "fg"}" style="${P}${b.muted ? `;color:${C.fg3};font-size:13px` : ""}">${inlineHtml(b.p)}</p>`;
104+ if ("md" in b)
105+ return `<div class="md box" style="margin:0 0 16px;padding:14px 16px;border:1px solid ${C.border};border-radius:8px;font-size:14px;line-height:1.6;color:${C.fg};overflow-wrap:anywhere">${markdownHtml(b.md, ctx.publicUrl, b.base) || `<span style="color:${C.fg3}">No description provided.</span>`}</div>`;
106+ if ("kv" in b)
107+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.kv
108+ .map(
109+ ([k, v], i) =>
110+ `<tr><td class="muted" style="padding:9px 14px;${i ? `border-top:1px solid ${C.border};` : ""}font-size:13px;color:${C.fg3};width:130px;vertical-align:top">${esc(k)}</td><td class="fg" style="padding:9px 14px;${i ? `border-top:1px solid ${C.border};` : ""}font-size:13px;color:${C.fg};overflow-wrap:anywhere">${inlineHtml(v)}</td></tr>`,
111+ )
112+ .join("")}</table>`;
113+ if ("button" in b) {
114+ const btn = (l: { label: string; url: string }, primary: boolean) =>
115+ `<a href="${esc(l.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;${primary ? `background:${C.accent};color:#ffffff;border:1px solid ${C.accent}` : `background:${C.card};color:${C.fg};border:1px solid ${C.border}`}" class="${primary ? "" : "btn2"}">${esc(l.label)}</a>`;
116+ return `<p style="margin:6px 0 18px">${btn(b.button, true)}${b.secondary ? `&nbsp;&nbsp;${btn(b.secondary, false)}` : ""}</p>`;
117+ }
118+ if ("callout" in b) {
119+ const [bg, fg] = C.tones[b.tone === "info" ? "blue" : b.tone === "warn" ? "amber" : "red"]!;
120+ return `<div style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${bg};color:${fg};font-size:13.5px;line-height:1.55">${inlineHtml(b.callout)}</div>`;
121+ }
122+ if ("code" in b)
123+ return `<pre class="md" style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${C.codeBg};font-family:${MONO};font-size:12.5px;line-height:1.55;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.code)}</pre>`;
124+ if ("quote" in b) return `<div class="muted" style="margin:0 0 16px;padding:2px 0 2px 14px;border-left:3px solid ${C.border};color:${C.fg2};font-size:14px;line-height:1.6">${inlineHtml(b.quote)}</div>`;
125+ const marks = { ok: ["✓", C.tones.green![1]], fail: ["✕", C.tones.red![1]], skip: ["–", C.fg3], dot: ["•", C.fg3] } as const;
126+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.items
127+ .map(
128+ (it, i) =>
129+ `<tr><td style="padding:8px 0 8px 14px;${i ? `border-top:1px solid ${C.border};` : ""}width:18px;font-size:14px;font-weight:700;color:${marks[it.mark][1]};vertical-align:top">${marks[it.mark][0]}</td><td class="fg" style="padding:8px 14px 8px 8px;${i ? `border-top:1px solid ${C.border};` : ""}font-size:13.5px;color:${C.fg};overflow-wrap:anywhere">${it.url ? `<a href="${esc(it.url)}" style="color:${C.fg};text-decoration:none">${inlineHtml(it.text)}</a>` : inlineHtml(it.text)}</td></tr>`,
130+ )
131+ .join("")}</table>`;
132+}
133+
134+function blockText(b: Block): string {
135+ if ("p" in b) return inlineText(b.p);
136+ if ("md" in b) return (b.md.trim() || "No description provided.").split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
137+ if ("kv" in b) return b.kv.map(([k, v]) => `${k}: ${inlineText(v)}`).join("\n");
138+ if ("button" in b) return `${b.button.label}: ${b.button.url}${b.secondary ? `\n${b.secondary.label}: ${b.secondary.url}` : ""}`;
139+ if ("callout" in b) return `! ${inlineText(b.callout)}`;
140+ if ("code" in b) return b.code.split("\n").map((l) => ` ${l}`).join("\n");
141+ if ("quote" in b) return `> ${inlineText(b.quote)}`;
142+ const marks = { ok: "[ok]", fail: "[failed]", skip: "[skipped]", dot: "-" };
143+ return b.items.map((it) => `${marks[it.mark]} ${inlineText(it.text)}${it.url ? ` <${it.url}>` : ""}`).join("\n");
144+}
145+
146+export const REASON_TEXT: Record<Reason, string> = {
147+ author: "You are receiving this because you opened this thread.",
148+ comment: "You are receiving this because you commented.",
149+ mention: "You are receiving this because you were mentioned.",
150+ assign: "You are receiving this because you were assigned.",
151+ review_requested: "You are receiving this because your review was requested.",
152+ manual: "You are receiving this because you subscribed to this thread.",
153+ state_change: "You are receiving this because you changed this thread's state.",
154+ subscribed: "You are receiving this because you are watching this repository.",
155+ ci_activity: "You are receiving this because this workflow run was triggered by you, or you watch this repository.",
156+ security_alert: "This is a security notice about your Diffinery account. These can't be turned off.",
157+ your_activity: "You are receiving this because of something you did on Diffinery.",
158+ invitation: "You are receiving this because someone invited this email address to Diffinery. If you weren't expecting it, you can ignore this email.",
159+ admin: "You are receiving this because you are an administrator.",
160+ account: "You are receiving this because you have a Diffinery account.",
161+};
162+
163+const LOGO = (url: string) => `${url}/icon-512.png`;
164+
165+/** Full HTML document. */
166+export function toHtml(e: Email, ctx: RenderCtx): string {
167+ const tone = e.eyebrow ? C.tones[e.eyebrow.tone]! : null;
168+ const footerLinks = [
169+ ...(e.url ? [{ label: "View it on Diffinery", url: e.url }] : []),
170+ ...(ctx.unsubscribe ?? []),
171+ ...(ctx.settingsUrl ? [{ label: "Notification settings", url: ctx.settingsUrl }] : []),
172+ ];
173+ return `<!doctype html>
174+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark"><title>${esc(e.subject)}</title>
175+<style>
176+body{margin:0;padding:0;-webkit-text-size-adjust:100%}
177+.md p{margin:0 0 10px}.md p:last-child{margin-bottom:0}
178+.md pre{background:${C.codeBg};padding:10px 12px;border-radius:6px;overflow:auto;font-family:${MONO};font-size:12.5px;line-height:1.5;white-space:pre-wrap}
179+.md code{background:${C.codeBg};border-radius:4px;padding:1px 4px;font-family:${MONO};font-size:12.5px}
180+.md pre code{padding:0;background:none}
181+.md blockquote{margin:0 0 10px;padding:0 12px;border-left:3px solid ${C.border};color:${C.fg3}}
182+.md table{border-collapse:collapse;margin:0 0 10px}.md td,.md th{border:1px solid ${C.border};padding:4px 8px}
183+.md img{max-width:100%;height:auto}.md a{color:${C.accent}}.md h1,.md h2,.md h3{font-size:16px;margin:12px 0 8px}
184+.md ul,.md ol{padding-left:22px;margin:0 0 10px}
185+@media (prefers-color-scheme:dark){
186+.bg{background:#0a0a0a !important}.card{background:#171717 !important;border-color:#2e2e2e !important}
187+.fg,.fg a,h1.fg{color:#f4f4f5 !important}.muted{color:#a3a3a3 !important}.box{border-color:#2e2e2e !important}
188+.box td{border-color:#2e2e2e !important}.md pre,.md code,pre.md{background:#262626 !important;color:#f4f4f5 !important}
189+.btn2{background:#262626 !important;color:#f4f4f5 !important;border-color:#3f3f46 !important}}
190+@media (max-width:620px){.card{border-radius:0 !important;padding:22px 18px !important}.outer{padding:0 !important}}
191+</style></head>
192+<body class="bg" style="margin:0;padding:0;background:${C.canvas}">
193+<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${esc(e.preheader)}${"&#8199;&#65279;&#847; ".repeat(30)}</div>
194+<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="bg" style="background:${C.canvas};font-family:${FONT}">
195+<tr><td align="center" class="outer" style="padding:28px 12px">
196+<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
197+<tr><td style="padding:0 4px 14px">
198+<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
199+<td style="vertical-align:middle"><a href="${esc(ctx.publicUrl)}" style="text-decoration:none"><img src="${esc(LOGO(ctx.publicUrl))}" width="26" height="26" alt="" style="vertical-align:middle;border:0;border-radius:6px"> <span class="fg" style="vertical-align:middle;font-size:16px;font-weight:650;color:${C.fg};letter-spacing:-.01em">Diffinery</span></a></td>
200+${e.context ? `<td align="right" style="vertical-align:middle;font-size:13px"><a href="${esc(e.context.url)}" class="muted" style="color:${C.fg3};text-decoration:none">${esc(e.context.label)}</a></td>` : ""}
201+</tr></table>
202+</td></tr>
203+<tr><td class="card" style="background:${C.card};border:1px solid ${C.border};border-radius:12px;padding:28px 32px">
204+${e.eyebrow && tone ? `<div style="margin:0 0 12px"><span style="display:inline-block;padding:3px 9px;border-radius:999px;background:${tone[0]};color:${tone[1]};font-size:12px;font-weight:600">${esc(e.eyebrow.text)}</span></div>` : ""}
205+<h1 class="fg" style="margin:0 0 16px;font-size:20px;line-height:1.35;font-weight:650;color:${C.fg};letter-spacing:-.01em">${inlineHtml(e.title)}</h1>
206+${e.blocks.map((b) => blockHtml(b, ctx)).join("\n")}
207+</td></tr>
208+<tr><td class="muted" style="padding:18px 8px 0;font-size:12px;line-height:1.6;color:${C.fg3}">
209+<p style="margin:0 0 6px">${esc(REASON_TEXT[ctx.reason])}</p>
210+${footerLinks.length ? `<p style="margin:0 0 6px">${footerLinks.map((l) => `<a href="${esc(l.url)}" style="color:${C.fg3};text-decoration:underline">${esc(l.label)}</a>`).join(" &middot; ")}</p>` : ""}
211+<p style="margin:0">Diffinery &middot; Code hosting and CI on Cloudflare &middot; <a href="${esc(ctx.publicUrl)}" style="color:${C.fg3}">${esc(new URL(ctx.publicUrl).host)}</a></p>
212+</td></tr>
213+</table>
214+</td></tr></table>
215+</body></html>`;
216+}
217+
218+export function toText(e: Email, ctx: RenderCtx): string {
219+ const lines = [inlineText(e.title), "", ...e.blocks.map(blockText).flatMap((t) => [t, ""])];
220+ lines.push("--", REASON_TEXT[ctx.reason]);
221+ if (e.url) lines.push(`View it on Diffinery: ${e.url}`);
222+ for (const u of ctx.unsubscribe ?? []) lines.push(`${u.label}: ${u.url}`);
223+ if (ctx.settingsUrl) lines.push(`Notification settings: ${ctx.settingsUrl}`);
224+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
225+}
226+
227+// ---------------------------------------------------------------- the emails
228+
229+const at = (iso: string) => new Date(iso).toUTCString().replace("GMT", "UTC");
230+const day = (iso: string) => new Date(iso).toLocaleDateString("en-US", { dateStyle: "long", timeZone: "UTC" });
231+const user = (login: string) => `@${login}`;
232+const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
233+const ROLE_TEXT: Record<string, string> = { read: "read", triage: "triage", write: "write", maintain: "maintain", admin: "admin", member: "member", owner: "owner" };
234+const roleText = (r: string) => ROLE_TEXT[r] ?? r;
235+const article = (w: string) => (/^[aeiou]/i.test(w) ? "an" : "a");
236+
237+function repoContext(pub: string, repo: string) {
238+ return { label: repo, url: `${pub}/${repo}` };
239+}
240+
241+function threadEmail(d: ThreadData, pub: string, login?: string): Email {
242+ const isPull = d.kind === "pull";
243+ const noun = isPull ? "pull request" : "issue";
244+ const tag = `${isPull ? "PR" : "Issue"} #${d.number}`;
245+ const threadUrl = `${pub}/${d.repo}/${isPull ? "pull" : "issues"}/${d.number}`;
246+ const [owner, name] = d.repo.split("/") as [string, string];
247+ const mdBase = { owner, repo: name, ref: d.base ?? "HEAD" };
248+ const subject = `${d.event === "opened" ? "" : "Re: "}[${d.repo}] ${d.title} (${tag})`;
249+ const me = (l: string) => !!login && l.toLowerCase() === login.toLowerCase();
250+ let title: Inline[];
251+ let eyebrow: Email["eyebrow"] = { text: isPull ? "Pull request" : "Issue", tone: isPull ? "purple" : "green" };
252+ const blocks: Block[] = [];
253+ const ref: Inline = { a: `${d.repo}#${d.number}`, href: threadUrl };
254+ switch (d.event) {
255+ case "opened":
256+ title = isPull ? [user(d.actor), " wants to merge ", { code: d.head ?? "" }, " into ", { code: d.base ?? "" }] : [user(d.actor), " opened ", ref];
257+ blocks.push({ p: [{ b: d.title }] });
258+ if (isPull && d.intent) blocks.push({ callout: [{ b: "Intent: " }, d.intent], tone: "info" });
259+ blocks.push({ md: d.body ?? "", base: mdBase });
260+ if (d.commits?.length) blocks.push({ items: d.commits.slice(0, 10).map((c) => ({ mark: "dot" as const, text: [{ code: c.sha.slice(0, 7) }, " ", c.message.split("\n")[0]!.slice(0, 120)] })) });
261+ if (d.reviewers?.length) blocks.push({ p: ["Review requested from ", d.reviewers.map(user).join(", "), "."], muted: true });
262+ if (d.assignees?.length) blocks.push({ p: ["Assigned to ", d.assignees.map(user).join(", "), "."], muted: true });
263+ blocks.push({ button: { label: `View ${noun}`, url: threadUrl }, ...(isPull ? { secondary: { label: "Files changed", url: `${threadUrl}/files` } } : {}) });
264+ break;
265+ case "comment":
266+ title = [user(d.actor), " commented on ", ref];
267+ blocks.push({ md: d.body ?? "", base: mdBase }, { button: { label: "Reply on Diffinery", url: d.url } });
268+ break;
269+ case "review": {
270+ const s = d.state ?? "COMMENTED";
271+ title = s === "APPROVED" ? [user(d.actor), " approved ", ref] : s === "CHANGES_REQUESTED" ? [user(d.actor), " requested changes on ", ref] : [user(d.actor), " reviewed ", ref];
272+ eyebrow = s === "APPROVED" ? { text: "Approved", tone: "green" } : s === "CHANGES_REQUESTED" ? { text: "Changes requested", tone: "red" } : { text: "Review", tone: "purple" };
273+ if (d.body?.trim()) blocks.push({ md: d.body, base: mdBase });
274+ blocks.push({ button: { label: "View review", url: d.url }, secondary: { label: "Files changed", url: `${threadUrl}/files` } });
275+ break;
276+ }
277+ case "closed":
278+ title = [user(d.actor), " closed ", ref, d.stateReason === "not_planned" ? " as not planned" : isPull ? "" : " as completed"];
279+ eyebrow = { text: "Closed", tone: isPull ? "red" : "purple" };
280+ blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
281+ break;
282+ case "merged":
283+ title = [user(d.actor), " merged ", ref, " into ", { code: d.base ?? "" }];
284+ eyebrow = { text: "Merged", tone: "purple" };
285+ blocks.push({ p: [{ b: d.title }] }, { button: { label: "View pull request", url: threadUrl } });
286+ break;
287+ case "reopened":
288+ title = [user(d.actor), " reopened ", ref];
289+ eyebrow = { text: "Reopened", tone: "green" };
290+ blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
291+ break;
292+ case "assigned": {
293+ const who = d.assignees ?? [];
294+ title = who.some(me) ? [user(d.actor), " assigned you to ", ref] : [user(d.actor), " assigned ", who.map(user).join(", "), " to ", ref];
295+ blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
296+ break;
297+ }
298+ case "review_requested": {
299+ const who = d.reviewers ?? [];
300+ title = who.some(me) ? [user(d.actor), " requested your review on ", ref] : [user(d.actor), " requested a review from ", who.map(user).join(", "), " on ", ref];
301+ eyebrow = { text: "Review requested", tone: "amber" };
302+ blocks.push({ p: [{ b: d.title }] });
303+ if (d.intent) blocks.push({ callout: [{ b: "Intent: " }, d.intent], tone: "info" });
304+ blocks.push({ button: { label: "Review changes", url: `${threadUrl}/files` }, secondary: { label: "View pull request", url: threadUrl } });
305+ break;
306+ }
307+ case "pushed": {
308+ const n = d.commits?.length ?? 0;
309+ title = [user(d.actor), ` pushed ${n ? plural(n, "commit") : "new commits"} to `, { code: d.head ?? "" }];
310+ blocks.push({ p: [ref, " ", { b: d.title }] });
311+ if (n) blocks.push({ items: d.commits!.slice(-10).map((c) => ({ mark: "dot" as const, text: [{ code: c.sha.slice(0, 7) }, " ", c.message.split("\n")[0]!.slice(0, 120)], url: `${pub}/${d.repo}/commit/${c.sha}` })) });
312+ blocks.push({ button: { label: "View changes", url: `${threadUrl}/files` } });
313+ break;
314+ }
315+ }
316+ return { subject, preheader: d.body?.trim() ? d.body.trim().slice(0, 140) : `${inlineText(title)}`, eyebrow, title, blocks, fromName: d.actor, context: repoContext(pub, d.repo), thread: `${d.repo}/${isPull ? "pull" : "issues"}/${d.number}`, url: d.url };
317+}
318+
319+function runEmail(d: RunData, pub: string): Email {
320+ const failed = d.conclusion === "failure" || d.conclusion === "timed_out";
321+ const sha = d.sha.slice(0, 7);
322+ const word = failed ? "failed" : d.conclusion === "success" ? "succeeded" : d.conclusion;
323+ return {
324+ subject: `[${d.repo}] Run ${word}: ${d.workflow} - ${d.branch} (${sha})`,
325+ preheader: `${d.workflow} #${d.runNumber} ${word} on ${d.branch}: ${d.title}`,
326+ eyebrow: failed ? { text: "Workflow run failed", tone: "red" } : { text: `Workflow run ${word}`, tone: d.conclusion === "success" ? "green" : "neutral" },
327+ title: [{ b: d.workflow }, ` #${d.runNumber} ${word} on `, { code: d.branch }],
328+ blocks: [
329+ {
330+ kv: [
331+ ["Repository", [{ a: d.repo, href: `${pub}/${d.repo}` }]],
332+ ["Commit", [{ a: sha, href: `${pub}/${d.repo}/commit/${d.sha}` }, " ", d.title]],
333+ ["Triggered by", `${d.event} by @${d.actor}`],
334+ ],
335+ },
336+ { items: d.jobs.slice(0, 20).map((j) => ({ mark: j.conclusion === "success" ? ("ok" as const) : j.conclusion === "failure" || j.conclusion === "timed_out" ? ("fail" as const) : ("skip" as const), text: [j.name, j.failure ? ` — ${j.failure.slice(0, 160)}` : j.conclusion && !["success", "failure", "skipped"].includes(j.conclusion) ? ` (${j.conclusion.replace(/_/g, " ")})` : ""], url: j.url })) },
337+ { button: { label: "View workflow run", url: d.url } },
338+ ],
339+ context: repoContext(pub, d.repo),
340+ url: d.url,
341+ };
342+}
343+
344+/** Build the email for a job. `login` is the recipient's login when they have an account. */
345+export function compose(job: MailJob, pub: string, login?: string): Email {
346+ const settings = `${pub}/settings/security`;
347+ const securityTip: Block = { callout: ["If this wasn't you, sign out of every other browser and remove any passkey or token you don't recognize under ", { a: "Passkeys and sessions", href: settings }, ", then tell a site administrator."], tone: "warn" };
348+ switch (job.kind) {
349+ case "thread":
350+ return threadEmail(job.data, pub, login);
351+ case "run":
352+ return runEmail(job.data, pub);
353+ case "signin_new": {
354+ const d = job.data;
355+ const how = d.method === "access" ? "email recovery" : d.method === "link" ? "a one-time sign-in link" : `the passkey “${d.passkey ?? "passkey"}”`;
356+ return {
357+ subject: "[Diffinery] New sign-in to your account",
358+ preheader: `Signed in with ${how} on ${d.device}.`,
359+ eyebrow: { text: "Security", tone: "red" },
360+ title: ["New sign-in to ", { b: user(login ?? "your account") }],
361+ blocks: [
362+ { p: [`Your Diffinery account was just signed in to from a browser or device it hasn't seen before, using ${how}.`] },
363+ { kv: [["When", at(job.at)], ["Device", d.device], ...(d.location ? ([["Location", d.location]] as Array<[string, string]>) : []), ...(d.ip ? ([["IP address", d.ip]] as Array<[string, string]>) : [])] },
364+ securityTip,
365+ { button: { label: "Review passkeys and sessions", url: settings } },
366+ ],
367+ url: settings,
368+ };
369+ }
370+ case "passkey_added":
371+ return {
372+ subject: "[Diffinery] A passkey was added to your account",
373+ preheader: `The passkey “${job.data.name}” can now sign in to your account.`,
374+ eyebrow: { text: "Security", tone: "red" },
375+ title: ["A passkey was added to your account"],
376+ blocks: [
377+ { p: ["The passkey ", { b: `“${job.data.name}”` }, job.data.via === "signin-link" ? " was added with a one-time sign-in link from an administrator." : " was added to your account.", " It can now sign you in."] },
378+ { kv: [["When", at(job.at)], ["Passkey", job.data.name]] },
379+ securityTip,
380+ { button: { label: "Review passkeys", url: settings } },
381+ ],
382+ url: settings,
383+ };
384+ case "passkey_removed":
385+ return {
386+ subject: "[Diffinery] A passkey was removed from your account",
387+ preheader: `The passkey “${job.data.name}” can no longer sign in.`,
388+ eyebrow: { text: "Security", tone: "red" },
389+ title: ["A passkey was removed from your account"],
390+ blocks: [{ p: ["The passkey ", { b: `“${job.data.name}”` }, " was removed and can no longer sign in. Browsers it signed in were signed out."] }, { kv: [["When", at(job.at)]] }, securityTip, { button: { label: "Review passkeys", url: settings } }],
391+ url: settings,
392+ };
393+ case "token_created": {
394+ const d = job.data;
395+ const page = d.agent ? `${pub}/settings/agents` : `${pub}/settings/tokens`;
396+ return {
397+ subject: d.agent ? `[Diffinery] A token was created for the agent ${d.agent}` : "[Diffinery] A personal access token was added to your account",
398+ preheader: `“${d.name}” with ${d.scopes.join(", ")} access.`,
399+ eyebrow: { text: "Security", tone: "red" },
400+ title: d.agent ? ["A token was created for ", { b: user(d.agent) }] : ["A personal access token was added to your account"],
401+ blocks: [
402+ { p: [d.agent ? "Anything holding this token can act as the agent, within its policy." : "Anything holding this token can use Git, the API, the CLI and MCP as you, within its scopes."] },
403+ { kv: [["Name", d.name], ["Scopes", d.scopes.join(", ")], ["When", at(job.at)]] },
404+ { callout: ["If you didn't create this token, revoke it now."], tone: "warn" },
405+ { button: { label: d.agent ? "Review agents" : "Review tokens", url: page } },
406+ ],
407+ url: page,
408+ };
409+ }
410+ case "token_expiring": {
411+ const d = job.data;
412+ const page = d.agent ? `${pub}/settings/agents` : `${pub}/settings/tokens`;
413+ return {
414+ subject: `[Diffinery] Your token “${d.name}” expires on ${day(d.expiresAt)}`,
415+ preheader: "Create a new token before then so Git, CI and agents keep working.",
416+ eyebrow: { text: "Token expiring", tone: "amber" },
417+ title: ["Your token ", { b: `“${d.name}”` }, ` expires on ${day(d.expiresAt)}`],
418+ blocks: [
419+ { p: [d.agent ? `The token for the agent @${d.agent}` : "This personal access token", " stops working then. Create a new one and swap it in wherever the old one is used (Git credentials, CI secrets, MCP clients)."] },
420+ { kv: [["Name", d.name], ["Starts with", [{ code: `${d.prefix}…` }]], ["Expires", at(d.expiresAt)]] },
421+ { button: { label: "Create a new token", url: page } },
422+ ],
423+ url: page,
424+ };
425+ }
426+ case "agent_created":
427+ return {
428+ subject: `[Diffinery] A new agent was added: ${job.data.agent}`,
429+ preheader: `The agent @${job.data.agent} can act on your behalf once it has a token.`,
430+ eyebrow: { text: "Security", tone: "red" },
431+ title: ["A new agent was added: ", { b: user(job.data.agent) }],
432+ blocks: [
433+ { p: ["Agents act on behalf of their owner, capped by their policy. They can't push to protected branches, and their pull requests need a human approval."] },
434+ { kv: [["Agent", user(job.data.agent)], ["When", at(job.at)]] },
435+ { callout: ["If you didn't add this agent, disable it now."], tone: "warn" },
436+ { button: { label: "Review agents", url: `${pub}/settings/agents` } },
437+ ],
438+ url: `${pub}/settings/agents`,
439+ };
440+ case "push_bypassed": {
441+ const d = job.data;
442+ return {
443+ subject: `[${d.repo}] Push protection was bypassed`,
444+ preheader: `@${d.pusher} pushed ${d.kinds.join(", ")} past push protection: ${d.reason}`,
445+ eyebrow: { text: "Push protection bypassed", tone: "amber" },
446+ title: [user(d.pusher), " pushed possible secrets to ", { a: d.repo, href: `${pub}/${d.repo}` }],
447+ blocks: [
448+ { p: ["Push protection found possible secrets, and a repository admin pushed anyway with a reason. The bypass is in the audit log."] },
449+ { kv: [["Pushed by", user(d.pusher)], ["Secrets", d.kinds.join(", ")], ["Reason", d.reason], ...(d.refs?.length ? ([["Refs", d.refs.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
450+ { callout: ["If any of these are real credentials, rotate them now: removing them from Git history doesn't make them safe again."], tone: "danger" },
451+ { button: { label: "View repository", url: `${pub}/${d.repo}` } },
452+ ],
453+ context: repoContext(pub, d.repo),
454+ url: `${pub}/${d.repo}`,
455+ };
456+ }
457+ case "push_blocked": {
458+ const d = job.data;
459+ return {
460+ subject: `[${d.repo}] Your push was blocked: possible secrets`,
461+ preheader: `Found ${d.kinds.join(", ")}. Nothing from the push was kept.`,
462+ eyebrow: { text: "Push protection", tone: "red" },
463+ title: ["Your push to ", { a: d.repo, href: `${pub}/${d.repo}` }, " was blocked"],
464+ blocks: [
465+ { p: ["Push protection found what look like secrets in your commits, so nothing from this push was kept."] },
466+ { kv: [["Found", d.kinds.join(", ")], ...(d.refs?.length ? ([["Refs", d.refs.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
467+ { p: ["Remove the secret from your commits (for example with ", { code: "git commit --amend" }, " or an interactive rebase) and push again. If it was a real credential, rotate it. For a test fixture, add a ", { code: "diffinery:allow-secret" }, " comment to the file. A repository admin can push past the check with ", { code: "git push -o allow-secrets=<reason>" }, ", which is audited."] },
468+ ],
469+ context: repoContext(pub, d.repo),
470+ url: `${pub}/${d.repo}`,
471+ };
472+ }
473+ case "account_status":
474+ return job.data.status === "disabled"
475+ ? {
476+ subject: "[Diffinery] Your account was disabled",
477+ preheader: "You can't sign in, and your tokens were revoked.",
478+ eyebrow: { text: "Account", tone: "red" },
479+ title: ["Your Diffinery account was disabled"],
480+ blocks: [{ p: ["A site administrator disabled your account. You can't sign in, and every access token you had was revoked."] }, { p: ["If you think this is a mistake, contact an administrator of this Diffinery instance."], muted: true }],
481+ }
482+ : {
483+ subject: "[Diffinery] Your account was enabled",
484+ preheader: "You can sign in with your passkey again.",
485+ eyebrow: { text: "Account", tone: "green" },
486+ title: ["Your Diffinery account was enabled"],
487+ blocks: [{ p: ["A site administrator enabled your account again. Sign in with your passkey, and create new access tokens: the old ones were revoked when the account was disabled."] }, { button: { label: "Sign in", url: `${pub}/login` } }],
488+ url: `${pub}/login`,
489+ };
490+ case "invite": {
491+ const d = job.data;
492+ const what = d.org ? `join the ${d.org} organization` : d.repo ? `collaborate on ${d.repo}` : "join Diffinery";
493+ return {
494+ subject: d.org ? `@${d.inviter} invited you to join the ${d.org} organization on Diffinery` : d.repo ? `@${d.inviter} invited you to collaborate on ${d.repo}` : `@${d.inviter} invited you to Diffinery`,
495+ preheader: `Accept by ${day(d.expiresAt)}. You'll sign in with a passkey, no password needed.`,
496+ eyebrow: { text: "Invitation", tone: "blue" },
497+ title: [{ b: user(d.inviter) }, ` invited you to ${what}`],
498+ blocks: [
499+ { p: ["Diffinery is where the team keeps its code: Git repositories, pull requests, issues and GitHub Actions–compatible CI, with AI agents as first-class collaborators."] },
500+ { kv: [["Invited by", user(d.inviter)], ...(d.org ? ([["Organization", d.org]] as Array<[string, string]>) : []), ...(d.repo ? ([["Repository", d.repo]] as Array<[string, string]>) : []), ...(d.org || d.repo ? ([["Role", roleText(d.role)]] as Array<[string, string]>) : []), ["Expires", day(d.expiresAt)]] },
501+ { button: { label: "Accept invitation", url: d.url } },
502+ { p: ["You'll pick a username and create a passkey with your fingerprint, face or screen lock. This invitation only works for this email address."], muted: true },
503+ ],
504+ url: d.url,
505+ };
506+ }
507+ case "invite_accepted": {
508+ const d = job.data;
509+ const where = d.org ? `the ${d.org} organization` : d.repo ? d.repo : "Diffinery";
510+ return {
511+ subject: `[Diffinery] @${d.login} accepted your invitation`,
512+ preheader: `${d.email} joined ${where}.`,
513+ eyebrow: { text: "Invitation accepted", tone: "green" },
514+ title: [{ b: user(d.login) }, ` accepted your invitation to ${where}`],
515+ blocks: [{ kv: [["Username", user(d.login)], ["Email", d.email]] }, { button: { label: "View profile", url: `${pub}/${d.login}` } }],
516+ url: `${pub}/${d.login}`,
517+ };
518+ }
519+ case "welcome":
520+ return {
521+ subject: `Welcome to Diffinery, @${job.data.login}`,
522+ preheader: "Your account is ready. Here's how to get going.",
523+ eyebrow: { text: "Welcome", tone: "blue" },
524+ title: ["Welcome to Diffinery, ", { b: user(job.data.login) }],
525+ blocks: [
526+ { p: ["Your account is ready, and you sign in with the passkey you just created. A few things worth doing first:"] },
527+ {
528+ items: [
529+ { mark: "dot", text: [{ b: "Add a second passkey" }, " on another device, or keep it in a password manager that syncs."], url: `${pub}/settings/security` },
530+ { mark: "dot", text: [{ b: "Create an access token" }, " for Git over HTTPS, the CLI and MCP clients."], url: `${pub}/settings/tokens` },
531+ { mark: "dot", text: [{ b: "Create a repository" }, " or push an existing one. Workflows in .github/workflows run as they do on GitHub."], url: `${pub}/new` },
532+ { mark: "dot", text: [{ b: "Choose your notifications" }, ": which emails you get, and which repositories you watch."], url: `${pub}/settings/notifications` },
533+ ],
534+ },
535+ { button: { label: "Go to your dashboard", url: pub } },
536+ ],
537+ url: pub,
538+ };
539+ case "signin_link":
540+ return {
541+ subject: "[Diffinery] Your one-time sign-in link",
542+ preheader: `Add a new passkey before ${day(job.data.expiresAt)}.`,
543+ eyebrow: { text: "Account recovery", tone: "amber" },
544+ title: ["Add a new passkey to your account"],
545+ blocks: [
546+ { p: [user(job.data.admin), " created a one-time link so you can add a new passkey and sign in again. It works once and expires ", at(job.data.expiresAt), "."] },
547+ { button: { label: "Add a passkey", url: job.data.url } },
548+ { callout: ["Anyone with this link can add a passkey to your account. Don't forward this email. If you didn't ask for it, tell a site administrator."], tone: "warn" },
549+ ],
550+ url: job.data.url,
551+ };
552+ case "access_request":
553+ return {
554+ subject: `[Diffinery] ${job.data.email} asked for access`,
555+ preheader: job.data.note ? job.data.note.slice(0, 140) : `@${job.data.login} is waiting for an invite.`,
556+ eyebrow: { text: "Access request", tone: "blue" },
557+ title: [{ b: job.data.email }, " asked for access to Diffinery"],
558+ blocks: [
559+ { kv: [["Username", user(job.data.login)], ["Email", job.data.email], ["When", at(job.at)]] },
560+ ...(job.data.note ? [{ quote: [job.data.note] } as Block] : []),
561+ { button: { label: "Review access requests", url: `${pub}/settings/admin` } },
562+ ],
563+ url: `${pub}/settings/admin`,
564+ };
565+ case "org_membership": {
566+ const d = job.data;
567+ const url = `${pub}/${d.org}`;
568+ const title: Inline[] = !d.role ? ["You were removed from the ", { b: d.org }, " organization"] : d.previous ? ["Your role in ", { b: d.org }, ` changed to ${roleText(d.role)}`] : ["You're now ", article(roleText(d.role)), " ", roleText(d.role), " of ", { b: d.org }];
569+ return {
570+ subject: `[Diffinery] ${inlineText(title)}`,
571+ preheader: d.role ? `You can see ${d.org}'s repositories according to your role.` : `You no longer have access to ${d.org}'s private repositories.`,
572+ eyebrow: { text: "Organization", tone: d.role ? "blue" : "neutral" },
573+ title,
574+ blocks: [{ kv: [["Organization", d.org], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : [])],
575+ context: { label: d.org, url },
576+ url: d.role ? url : undefined,
577+ };
578+ }
579+ case "repo_access": {
580+ const d = job.data;
581+ const url = `${pub}/${d.repo}`;
582+ const title: Inline[] = d.role ? (d.previous ? ["Your access to ", { b: d.repo }, ` changed to ${roleText(d.role)}`] : ["You now have ", roleText(d.role), " access to ", { b: d.repo }]) : ["Your access to ", { b: d.repo }, " was removed"];
583+ return {
584+ subject: `[${d.repo}] ${inlineText(title)}`,
585+ preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's internal or public.",
586+ eyebrow: { text: "Repository access", tone: d.role ? "blue" : "neutral" },
587+ title,
588+ blocks: [{ kv: [["Repository", d.repo], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: "Open repository", url } } as Block] : [])],
589+ context: repoContext(pub, d.repo),
590+ url: d.role ? url : undefined,
591+ };
592+ }
593+ case "repo_created": {
594+ const d = job.data;
595+ const url = `${pub}/${d.repo}`;
596+ const mine = job.reason === "your_activity";
597+ return {
598+ subject: `[${d.repo}] New ${d.visibility} repository`,
599+ preheader: mine ? `Push your code to ${url}.git` : `@${d.creator} created ${d.repo}.`,
600+ eyebrow: { text: "New repository", tone: "green" },
601+ title: mine ? ["Your repository ", { b: d.repo }, " is ready"] : [user(d.creator), " created ", { b: d.repo }],
602+ blocks: [
603+ ...(d.description ? [{ p: [d.description] } as Block] : []),
604+ { kv: [["Repository", [{ a: d.repo, href: url }]], ["Visibility", d.visibility], ["Default branch", d.defaultBranch], ["Created by", user(d.creator)]] },
605+ ...(mine
606+ ? [
607+ { p: ["Push an existing repository from the command line:"] } as Block,
608+ { code: `git remote add origin ${url}.git\ngit push -u origin ${d.defaultBranch}` } as Block,
609+ { p: ["Use an ", { a: "access token", href: `${pub}/settings/tokens` }, " as the password. Workflows in .github/workflows start running on the first push."], muted: true } as Block,
610+ ]
611+ : []),
612+ { button: { label: "Open repository", url } },
613+ ],
614+ context: repoContext(pub, d.repo),
615+ url,
616+ };
617+ }
618+ case "repo_deleted":
619+ return {
620+ subject: `[${job.data.repo}] Repository deleted`,
621+ preheader: `@${job.data.actor} deleted ${job.data.repo}.`,
622+ eyebrow: { text: "Repository deleted", tone: "red" },
623+ title: [user(job.data.actor), " deleted ", { b: job.data.repo }],
624+ blocks: [{ p: ["The repository, its issues, pull requests, workflow runs and settings are gone."] }, { kv: [["Repository", job.data.repo], ["Deleted by", user(job.data.actor)], ["When", at(job.at)]] }],
625+ };
626+ case "test":
627+ return {
628+ subject: "[Diffinery] Test email",
629+ preheader: "Email from Diffinery works.",
630+ eyebrow: { text: "Test", tone: "green" },
631+ title: ["Email from Diffinery works"],
632+ blocks: [{ p: [user(job.data.by), " sent this test email at ", at(job.at), ". It came from Cloudflare Email Sending, signed with DKIM for diffinery.com."] }, { button: { label: "Notification settings", url: `${pub}/settings/notifications` } }],
633+ url: `${pub}/settings/notifications`,
634+ };
635+ }
636+}
addedapps/diffinery/src/mail/types.ts+140−0View file
@@ -1,0 +1,140 @@
1+/**
2+ * Email jobs: what goes on the `diffinery-mail` queue. Every job names one
3+ * recipient and carries everything its template needs, so a retry renders the
4+ * same email. Rendering and the settings check happen when the job is sent.
5+ */
6+
7+/** Why someone gets an email (shown in the footer and the X-Diffinery-Reason header, like GitHub's X-GitHub-Reason). */
8+export type Reason =
9+ | "author"
10+ | "comment"
11+ | "mention"
12+ | "assign"
13+ | "review_requested"
14+ | "manual"
15+ | "state_change"
16+ | "subscribed"
17+ | "ci_activity"
18+ | "security_alert"
19+ | "your_activity"
20+ | "invitation"
21+ | "admin"
22+ | "account";
23+
24+/**
25+ * Settings groups. `security` and `account` always send: they protect the
26+ * account or are the only way to reach someone (invites, sign-in links).
27+ */
28+export type Category = "security" | "account" | "participating" | "watching" | "actions" | "repos" | "orgs";
29+
30+export type Recipient = { accountId: string } | { email: string; name?: string };
31+
32+export interface ThreadData {
33+ repo: string;
34+ repoId: string;
35+ number: number;
36+ kind: "issue" | "pull";
37+ title: string;
38+ event: "opened" | "comment" | "review" | "closed" | "reopened" | "merged" | "assigned" | "review_requested" | "pushed";
39+ actor: string;
40+ /** Markdown: the issue or pull request description, the comment or the review body. */
41+ body?: string;
42+ /** Review state: APPROVED, CHANGES_REQUESTED or COMMENTED. */
43+ state?: string;
44+ stateReason?: string | null;
45+ /** Link to exactly what happened (a comment anchor, the files tab…). */
46+ url: string;
47+ base?: string;
48+ head?: string;
49+ intent?: string | null;
50+ commits?: Array<{ sha: string; message: string }>;
51+ assignees?: string[];
52+ reviewers?: string[];
53+ labels?: string[];
54+}
55+
56+export interface RunData {
57+ repo: string;
58+ repoId: string;
59+ runId: string;
60+ runNumber: number;
61+ workflow: string;
62+ title: string;
63+ conclusion: string;
64+ event: string;
65+ branch: string;
66+ sha: string;
67+ actor: string;
68+ url: string;
69+ jobs: Array<{ name: string; conclusion: string | null; url: string; failure?: string | null }>;
70+}
71+
72+export type MailBody =
73+ | { kind: "signin_new"; data: { method: "passkey" | "access" | "link"; device: string; ip?: string; location?: string; passkey?: string } }
74+ | { kind: "passkey_added"; data: { name: string; via?: "settings" | "signin-link" | "invite" } }
75+ | { kind: "passkey_removed"; data: { name: string } }
76+ | { kind: "token_created"; data: { name: string; scopes: string[]; agent?: string } }
77+ | { kind: "token_expiring"; data: { name: string; prefix: string; expiresAt: string; agent?: string } }
78+ | { kind: "agent_created"; data: { agent: string } }
79+ | { kind: "push_bypassed"; data: { repo: string; pusher: string; kinds: string[]; reason: string; refs?: string[] } }
80+ | { kind: "push_blocked"; data: { repo: string; kinds: string[]; refs?: string[] } }
81+ | { kind: "account_status"; data: { status: "disabled" | "active" } }
82+ | { kind: "invite"; data: { inviter: string; url: string; org?: string; repo?: string; role: string; expiresAt: string } }
83+ | { kind: "invite_accepted"; data: { login: string; email: string; org?: string; repo?: string } }
84+ | { kind: "welcome"; data: { login: string } }
85+ | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } }
86+ | { kind: "access_request"; data: { email: string; login: string; note: string } }
87+ | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null } }
88+ | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
89+ | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } }
90+ | { kind: "repo_deleted"; data: { repo: string; actor: string } }
91+ | { kind: "thread"; data: ThreadData }
92+ | { kind: "run"; data: RunData }
93+ | { kind: "test"; data: { by: string } };
94+
95+export type MailKind = MailBody["kind"];
96+
97+export type MailJob = MailBody & {
98+ to: Recipient;
99+ reason: Reason;
100+ /** When the thing happened (ISO). */
101+ at: string;
102+ /** Send even when the recipient turned this group off (template samples an admin asked for). */
103+ force?: boolean;
104+};
105+
106+/** Which settings group each kind belongs to (threads depend on the reason). */
107+export function categoryOf(job: Pick<MailJob, "kind" | "reason">): Category {
108+ switch (job.kind) {
109+ case "signin_new":
110+ case "passkey_added":
111+ case "passkey_removed":
112+ case "token_created":
113+ case "token_expiring":
114+ case "agent_created":
115+ case "push_bypassed":
116+ case "push_blocked":
117+ case "account_status":
118+ return "security";
119+ case "invite":
120+ case "welcome":
121+ case "signin_link":
122+ case "test":
123+ return "account";
124+ case "invite_accepted":
125+ case "access_request":
126+ case "org_membership":
127+ return "orgs";
128+ case "repo_access":
129+ case "repo_created":
130+ case "repo_deleted":
131+ return "repos";
132+ case "run":
133+ return "actions";
134+ case "thread":
135+ return job.reason === "subscribed" ? "watching" : "participating";
136+ }
137+}
138+
139+/** Kinds that always send, whatever the person's settings. */
140+export const isMandatory = (c: Category): boolean => c === "security" || c === "account";
addedapps/diffinery/src/mail/unsubscribe.ts+55−0View file
@@ -1,0 +1,55 @@
1+import type { Env } from "../env.js";
2+import { b64url, fromB64url, safeEqual } from "../lib/util.js";
3+import type { Category } from "./types.js";
4+
5+/**
6+ * Unsubscribe links work without signing in, so each one carries a signed
7+ * capability: whose settings to change and what to turn off. The key is
8+ * derived from MASTER_KEY, so links stay valid across deploys.
9+ */
10+export type UnsubScope =
11+ /** Stop email for a settings group. */
12+ | { t: "category"; category: Exclude<Category, "security" | "account"> }
13+ /** Mute one issue or pull request. */
14+ | { t: "thread"; repoId: string; number: number; repo: string }
15+ /** Stop watching a repository. */
16+ | { t: "repo"; repoId: string; repo: string };
17+
18+export interface UnsubToken {
19+ accountId: string;
20+ scope: UnsubScope;
21+}
22+
23+async function key(env: Env): Promise<CryptoKey> {
24+ // Local development has no MASTER_KEY unless one is passed; production always does.
25+ const secret = env.MASTER_KEY ?? (env.DEV_AUTH ? "diffinery-dev-unsubscribe" : null);
26+ if (!secret) throw new Error("MASTER_KEY is not set, so unsubscribe links can't be signed");
27+ return crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-unsubscribe-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
28+}
29+
30+async function sign(env: Env, payload: string): Promise<string> {
31+ const sig = await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(payload));
32+ return b64url(new Uint8Array(sig));
33+}
34+
35+export async function unsubToken(env: Env, t: UnsubToken): Promise<string> {
36+ const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope })));
37+ return `${payload}.${await sign(env, payload)}`;
38+}
39+
40+export async function readUnsubToken(env: Env, token: string): Promise<UnsubToken | null> {
41+ const [payload, sig] = token.split(".");
42+ if (!payload || !sig || token.length > 2000) return null;
43+ if (!safeEqual(sig, await sign(env, payload))) return null;
44+ try {
45+ const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope };
46+ if (typeof v.a !== "string" || !v.s || typeof v.s !== "object") return null;
47+ return { accountId: v.a, scope: v.s };
48+ } catch {
49+ return null;
50+ }
51+}
52+
53+export async function unsubUrl(env: Env, t: UnsubToken): Promise<string> {
54+ return `${env.PUBLIC_URL}/-/unsubscribe/${await unsubToken(env, t)}`;
55+}
addedapps/diffinery/src/mail/web.tsx+447−0View file
@@ -1,0 +1,447 @@
1+/** @jsxImportSource hono/jsx */
2+import type { Child, FC } from "hono/jsx";
3+import { requireAccount, type AppContext } from "../auth/principal.js";
4+import type { Account } from "../lib/directory.js";
5+import { loadRepo } from "../http/common.js";
6+import { HttpError, now } from "../lib/util.js";
7+import { SettingsNav, type SettingsTab } from "../web/account.js";
8+import { csrfToken, formData } from "../web/csrf.js";
9+import { KCheckbox, KSelect } from "../web/kumo.js";
10+import { Form, Time, WATCH_OPTIONS, act, flash, post, prm, render, repoUrl, type WebApp } from "../web/ui.js";
11+import { MailStore, WATCH_LEVELS, type EmailLogRow, type NotificationPrefs, type WatchLevel } from "./prefs.js";
12+import { sampleJobs } from "./samples.js";
13+import { buildMessage, deliver, enqueueMail } from "./send.js";
14+import type { Category, MailJob } from "./types.js";
15+import { readUnsubToken, type UnsubScope, type UnsubToken } from "./unsubscribe.js";
16+
17+/**
18+ * Email in the web UI: notification settings, one-click unsubscribe, watching
19+ * repositories and subscribing to threads, and the Email page in site
20+ * administration (delivery status, the log and a gallery of every template).
21+ */
22+
23+const CATEGORY_TEXT: Record<Exclude<Category, "security" | "account">, string> = {
24+ participating: "issues and pull requests you take part in",
25+ watching: "activity in repositories you watch",
26+ actions: "workflow runs",
27+ repos: "repository changes",
28+ orgs: "organization updates",
29+};
30+
31+const settingsPage = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child) =>
32+ render(
33+ c,
34+ { title },
35+ <div class="grid left">
36+ <SettingsNav me={me} tab={tab} />
37+ <div>{body}</div>
38+ </div>,
39+ );
40+
41+const requireUser = (c: AppContext): Account => {
42+ const me = requireAccount(c);
43+ if (me.kind !== "user") throw new HttpError(403, "only people have notification settings");
44+ return me;
45+};
46+
47+const requireSiteAdmin = (c: AppContext): Account => {
48+ const me = requireUser(c);
49+ if (!me.site_admin) throw new HttpError(403, "site administrators only");
50+ return me;
51+};
52+
53+/** One-line description of what an unsubscribe link changes. */
54+function scopeText(s: UnsubScope): string {
55+ if (s.t === "thread") return `Mute ${s.repo}#${s.number}`;
56+ if (s.t === "repo") return `Stop watching ${s.repo}`;
57+ return `Turn off email about ${CATEGORY_TEXT[s.category] ?? s.category}`;
58+}
59+
60+const StatusBadge: FC<{ s: EmailLogRow["status"] }> = ({ s }) => <span class={s === "sent" ? "ok" : s === "failed" ? "bad" : "faint"}>{s}</span>;
61+
62+const EmailLog: FC<{ rows: EmailLogRow[]; showTo?: boolean }> = ({ rows, showTo }) =>
63+ rows.length ? (
64+ <div class="box" style="overflow:auto">
65+ <table class="tbl">
66+ <thead>
67+ <tr>
68+ <th>When</th>
69+ {showTo ? <th>To</th> : null}
70+ <th>Email</th>
71+ <th>Status</th>
72+ </tr>
73+ </thead>
74+ <tbody>
75+ {rows.map((r) => (
76+ <tr>
77+ <td class="small">
78+ <Time at={r.created_at} />
79+ </td>
80+ {showTo ? <td class="small">{r.to_addr || "—"}</td> : null}
81+ <td>
82+ <div>{r.subject || r.kind}</div>
83+ <div class="muted small">
84+ {r.kind}
85+ {r.message_id ? ` · ${r.message_id}` : ""}
86+ </div>
87+ </td>
88+ <td class="small">
89+ <StatusBadge s={r.status} />
90+ {r.detail ? <div class="muted small">{r.detail}</div> : null}
91+ </td>
92+ </tr>
93+ ))}
94+ </tbody>
95+ </table>
96+ </div>
97+ ) : (
98+ <p class="muted">No emails yet.</p>
99+ );
100+
101+/** The four-column settings grid: what, on Diffinery, by email. */
102+const PrefRow: FC<{ title: string; hint: string; web?: { name: string; on: boolean }; email: { name: string; on: boolean; locked?: boolean }; extra?: Child }> = (p) => (
103+ <tr>
104+ <td>
105+ <b>{p.title}</b>
106+ <div class="muted small">{p.hint}</div>
107+ {p.extra}
108+ </td>
109+ <td>{p.web ? <KCheckbox name={p.web.name} checked={p.web.on} aria={`${p.title} on Diffinery`} /> : <span class="faint">—</span>}</td>
110+ <td>
111+ <KCheckbox name={p.email.name} checked={p.email.on} disabled={p.email.locked} aria={`${p.title} by email`} />
112+ </td>
113+ </tr>
114+);
115+
116+async function notificationsPage(c: AppContext, me: Account): Promise<Response> {
117+ const store = new MailStore(c.env.DB);
118+ const dir = c.get("dir");
119+ const t = csrfToken(c);
120+ const [p, watches, recent] = await Promise.all([store.prefs(me.id), store.watchedRepos(me.id), store.recent({ accountId: me.id, limit: 20 })]);
121+ const repos = (await Promise.all(watches.map(async (w) => ({ level: w.level, repo: await dir.repoById(w.repo_id) })))).filter((w) => w.repo);
122+ return settingsPage(
123+ c,
124+ me,
125+ "notifications",
126+ "Notifications",
127+ <>
128+ <h1>Notifications</h1>
129+ <p class="muted">
130+ Emails go to <b>{me.email ?? "no address"}</b> from {c.env.MAIL_FROM || "notifications@diffinery.com"}. Security and account emails always send.
131+ </p>
132+ <Form action="/settings/notifications" t={t} class="box">
133+ <table class="tbl">
134+ <thead>
135+ <tr>
136+ <th>Notify me about</th>
137+ <th>On Diffinery</th>
138+ <th>Email</th>
139+ </tr>
140+ </thead>
141+ <tbody>
142+ <PrefRow
143+ title="Participating"
144+ hint="Issues and pull requests you opened, commented on, reviewed, were assigned, asked to review, @mentioned in or subscribed to."
145+ web={{ name: "participating.web", on: p.participating.web }}
146+ email={{ name: "participating.email", on: p.participating.email }}
147+ />
148+ <PrefRow title="Watching" hint="Everything else in repositories you watch with All activity." web={{ name: "watching.web", on: p.watching.web }} email={{ name: "watching.email", on: p.watching.email }} />
149+ <PrefRow
150+ title="Actions"
151+ hint="Workflow runs you started, and scheduled runs in repositories you watch."
152+ web={{ name: "actions.web", on: p.actions.web }}
153+ email={{ name: "actions.email", on: p.actions.email }}
154+ extra={
155+ <div style="margin-top:6px">
156+ <KCheckbox name="actions.failuresOnly" checked={p.actions.failuresOnly} label="Only failed runs" />
157+ </div>
158+ }
159+ />
160+ <PrefRow title="Repositories" hint="Repositories created or deleted in your organizations, and access granted to you." email={{ name: "repos.email", on: p.repos.email }} />
161+ <PrefRow title="Organizations" hint="Your organization role, accepted invitations and access requests (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} />
162+ <PrefRow title="Security and account" hint="New sign-ins, passkeys, tokens, agents, push protection, invitations and sign-in links." email={{ name: "security", on: true, locked: true }} />
163+ </tbody>
164+ </table>
165+ <div class="body">
166+ <div class="kstack">
167+ <KCheckbox name="ownActivity" checked={p.ownActivity} label="Email me about my own comments and changes" />
168+ <KCheckbox name="autoWatch" checked={p.autoWatch} label="Watch repositories I create (All activity)" />
169+ </div>
170+ <button class="btn primary">Save notification settings</button>
171+ </div>
172+ </Form>
173+
174+ <h2 class="mt">Watched repositories</h2>
175+ {repos.length ? (
176+ <div class="box">
177+ <table class="tbl">
178+ <tbody>
179+ {repos.map(({ repo, level }) => (
180+ <tr>
181+ <td>
182+ <a href={repoUrl(repo!)}>
183+ {repo!.owner_login}/{repo!.name}
184+ </a>
185+ </td>
186+ <td style="text-align:right">
187+ <Form action={`${repoUrl(repo!)}/subscription`} t={t} class="inline">
188+ <input type="hidden" name="back" value="/settings/notifications" />
189+ <KSelect name="level" options={WATCH_OPTIONS} value={level} size="sm" label={`Watch ${repo!.owner_login}/${repo!.name}`} autosubmit />
190+ </Form>
191+ </td>
192+ </tr>
193+ ))}
194+ </tbody>
195+ </table>
196+ </div>
197+ ) : (
198+ <p class="muted">You aren't watching or ignoring any repository. Use the Watch menu at the top of a repository to change that.</p>
199+ )}
200+
201+ <h2 class="mt">Recent emails to you</h2>
202+ <Form action="/settings/notifications/test" t={t} class="mb">
203+ <button class="btn sm">Send me a test email</button>
204+ </Form>
205+ <EmailLog rows={recent} />
206+ </>,
207+ );
208+}
209+
210+function prefsFromForm(f: { b(name: string): boolean }): NotificationPrefs {
211+ return {
212+ participating: { web: f.b("participating.web"), email: f.b("participating.email") },
213+ watching: { web: f.b("watching.web"), email: f.b("watching.email") },
214+ actions: { web: f.b("actions.web"), email: f.b("actions.email"), failuresOnly: f.b("actions.failuresOnly") },
215+ repos: { email: f.b("repos.email") },
216+ orgs: { email: f.b("orgs.email") },
217+ ownActivity: f.b("ownActivity"),
218+ autoWatch: f.b("autoWatch"),
219+ };
220+}
221+
222+/** Send a test email now (not through the queue) so the result shows right away. */
223+export async function sendTest(c: AppContext, me: Account) {
224+ return deliver(c.env, { kind: "test", data: { by: me.login }, to: { accountId: me.id }, reason: "account", at: now() });
225+}
226+
227+async function readToken(c: AppContext): Promise<{ t: UnsubToken; account: Account } | null> {
228+ const t = await readUnsubToken(c.env, prm(c, "token")).catch(() => null);
229+ if (!t) return null;
230+ const account = await c.get("dir").account(t.accountId);
231+ if (!account || account.status === "disabled") return null;
232+ return { t, account };
233+}
234+
235+async function applyUnsub(c: AppContext, t: UnsubToken, undo = false): Promise<void> {
236+ const store = new MailStore(c.env.DB);
237+ const s = t.scope;
238+ if (s.t === "category") {
239+ if (!(s.category in CATEGORY_TEXT)) throw new HttpError(400, "unknown kind of email");
240+ const p = await store.prefs(t.accountId);
241+ (p[s.category] as { email: boolean }).email = undo;
242+ await store.setPrefs(t.accountId, p);
243+ } else if (s.t === "thread") {
244+ if (undo) await store.clearThreadSub(t.accountId, s.repoId, s.number);
245+ else await store.setThreadSub(t.accountId, s.repoId, s.number, false);
246+ } else if (s.t === "repo") {
247+ await store.setWatch(t.accountId, s.repoId, undo ? "all" : "participating");
248+ }
249+}
250+
251+const UnsubPage: FC<{ title: string; children?: Child }> = ({ title, children }) => (
252+ <div style="max-width:560px;margin:40px auto">
253+ <div class="box">
254+ <div class="body">
255+ <h1 style="margin-top:0">{title}</h1>
256+ {children}
257+ </div>
258+ </div>
259+ </div>
260+);
261+
262+export function registerMailWeb(web: WebApp): void {
263+ // ------------------------------------------------------------ settings
264+
265+ web.get("/settings/notifications", async (c) => notificationsPage(c, requireUser(c)));
266+
267+ post(web, "/settings/notifications", async (c, f) => {
268+ const me = requireUser(c);
269+ return act(c, "/settings/notifications", () => new MailStore(c.env.DB).setPrefs(me.id, prefsFromForm(f)), "Notification settings saved.");
270+ });
271+
272+ post(web, "/settings/notifications/test", async (c) => {
273+ const me = requireUser(c);
274+ const r = await sendTest(c, me);
275+ if (r.status === "sent") flash(c, "notice", `Test email sent to ${me.email}.`);
276+ else flash(c, "error", `The test email was ${r.status}: ${r.detail ?? "unknown reason"}`);
277+ return c.redirect("/settings/notifications", 303);
278+ });
279+
280+ // ------------------------------------------------------------ unsubscribe (no sign-in: the signed link is the permission)
281+
282+ web.get("/-/unsubscribe/:token", async (c) => {
283+ const x = await readToken(c);
284+ if (!x) return render(c, { title: "Unsubscribe", status: 404 }, <UnsubPage title="This link doesn't work">
285+ <p>It may be incomplete or the account no longer exists. You can change what you get by email in <a href="/settings/notifications">notification settings</a>.</p>
286+ </UnsubPage>);
287+ return render(
288+ c,
289+ { title: "Unsubscribe" },
290+ <UnsubPage title={scopeText(x.t.scope)}>
291+ <p class="muted">
292+ For <b>{x.account.login}</b> ({x.account.email}).
293+ </p>
294+ <form method="post" action={c.req.path}>
295+ <button class="btn primary">{scopeText(x.t.scope)}</button>
296+ </form>
297+ <p class="muted small mt">
298+ Or choose exactly what you get in <a href="/settings/notifications">notification settings</a>.
299+ </p>
300+ </UnsubPage>,
301+ );
302+ });
303+
304+ // Mail apps send `List-Unsubscribe=One-Click` here (RFC 8058); the button above posts an empty form.
305+ web.post("/-/unsubscribe/:token", async (c) => {
306+ const x = await readToken(c);
307+ const form = await formData(c).catch(() => null);
308+ const oneClick = form?.s("List-Unsubscribe").toLowerCase() === "one-click";
309+ if (!x) return oneClick ? c.text("invalid link", 404) : c.redirect(c.req.path, 303);
310+ await applyUnsub(c, x.t);
311+ if (oneClick) return c.text("Unsubscribed.");
312+ return render(
313+ c,
314+ { title: "Unsubscribed" },
315+ <UnsubPage title="Done">
316+ <p>{scopeText(x.t.scope)}: done. You won't get these emails any more.</p>
317+ <form method="post" action={`${c.req.path}/undo`} class="inline">
318+ <button class="btn">Undo</button>
319+ </form>{" "}
320+ <a class="btn" href="/settings/notifications">
321+ Notification settings
322+ </a>
323+ </UnsubPage>,
324+ );
325+ });
326+
327+ web.post("/-/unsubscribe/:token/undo", async (c) => {
328+ const x = await readToken(c);
329+ if (!x) return c.redirect(`/-/unsubscribe/${prm(c, "token")}`, 303);
330+ await applyUnsub(c, x.t, true);
331+ return render(
332+ c,
333+ { title: "Undone" },
334+ <UnsubPage title="Undone">
335+ <p>You'll get these emails again.</p>
336+ <a class="btn" href="/settings/notifications">
337+ Notification settings
338+ </a>
339+ </UnsubPage>,
340+ );
341+ });
342+
343+ // ------------------------------------------------------------ watching and thread subscriptions
344+
345+ post(web, "/:owner/:repo/subscription", async (c, f) => {
346+ const me = requireUser(c);
347+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
348+ const back = f.s("back") === "/settings/notifications" ? "/settings/notifications" : repoUrl(l.repo);
349+ const level = WATCH_LEVELS.find((x) => x === f.s("level")) as WatchLevel | undefined;
350+ if (!level) throw new HttpError(422, "pick participating, all or ignore");
351+ const label = WATCH_OPTIONS.find((o) => o.value === level)!.label;
352+ return act(c, back, () => new MailStore(c.env.DB).setWatch(me.id, l.repo.id, level), `Watching ${l.repo.owner_login}/${l.repo.name}: ${label}.`);
353+ });
354+
355+ post(web, "/:owner/:repo/issues/:number/subscription", async (c, f) => {
356+ const me = requireUser(c);
357+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
358+ const n = Number(prm(c, "number"));
359+ if (!Number.isInteger(n) || n < 1) throw new HttpError(404, "not found");
360+ const on = f.s("subscribed") === "1";
361+ return act(c, `${repoUrl(l.repo)}/issues/${n}`, () => new MailStore(c.env.DB).setThreadSub(me.id, l.repo.id, n, on), on ? "Subscribed. You'll be notified about all activity." : "Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review.");
362+ });
363+
364+ // ------------------------------------------------------------ site administration: Email
365+
366+ web.get("/settings/admin/email", async (c) => {
367+ const me = requireSiteAdmin(c);
368+ const store = new MailStore(c.env.DB);
369+ const t = csrfToken(c);
370+ const since = new Date(Date.now() - 86400_000).toISOString();
371+ const [counts, rows] = await Promise.all([store.counts(since), store.recent({ limit: 100 })]);
372+ const samples = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login);
373+ const pick = samples.find((s) => s.id === c.req.query("sample")) ?? samples[0]!;
374+ return settingsPage(
375+ c,
376+ me,
377+ "email",
378+ "Email",
379+ <>
380+ <h1>Email</h1>
381+ <div class="box">
382+ <div class="body">
383+ <dl class="kv m0">
384+ <dt>Sending</dt>
385+ <dd>{c.env.EMAIL ? <span class="ok">Cloudflare Email Sending is bound</span> : <span class="bad">not configured (no EMAIL binding): emails are logged, not sent</span>}</dd>
386+ <dt>Queue</dt>
387+ <dd>{c.env.MAIL ? "diffinery-mail" : <span class="bad">not bound: emails are dropped</span>}</dd>
388+ <dt>From</dt>
389+ <dd>{c.env.MAIL_FROM || "notifications@diffinery.com"}</dd>
390+ <dt>Last 24 hours</dt>
391+ <dd>
392+ <span class="ok">{counts.sent} sent</span> · <span class={counts.failed ? "bad" : "faint"}>{counts.failed} failed</span> · <span class="faint">{counts.skipped} skipped</span>
393+ </dd>
394+ </dl>
395+ </div>
396+ </div>
397+ <div class="row mt mb">
398+ <Form action="/settings/notifications/test" t={t} class="inline">
399+ <button class="btn sm">Send me a test email</button>
400+ </Form>
401+ <Form action="/settings/admin/email/samples" t={t} class="inline" confirm={`Send all ${samples.length} templates to ${me.email}?`}>
402+ <button class="btn sm">Send every template to me</button>
403+ </Form>
404+ </div>
405+
406+ <h2>Templates</h2>
407+ <form method="get" action="/settings/admin/email" class="row mb">
408+ <KSelect name="sample" options={samples.map((s) => ({ value: s.id, label: s.label }))} value={pick.id} label="Template" autosubmit />
409+ <a class="btn sm" href={`/settings/admin/email/preview/${pick.id}`} target="_blank" rel="noopener">
410+ Open HTML
411+ </a>
412+ <a class="btn sm" href={`/settings/admin/email/preview/${pick.id}?format=text`} target="_blank" rel="noopener">
413+ Plain text
414+ </a>
415+ </form>
416+ <iframe src={`/settings/admin/email/preview/${pick.id}`} title={`Preview: ${pick.label}`} style="width:100%;height:720px;border:1px solid var(--border);border-radius:8px;background:#fff" sandbox="" />
417+
418+ <h2 class="mt">Recent emails</h2>
419+ <EmailLog rows={rows} showTo />
420+ </>,
421+ );
422+ });
423+
424+ web.get("/settings/admin/email/preview/:id", async (c) => {
425+ const me = requireSiteAdmin(c);
426+ const s = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).find((x) => x.id === c.req.param("id"));
427+ if (!s) throw new HttpError(404, "no such template");
428+ const msg = await buildMessage(c.env, s.job, { email: me.email ?? "you@example.com", name: me.name ?? undefined, login: me.login, accountId: me.id });
429+ c.header("content-security-policy", "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'");
430+ c.header("x-frame-options", "SAMEORIGIN");
431+ c.header("x-content-type-options", "nosniff");
432+ c.header("cache-control", "private, no-store");
433+ if (c.req.query("format") === "text") {
434+ const head = [`From: ${msg.from.name} <${msg.from.email}>`, `Subject: ${msg.subject}`, ...Object.entries(msg.headers).map(([k, v]) => `${k}: ${v}`)].join("\n");
435+ return c.text(`${head}\n\n${msg.text}`);
436+ }
437+ return c.html(msg.html);
438+ });
439+
440+ post(web, "/settings/admin/email/samples", async (c) => {
441+ const me = requireSiteAdmin(c);
442+ const jobs = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).map((s) => ({ ...s.job, force: true }) as MailJob);
443+ await enqueueMail(c.env, jobs);
444+ flash(c, "notice", `Queued ${jobs.length} emails to ${me.email}. They show up under Recent emails as they're sent.`);
445+ return c.redirect("/settings/admin/email", 303);
446+ });
447+}
modifiedapps/diffinery/src/repo/RepoDO.ts+2−2View file
@@ -255,14 +255,14 @@
255255 if (reason && caller.role === "admin" && caller.kind === "user") {
256256 messages.push(`Push protection bypassed by ${caller.login}: ${reason}`);
257257 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
258- this.emit({ type: "audit", action: "push.secret_bypassed", actor: caller, target, detail: { kinds, reason }, at: now() });
258+ this.emit({ type: "audit", action: "push.secret_bypassed", actor: caller, target, detail: { kinds, reason, refs: req.commands.map((c) => c.ref) }, at: now() });
259259 } else {
260260 messages.push("Push blocked: possible secrets detected.");
261261 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
262262 messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
263263 if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection.");
264264 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
265- this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds }, at: now() });
265+ this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds, refs: req.commands.map((c) => c.ref) }, at: now() });
266266 return reportStatus(req, null, results, messages);
267267 }
268268 }
modifiedapps/diffinery/src/repo/collab.ts+14−0View file
@@ -301,6 +301,14 @@
301301 if (action === "closed" && this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
302302 } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t });
303303 }
304+ const before = new Set((JSON.parse(r.assignees) as string[]).map((x) => x.toLowerCase()));
305+ const added = (patch.assignees ?? []).filter((x) => !before.has(x.toLowerCase()));
306+ if (added.length) {
307+ if (r.kind === "pull") {
308+ const p = this.pullRow(number);
309+ this.repo.emit({ type: "pull_request", action: "assigned", repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t, assignees: added });
310+ } else this.repo.emit({ type: "issues", action: "assigned", repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t, assignees: added });
311+ }
304312 this.repo.broadcast(`pr:${number}`, { action: "updated" });
305313 return this.toIssue(this.row(number));
306314 }
@@ -413,6 +421,12 @@
413421 const p = this.pullRow(number);
414422 const set = new Set<string>([...JSON.parse(p.requested_reviewers), ...reviewers]);
415423 this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify([...set]), number);
424+ const before = new Set((JSON.parse(p.requested_reviewers) as string[]).map((x) => x.toLowerCase()));
425+ const added = [...new Set(reviewers)].filter((x) => !before.has(x.toLowerCase()));
426+ if (added.length) {
427+ const m = this.repo.meta;
428+ this.repo.emit({ type: "pull_request", action: "review_requested", repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: now(), reviewers: added });
429+ }
416430 }
417431
418432 /** Called after a branch moves: refresh open pull requests whose head is that branch. */
modifiedapps/diffinery/src/web/account.tsx+13−6View file
@@ -10,6 +10,7 @@
1010 import { KCheckbox, KRadio, KSelect } from "./kumo.js";
1111 import { csrfToken } from "./csrf.js";
1212 import { Auth } from "../auth/sessions.js";
13+import { repoCreated } from "../mail/notify.js";
1314 import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js";
1415 import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
1516
@@ -310,6 +311,7 @@
310311 const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private";
311312 const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" });
312313 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` }));
314+ c.executionCtx.waitUntil(repoCreated(c.env, dir, repo, me, { viaToken: false }));
313315 return c.redirect(repoUrl(repo), 303);
314316 } catch (e) {
315317 if (!(e instanceof HttpError) || e.status >= 500) throw e;
@@ -492,9 +494,9 @@
492494
493495 // ---------------------------------------------------------------- /settings
494496
495-type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "admin" | "audit";
497+export type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit";
496498
497-const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
499+export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
498500 <aside class="side">
499501 <section>
500502 <div class="row mb">
@@ -520,12 +522,17 @@
520522 <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}>
521523 Organizations
522524 </a>
523- <a href="/notifications">Notifications</a>
525+ <a href="/settings/notifications" class={tab === "notifications" ? "on" : ""}>
526+ Notifications
527+ </a>
524528 {me.site_admin ? (
525529 <>
526530 <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
527531 Site administration
528532 </a>
533+ <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}>
534+ Email
535+ </a>
529536 <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
530537 Audit log
531538 </a>
@@ -1253,7 +1260,7 @@
12531260 if (target.status === "disabled") throw new HttpError(422, "enable this account first");
12541261 const link = await new Auth(c.env).createSigninLink(me, target);
12551262 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login }));
1256- return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: "Send this only to them. It works once within 24 hours and adds a passkey to their account." });
1263+ return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: `We emailed it to ${target.email ?? "them"}; you can also send it yourself. Send it only to them: it works once within 24 hours and adds a passkey to their account.` });
12571264 });
12581265
12591266 post(web, "/settings/admin/invite", async (c, f) => {
@@ -1262,7 +1269,7 @@
12621269 if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
12631270 const inv = await c.get("dir").createInvite(me, { email, role: "read" });
12641271 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email }));
1265- return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: "Send them this link. It expires in 14 days and works once: they pick a username and create a passkey." });
1272+ return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: `We emailed it to ${email}; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey.` });
12661273 });
12671274
12681275 web.get("/settings/audit", async (c) => {
@@ -1538,7 +1545,7 @@
15381545 <div class="box" style="border-color:var(--amber)">
15391546 <div class="body">
15401547 <h3>Invite created for {invite.email}</h3>
1541- <p class="muted">Send them this link. It expires in 14 days. Opening it lets them create a passkey and join.</p>
1548+ <p class="muted">We emailed this link to them; you can also send it yourself. It expires in 14 days. Opening it lets them create a passkey and join.</p>
15421549 <div class="row">
15431550 <div class="secret grow">{invite.url}</div>
15441551 <CopyButton text={invite.url} />
modifiedapps/diffinery/src/web/auth.tsx+12−5View file
@@ -49,6 +49,13 @@
4949 return [os, browser].filter(Boolean).join(" · ") || "Passkey";
5050 }
5151
52+/** Where a sign-in came from, for the audit log and the "new sign-in" email. */
53+export function signinContext(c: AppContext): { device: string; ip?: string; location?: string } {
54+ const cf = (c.req.raw as { cf?: { city?: string; region?: string; country?: string } }).cf;
55+ const location = [cf?.city, cf?.country].filter(Boolean).join(", ") || undefined;
56+ return { device: deviceLabel(c.req.header("user-agent")), ip: c.req.header("cf-connecting-ip") ?? undefined, location };
57+}
58+
5259 async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> {
5360 const old = getCookie(c, SESSION_COOKIE);
5461 if (old) await auth.endSession(old);
@@ -262,7 +269,7 @@
262269 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
263270 await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp);
264271 await startSession(c, auth, account.id, "passkey", key.id);
265- c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name } }));
272+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name, passkeyId: key.id, ...signinContext(c) } }));
266273 return c.json({ redirect: safeNext(body.next) });
267274 });
268275
@@ -348,7 +355,7 @@
348355 const me = browserAccount(c);
349356 if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey.");
350357 await auth.addPasskey({ ...key, account_id: me.id });
351- c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label } }));
358+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label, passkeyId: key.id, ...signinContext(c) } }));
352359 flash(c, "notice", `Passkey "${label}" added.`);
353360 return c.json({ redirect: "/settings/security" });
354361 } else if (s.kind === "invite") {
@@ -359,7 +366,7 @@
359366 // Creating the account accepts every open invite for this email, including this one.
360367 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
361368 await auth.addPasskey({ ...key, account_id: account.id });
362- c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey" } }));
369+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } }));
363370 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
364371 redirect = "/";
365372 } else if (s.kind === "link") {
@@ -368,7 +375,7 @@
368375 if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled.");
369376 account = found;
370377 await auth.addPasskey({ ...key, account_id: account.id });
371- c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link" } }));
378+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link", passkeyId: key.id, ...signinContext(c) } }));
372379 flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`);
373380 redirect = "/settings/security";
374381 } else {
@@ -405,7 +412,7 @@
405412 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
406413 const auth = new Auth(c.env);
407414 await startSession(c, auth, account.id, "access", null);
408- c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access" } }));
415+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access", ...signinContext(c) } }));
409416 if (account.status === "pending") return c.redirect("/", 302);
410417 const keys = await auth.passkeys(account.id);
411418 if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302);
modifiedapps/diffinery/src/web/collab.tsx+58−1View file
@@ -9,6 +9,8 @@
99 import { ICONS } from "./assets.js";
1010 import { KCheckbox, KRadio, KSelect } from "./kumo.js";
1111 import { csrfToken } from "./csrf.js";
12+import { MailStore, type WatchLevel } from "../mail/prefs.js";
13+import { mentions } from "../mail/notify.js";
1214 import {
1315 Actor,
1416 CommitRow,
@@ -37,6 +39,7 @@
3739 type LineThread,
3840 type WebApp,
3941 prm,
42+ principalAccount,
4043 } from "./ui.js";
4144
4245 type Loaded = Awaited<ReturnType<typeof loadRepo>>;
@@ -246,6 +249,8 @@
246249 reviews: Review[];
247250 labels: LabelDef[];
248251 behalf: Map<string, string>;
252+ /** The signed-in person's thread subscription (explicit choice, if any) and repository watch level. */
253+ sub?: { explicit: boolean | null; watch: WatchLevel };
249254 }
250255
251256 const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string }> = ({ x, author, at, body, verb, id, extra, cls }) => (
@@ -397,6 +402,50 @@
397402 );
398403 };
399404
405+/** Why the signed-in person gets notifications for this thread, and a button to change it (like GitHub's sidebar). */
406+const SubscriptionBox: FC<{ x: ConvCtx; sub: NonNullable<ConvCtx["sub"]> }> = ({ x, sub }) => {
407+ const me = principalAccount(x.c)!;
408+ const i = x.issue;
409+ const why =
410+ i.author.login === me.login
411+ ? "you authored it"
412+ : i.assignees.includes(me.login)
413+ ? "you were assigned"
414+ : i.pull?.requestedReviewers.includes(me.login)
415+ ? "your review was requested"
416+ : x.reviews.some((r) => r.author.login === me.login)
417+ ? "you reviewed it"
418+ : x.comments.some((c) => c.author.login === me.login)
419+ ? "you commented"
420+ : mentions([i.body, ...x.comments.map((c) => c.body)].join("\n")).includes(me.login.toLowerCase())
421+ ? "you were mentioned"
422+ : sub.watch === "all"
423+ ? "you're watching this repository"
424+ : null;
425+ const on = sub.explicit ?? (why !== null && sub.watch !== "ignore");
426+ const what = i.kind === "pull" ? "pull request" : "issue";
427+ return (
428+ <section>
429+ <h3>Notifications</h3>
430+ <Form action={`${repoUrl(x.l.repo)}/issues/${i.number}/subscription`} t={csrfToken(x.c)}>
431+ <input type="hidden" name="subscribed" value={on ? "0" : "1"} />
432+ <button class="btn sm" style="width:100%">
433+ {raw(ICONS.bell)} {on ? "Unsubscribe" : "Subscribe"}
434+ </button>
435+ </Form>
436+ <p class="muted small" style="margin:6px 0 0">
437+ {sub.explicit === true
438+ ? `You're subscribed to this ${what}.`
439+ : sub.explicit === false
440+ ? `You muted this ${what}. You'll still hear about it if you're @mentioned, assigned or asked to review.`
441+ : on
442+ ? `You're receiving notifications because ${why}.`
443+ : `You're not receiving notifications for this ${what}.`}
444+ </p>
445+ </section>
446+ );
447+};
448+
400449 const Sidebar: FC<{ x: ConvCtx }> = ({ x }) => {
401450 const base = repoUrl(x.l.repo);
402451 const t = csrfToken(x.c);
@@ -488,6 +537,7 @@
488537 </details>
489538 ) : null}
490539 </section>
540+ {x.sub ? <SubscriptionBox x={x} sub={x.sub} /> : null}
491541 <section>
492542 <h3>Participants</h3>
493543 <div class="row" style="gap:4px">
@@ -859,7 +909,14 @@
859909 const n = issueNumber(c);
860910 const [data, labels] = await Promise.all([l.stub.issue(n), l.stub.labels()]);
861911 const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]);
862- return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf };
912+ const me = principalAccount(c);
913+ let sub: ConvCtx["sub"];
914+ if (me?.kind === "user") {
915+ const store = new MailStore(c.env.DB);
916+ const [explicit, watch] = await Promise.all([store.threadSub(me.id, l.repo.id, n), store.watchLevel(me.id, l.repo.id)]);
917+ sub = { explicit, watch };
918+ }
919+ return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf, sub };
863920 }
864921
865922 async function issuePage(c: AppContext): Promise<Response> {
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
@@ -3,6 +3,7 @@
33 import type { Env } from "../env.js";
44 import type { AppVars } from "../auth/principal.js";
55 import { registerAccount } from "./account.js";
6+import { registerMailWeb } from "../mail/web.js";
67 import { registerAuth } from "./auth.js";
78 import { registerActions } from "./actions.js";
89 import { registerCode } from "./code.js";
@@ -33,6 +34,7 @@
3334 registerKumoAssets(web);
3435 registerAuth(web);
3536 registerPackages(web);
37+registerMailWeb(web);
3638 registerAccount(web);
3739 registerRepoSettings(web);
3840 registerActions(web);
modifiedapps/diffinery/src/web/ui.tsx+35−3View file
@@ -12,7 +12,8 @@
1212 import { CSS, ICONS, JS, type IconName } from "./assets.js";
1313 import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
1414 import { renderMarkdown, type MarkdownBase } from "./markdown.js";
15-import { kumoAssets } from "./kumo.js";
15+import { KSelect, kumoAssets } from "./kumo.js";
16+import { MailStore, type WatchLevel } from "../mail/prefs.js";
1617 import { SeoHead, SITE } from "./seo.js";
1718
1819 export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -262,13 +263,32 @@
262263 home?: boolean;
263264 }
264265
265-const RepoHeader: FC<{ r: RepoCtx }> = ({ r }) => {
266+export const WATCH_OPTIONS = [
267+ { value: "participating", label: "Participating and @mentions" },
268+ { value: "all", label: "All activity" },
269+ { value: "ignore", label: "Ignore" },
270+];
271+
272+/** Watch / unwatch control in the repository header. */
273+const WatchControl: FC<{ base: string; level: WatchLevel; t: string }> = ({ base, level, t }) => (
274+ <form method="post" action={`${base}/subscription`} class="inline" title="Which activity in this repository notifies you">
275+ <input type="hidden" name={CSRF_FIELD} value={t} />
276+ <KSelect name="level" options={WATCH_OPTIONS.map((o) => ({ value: o.value, label: o.value === "participating" ? "Watch: participating" : o.value === "all" ? "Watch: all activity" : "Watch: ignore" }))} value={level} size="sm" label="Watch" autosubmit />
277+ </form>
278+);
279+
280+const RepoHeader: FC<{ r: RepoCtx; watch?: WatchLevel; t: string }> = ({ r, watch, t }) => {
266281 const base = repoUrl(r.repo);
267282 const tab = r.tab ?? "";
268283 const vis = r.repo.visibility;
269284 return (
270285 <div class="repohead">
271286 <div class="wrap">
287+ {watch ? (
288+ <div style="float:right">
289+ <WatchControl base={base} level={watch} t={t} />
290+ </div>
291+ ) : null}
272292 <h1>
273293 <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span>
274294 <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a>
@@ -325,6 +345,18 @@
325345
326346 /** Render a full page. */
327347 export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> {
348+ const p = c.get("principal");
349+ const me = p?.type === "account" ? p.account : null;
350+ if (opts.repo && me?.status === "active" && me.kind === "user" && c.env.DB) {
351+ return new MailStore(c.env.DB)
352+ .watchLevel(me.id, opts.repo.repo.id)
353+ .catch(() => "participating" as const)
354+ .then((w) => renderPage(c, opts, body, w));
355+ }
356+ return renderPage(c, opts, body);
357+}
358+
359+function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Response | Promise<Response> {
328360 csrfToken(c);
329361 const nonce = randomToken(16);
330362 const fl = takeFlash(c);
@@ -404,7 +436,7 @@
404436 </nav>
405437 </div>
406438 </header>
407- {r ? <RepoHeader r={r} /> : null}
439+ {r ? <RepoHeader r={r} watch={watch} t={csrfToken(c)} /> : null}
408440 <main class="wrap" style={opts.wide ? "max-width:none" : undefined}>
409441 {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null}
410442 {body}
addedapps/diffinery/test/e2e/email.test.mjs+257−0View file
@@ -1,0 +1,257 @@
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) muting a
10+// thread while mentions still get through, and failed workflow run emails.
11+import { spawn, execFileSync } from "node:child_process";
12+import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
13+import { tmpdir } from "node:os";
14+import { randomBytes } from "node:crypto";
15+import { join, dirname, resolve } from "node:path";
16+import { fileURLToPath } from "node:url";
17+import { createServer } from "node:net";
18+import { test, before, after } from "node:test";
19+import assert from "node:assert/strict";
20+
21+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
22+const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
23+const work = mkdtempSync(join(tmpdir(), "diffinery-email-e2e-"));
24+let base;
25+let dev;
26+let devLog = "";
27+let token;
28+let me;
29+
30+const freePort = () =>
31+ new Promise((res) => {
32+ const s = createServer();
33+ s.listen(0, "127.0.0.1", () => {
34+ const { port } = s.address();
35+ s.close(() => res(port));
36+ });
37+ });
38+
39+async function api(method, path, body, tok = token) {
40+ const res = await fetch(`${base}/api/v3${path}`, {
41+ method,
42+ headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
43+ body: body === undefined ? undefined : JSON.stringify(body),
44+ });
45+ const text = await res.text();
46+ let json = null;
47+ try {
48+ json = text ? JSON.parse(text) : null;
49+ } catch {
50+ json = text;
51+ }
52+ return { status: res.status, json };
53+}
54+async function ok(method, path, body, tok) {
55+ const r = await api(method, path, body, tok);
56+ assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
57+ return r.json;
58+}
59+
60+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
61+async function until(what, fn, timeoutMs = 60_000) {
62+ const end = Date.now() + timeoutMs;
63+ let last;
64+ while (Date.now() < end) {
65+ last = await fn();
66+ if (last) return last;
67+ await sleep(500);
68+ }
69+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
70+}
71+
72+/** My emails, newest first, with the full message (kept in local development only). */
73+const emails = async (q = "") => ok("GET", `/diffinery/emails?per_page=200${q}`);
74+/** Wait for an email matching `pred` and return it. */
75+const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred));
76+
77+function git(cwd, args, tok = token) {
78+ const auth = Buffer.from(`x-token:${tok}`).toString("base64");
79+ return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
80+ cwd,
81+ encoding: "utf8",
82+ stdio: ["ignore", "pipe", "pipe"],
83+ 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" },
84+ });
85+}
86+
87+function spawnAsync(cmd, args, env, timeoutMs) {
88+ return new Promise((done) => {
89+ const child = spawn(cmd, args, { env: { ...process.env, ...env } });
90+ let out = "";
91+ child.stdout.on("data", (d) => (out += d));
92+ child.stderr.on("data", (d) => (out += d));
93+ const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
94+ child.on("close", (status) => {
95+ clearTimeout(t);
96+ done({ status, out });
97+ });
98+ });
99+}
100+
101+before(async () => {
102+ const port = await freePort();
103+ base = `http://127.0.0.1:${port}`;
104+ const persist = join(work, "state");
105+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
106+ dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], {
107+ cwd: APP,
108+ env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
109+ stdio: ["ignore", "pipe", "pipe"],
110+ detached: true,
111+ });
112+ dev.stdout.on("data", (d) => (devLog += d));
113+ dev.stderr.on("data", (d) => (devLog += d));
114+ await until("wrangler dev", async () => {
115+ try {
116+ return (await fetch(`${base}/healthz`)).ok;
117+ } catch {
118+ return false;
119+ }
120+ }, 120_000);
121+ const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "email-e2e", scopes: ["admin"] }) });
122+ assert.equal(res.status, 201, await res.clone().text());
123+ token = (await res.json()).token;
124+ me = await ok("GET", "/user");
125+});
126+
127+after(() => {
128+ if (dev?.pid) {
129+ try {
130+ process.kill(-dev.pid, "SIGTERM");
131+ } catch {}
132+ }
133+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
134+});
135+
136+test("email: security emails, settings, the test email and every template", async () => {
137+ // Creating a token is a security email, and it was delivered through the EMAIL binding.
138+ const tokenMail = await email("token created", (e) => e.kind === "token_created");
139+ assert.equal(tokenMail.status, "sent", JSON.stringify(tokenMail));
140+ assert.ok(tokenMail.message_id, "Email Sending returned a message id");
141+ assert.equal(tokenMail.to_addr, me.email);
142+ assert.match(tokenMail.subject, /token/i);
143+ const msg = tokenMail.dev_copy;
144+ assert.equal(msg.from.email, "notifications@diffinery.com");
145+ assert.match(msg.html, /email-e2e/);
146+ assert.match(msg.text, /email-e2e/);
147+ assert.equal(msg.headers["X-Diffinery-Reason"], "security_alert");
148+ assert.equal(msg.headers["List-Unsubscribe"], undefined, "security emails can't be turned off");
149+
150+ // Settings: partial updates, defaults kept, junk refused.
151+ const defaults = await ok("GET", "/diffinery/notification-settings");
152+ assert.deepEqual(defaults.participating, { web: true, email: true });
153+ assert.equal(defaults.actions.failuresOnly, true);
154+ const changed = await ok("PUT", "/diffinery/notification-settings", { watching: { email: false } });
155+ assert.deepEqual(changed.watching, { web: true, email: false });
156+ assert.deepEqual(changed.participating, defaults.participating);
157+ assert.equal((await api("PUT", "/diffinery/notification-settings", { bogus: true })).status, 422);
158+ await ok("PUT", "/diffinery/notification-settings", { watching: { email: true } });
159+
160+ // The test email goes out right away and reports Cloudflare's answer.
161+ const sent = await ok("POST", "/diffinery/emails/test");
162+ assert.equal(sent.status, "sent", JSON.stringify(sent));
163+ assert.ok(sent.messageId);
164+
165+ // Site admins: delivery status and every template, sent for real.
166+ const status = await ok("GET", "/diffinery/admin/email");
167+ assert.equal(status.sending, true);
168+ assert.equal(status.queue, true);
169+ const all = await ok("POST", "/diffinery/admin/emails/samples");
170+ assert.ok(all.total >= 30, `only ${all.total} templates`);
171+ assert.equal(all.sent, all.total, JSON.stringify(all.results.filter((r) => r.status !== "sent")));
172+
173+ // The template gallery and the settings page render.
174+ const gallery = await fetch(`${base}/settings/admin/email`);
175+ assert.equal(gallery.status, 200);
176+ assert.match(await gallery.text(), /Send every template to me/);
177+ const preview = await fetch(`${base}/settings/admin/email/preview/run-failed`);
178+ assert.equal(preview.status, 200);
179+ assert.match(preview.headers.get("content-security-policy") ?? "", /default-src 'none'/);
180+ assert.match(await preview.text(), /Run failed/);
181+ const settings = await fetch(`${base}/settings/notifications`);
182+ assert.equal(settings.status, 200);
183+ assert.match(await settings.text(), /Watched repositories/);
184+});
185+
186+test("email: watching, mentions, own activity, one-click unsubscribe and failed runs", async () => {
187+ const owner = me.login;
188+ // Repositories you create are watched (All activity).
189+ await ok("POST", "/user/repos", { name: "mailbox" });
190+ const sub = await ok("GET", `/repos/${owner}/mailbox/subscription`);
191+ assert.equal(sub.subscribed, true);
192+
193+ // Adding an agent is a security email.
194+ const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" });
195+ const botToken = bot.token.token;
196+ await email("agent added", (e) => e.kind === "agent_created");
197+
198+ // The agent opens an issue that @mentions me.
199+ const issue = await ok("POST", `/repos/${owner}/mailbox/issues`, { title: "Flaky deploy step", body: `@${owner} can you take a look?` }, botToken);
200+ const opened = await email("issue opened", (e) => e.kind === "thread" && e.subject.includes(`(Issue #${issue.number})`));
201+ assert.equal(opened.status, "sent");
202+ assert.equal(opened.subject, `[${owner}/mailbox] Flaky deploy step (Issue #${issue.number})`);
203+ assert.equal(opened.dev_copy.headers["X-Diffinery-Reason"], "mention");
204+ assert.equal(opened.dev_copy.from.name, "mail-bot (Diffinery)");
205+ assert.match(opened.dev_copy.text, /can you take a look/);
206+
207+ // My own comment doesn't email me; the agent's reply does (I commented, like GitHub's "comment" reason).
208+ await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "On it." });
209+ await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Thanks, the logs are attached." }, botToken);
210+ const reply = await email("reply", (e) => e.kind === "thread" && e.subject.startsWith("Re: ") && e.dev_copy?.text.includes("logs are attached"));
211+ assert.equal(reply.dev_copy.headers["X-Diffinery-Reason"], "comment");
212+ assert.equal(reply.dev_copy.headers["In-Reply-To"], opened.dev_copy.headers.References);
213+
214+ // One-click unsubscribe from the mail app mutes the thread.
215+ const link = /^<(.+)>$/.exec(reply.dev_copy.headers["List-Unsubscribe"])[1];
216+ assert.ok(link.startsWith(`${base}/-/unsubscribe/`));
217+ assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click");
218+ const confirm = await fetch(link);
219+ assert.match(await confirm.text(), new RegExp(`Mute ${owner}/mailbox#${issue.number}`));
220+ const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
221+ assert.equal(unsub.status, 200, await unsub.clone().text());
222+ const page = await (await fetch(`${base}/${owner}/mailbox/issues/${issue.number}`)).text();
223+ assert.match(page, /You muted this issue/);
224+ assert.equal((await fetch(`${link.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused");
225+
226+ // Muted: a plain comment stays quiet, a mention still gets through.
227+ await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Another update nobody needs." }, botToken);
228+ await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: `@${owner} this one needs you.` }, botToken);
229+ await email("mention on a muted thread", (e) => e.kind === "thread" && e.dev_copy?.text.includes("this one needs you"));
230+ const threadMails = (await emails("&kind=thread")).filter((e) => e.subject.includes(`(Issue #${issue.number})`));
231+ assert.deepEqual(
232+ threadMails.map((e) => e.dev_copy.headers["X-Diffinery-Reason"]).reverse(),
233+ ["mention", "comment", "mention"],
234+ "opened, the reply and the last mention; not my comment or the muted one",
235+ );
236+
237+ // A failed workflow run emails the person who pushed.
238+ if (!existsSync(AGENT)) return assert.fail(`build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
239+ const src = join(work, "mailbox");
240+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
241+ 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");
242+ git(src, ["init", "-q", "-b", "main"]);
243+ git(src, ["add", "-A"]);
244+ git(src, ["commit", "-q", "-m", "Add CI that fails"]);
245+ git(src, ["remote", "add", "origin", `${base}/${owner}/mailbox.git`]);
246+ git(src, ["push", "-q", "origin", "main"]);
247+ const claim = await until("a job to claim", async () => {
248+ const r = await api("POST", `/repos/${owner}/mailbox/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
249+ return r.status === 200 ? r.json : null;
250+ });
251+ 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);
252+ const failed = await email("run failed", (e) => e.kind === "run" && e.subject.includes(`${owner}/mailbox`));
253+ assert.equal(failed.status, "sent");
254+ assert.match(failed.subject, new RegExp(`^\\[${owner}/mailbox\\] Run failed: CI - main \\([0-9a-f]{7}\\)$`));
255+ assert.equal(failed.dev_copy.headers["X-Diffinery-Reason"], "ci_activity");
256+ assert.match(failed.dev_copy.text, /test/);
257+});
modifiedapps/diffinery/test/e2e/entry.ts+2−1View file
@@ -1,10 +1,11 @@
11 // Worker entry for end-to-end tests while the web UI is optional: same app, no HTML routes.
22 import type { DiffineryEvent } from "@diffinery/shared";
33 import type { Env } from "../../src/env.js";
4+import type { MailJob } from "../../src/mail/types.js";
45 import { app, queue, scheduled } from "../../src/app.js";
56
67 export { RepoDO } from "../../src/repo/RepoDO.js";
78 export { RunnerContainer, SandboxContainer } from "../../src/runner/containers.js";
89 export { RunWorkflow } from "../../src/runner/workflow.js";
910
10-export default { fetch: app.fetch, queue, scheduled } satisfies ExportedHandler<Env, DiffineryEvent>;
11+export default { fetch: app.fetch, queue, scheduled } satisfies ExportedHandler<Env, DiffineryEvent | MailJob>;
addedapps/diffinery/test/unit/email.test.ts+105−0View file
@@ -1,0 +1,105 @@
1+import { describe, expect, it } from "vitest";
2+import type { Env } from "../../src/env.js";
3+import { mentions } from "../../src/mail/notify.js";
4+import { DEFAULT_PREFS, mergePrefs, wantsEmail } from "../../src/mail/prefs.js";
5+import { sampleJobs } from "../../src/mail/samples.js";
6+import { buildMessage } from "../../src/mail/send.js";
7+import { compose, toHtml, toText } from "../../src/mail/templates.js";
8+import { categoryOf, isMandatory, type MailJob } from "../../src/mail/types.js";
9+import { readUnsubToken, unsubToken } from "../../src/mail/unsubscribe.js";
10+
11+const PUB = "https://diffinery.example";
12+const env = { PUBLIC_URL: PUB, MASTER_KEY: "test-master-key", MAIL_FROM: "notifications@diffinery.example" } as unknown as Env;
13+const samples = sampleJobs(PUB, { accountId: "acc1" }, "octocat");
14+
15+describe("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", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "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: "octocat@example.com", name: "Octo Cat", login: "octocat", 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("threads issue and pull request emails like GitHub", async () => {
45+ const opened = samples.find((s) => s.id === "pr-opened")!.job;
46+ const comment = samples.find((s) => s.id === "comment")!.job;
47+ const a = await buildMessage(env, opened, { email: "a@example.com", accountId: "acc1", login: "octocat" });
48+ const b = await buildMessage(env, comment, { email: "a@example.com", accountId: "acc1", login: "octocat" });
49+ expect(a.subject).toBe("[acme/website] Cache build artifacts between workflow runs (PR #42)");
50+ expect(b.subject).toBe("Re: [acme/website] Cache build artifacts between workflow runs (PR #42)");
51+ expect(a.headers.References).toBe(b.headers.References);
52+ expect(a.headers["In-Reply-To"]).toBeUndefined();
53+ expect(b.headers["In-Reply-To"]).toBe(b.headers.References);
54+ expect(a.headers["List-Id"]).toBe("acme/website <website.acme.diffinery.example>");
55+ });
56+
57+ it("escapes what people write", () => {
58+ const job = { ...samples.find((s) => s.id === "issue-opened")!.job } as Extract<MailJob, { kind: "thread" }>;
59+ job.data = { ...job.data, title: `<img src=x onerror=alert(1)>`, body: `<script>alert(1)</script> **bold**` };
60+ const e = compose(job, PUB, "octocat");
61+ const html = toHtml(e, { publicUrl: PUB, reason: job.reason, unsubscribe: [] });
62+ expect(html).not.toContain("<img src=x");
63+ expect(html).not.toContain("<script>alert(1)");
64+ expect(html).toContain("<strong>bold</strong>");
65+ expect(toText(e, { publicUrl: PUB, reason: job.reason, unsubscribe: [] })).toContain("<img src=x onerror=alert(1)>");
66+ });
67+});
68+
69+describe("mentions", () => {
70+ it("finds @logins outside code", () => {
71+ expect(mentions("Hey @Octocat and @rivera-2, see `@notme` and\n```\n@alsonot\n```\nmail me@example.com")).toEqual(["octocat", "rivera-2"]);
72+ expect(mentions(null)).toEqual([]);
73+ });
74+});
75+
76+describe("notification settings", () => {
77+ it("fills in defaults and ignores junk", () => {
78+ expect(mergePrefs(null)).toEqual(DEFAULT_PREFS);
79+ const p = mergePrefs({ watching: { email: false, web: "yes" }, ownActivity: true, bogus: 1 });
80+ expect(p.watching).toEqual({ web: true, email: false });
81+ expect(p.ownActivity).toBe(true);
82+ expect(p).not.toHaveProperty("bogus");
83+ });
84+
85+ it("never turns off security and account email", () => {
86+ const off = mergePrefs({ participating: { email: false }, watching: { email: false }, actions: { email: false }, repos: { email: false }, orgs: { email: false } });
87+ expect(wantsEmail(off, "security")).toBe(true);
88+ expect(wantsEmail(off, "account")).toBe(true);
89+ expect(wantsEmail(off, "participating")).toBe(false);
90+ expect(categoryOf({ kind: "thread", reason: "subscribed" })).toBe("watching");
91+ expect(categoryOf({ kind: "thread", reason: "mention" })).toBe("participating");
92+ });
93+});
94+
95+describe("unsubscribe links", () => {
96+ it("round-trips and rejects tampering", async () => {
97+ const t = await unsubToken(env, { accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
98+ expect(await readUnsubToken(env, t)).toEqual({ accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
99+ const [payload, sig] = t.split(".");
100+ const forged = Buffer.from(JSON.stringify({ a: "someone-else", s: { t: "category", category: "watching" } })).toString("base64url");
101+ expect(await readUnsubToken(env, `${forged}.${sig}`)).toBeNull();
102+ expect(await readUnsubToken(env, `${payload}.${sig!.slice(0, -2)}xx`)).toBeNull();
103+ expect(await readUnsubToken({ ...env, MASTER_KEY: "another-key" } as Env, t)).toBeNull();
104+ });
105+});
modifiedapps/diffinery/wrangler.jsonc+21−4View file
@@ -17,6 +17,7 @@
1717
1818 "vars": {
1919 "PUBLIC_URL": "https://diffinery.com",
20+ "MAIL_FROM": "notifications@diffinery.com",
2021 "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com",
2122 "ACCESS_TEAM_DOMAIN": "",
2223 "ACCESS_AUD": ""
@@ -27,11 +28,18 @@
2728 ],
2829 "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data" }],
2930 "queues": {
30- "producers": [{ "binding": "EVENTS", "queue": "diffinery-events" }],
31+ "producers": [
32+ { "binding": "EVENTS", "queue": "diffinery-events" },
33+ { "binding": "MAIL", "queue": "diffinery-mail" }
34+ ],
3135 "consumers": [
32- { "queue": "diffinery-events", "max_batch_size": 50, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-events-dlq" }
36+ { "queue": "diffinery-events", "max_batch_size": 50, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-events-dlq" },
37+ { "queue": "diffinery-mail", "max_batch_size": 20, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-mail-dlq" }
3338 ]
3439 },
40+ // Cloudflare Email Sending (notifications, security alerts, invites). The sending
41+ // domain is onboarded under Compute > Email Service > Email Sending (see scripts/email-sending.sh).
42+ "send_email": [{ "name": "EMAIL" }],
3543 "durable_objects": {
3644 "bindings": [
3745 { "name": "REPO", "class_name": "RepoDO" },
@@ -54,6 +62,7 @@
5462 "workers_dev": true,
5563 "vars": {
5664 "PUBLIC_URL": "http://localhost:8787",
65+ "MAIL_FROM": "notifications@diffinery.com",
5766 "BOOTSTRAP_ADMINS": "dev@diffinery.local",
5867 "ACCESS_TEAM_DOMAIN": "",
5968 "ACCESS_AUD": "",
@@ -62,9 +71,17 @@
6271 "d1_databases": [{ "binding": "DB", "database_name": "diffinery-dev", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }],
6372 "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data-dev" }],
6473 "queues": {
65- "producers": [{ "binding": "EVENTS", "queue": "diffinery-events-dev" }],
66- "consumers": [{ "queue": "diffinery-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 }]
74+ "producers": [
75+ { "binding": "EVENTS", "queue": "diffinery-events-dev" },
76+ { "binding": "MAIL", "queue": "diffinery-mail-dev" }
77+ ],
78+ "consumers": [
79+ { "queue": "diffinery-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 },
80+ { "queue": "diffinery-mail-dev", "max_batch_size": 20, "max_batch_timeout": 1 }
81+ ]
6782 },
83+ // Local email: Miniflare captures sent messages instead of delivering them.
84+ "send_email": [{ "name": "EMAIL" }],
6885 "durable_objects": {
6986 "bindings": [
7087 { "name": "REPO", "class_name": "RepoDO" },
modifiedpackages/shared/src/events.ts+2−2View file
@@ -2,8 +2,8 @@
22 /** Events flowing through the `events` queue. Payload shapes follow GitHub webhooks where practical. */
33 export type DiffineryEvent =
44 | { type: "push"; repoId: string; owner: string; repo: string; ref: string; before: string; after: string; actor: Actor; forced: boolean; created: boolean; deleted: boolean; at: string }
5- | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged" | "enqueued" | "dequeued"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string }
6- | { type: "issues"; action: "opened" | "closed" | "reopened" | "edited"; repoId: string; owner: string; repo: string; number: number; actor: Actor; at: string }
5+ | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged" | "enqueued" | "dequeued" | "assigned" | "review_requested"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string; /** Logins newly assigned (assigned). */ assignees?: string[]; /** Logins newly asked to review (review_requested). */ reviewers?: string[] }
6+ | { type: "issues"; action: "opened" | "closed" | "reopened" | "edited" | "assigned"; repoId: string; owner: string; repo: string; number: number; actor: Actor; at: string; /** Logins newly assigned (assigned). */ assignees?: string[] }
77 | { type: "issue_comment"; action: "created"; repoId: string; owner: string; repo: string; number: number; commentId: string; actor: Actor; at: string }
88 | { type: "pull_request_review"; action: "submitted"; repoId: string; owner: string; repo: string; number: number; state: string; actor: Actor; at: string }
99 | { type: "workflow_run"; action: "requested" | "completed"; repoId: string; owner: string; repo: string; runId: string; conclusion?: string; actor: Actor; at: string }
modifiedscripts/bootstrap.sh+7−2View file
@@ -1,7 +1,8 @@
11 #!/usr/bin/env bash
22 # Idempotent production setup for Diffinery on a Cloudflare account.
33 #
4-# Creates (only when missing): the D1 database, R2 bucket, event queues and the
4+# Creates (only when missing): the D1 database, R2 bucket, event and mail queues, the
5+# Email Sending setup for the domain (scripts/email-sending.sh) and the
56 # Cloudflare Access application for /auth/access, the email-based account
67 # recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners
78 # use Diffinery tokens.
@@ -63,7 +64,7 @@
6364 # ------------------------------------------------------------------ Queues
6465 log "Queues"
6566 EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')"
66-for q in diffinery-events diffinery-events-dlq; do
67+for q in diffinery-events diffinery-events-dlq diffinery-mail diffinery-mail-dlq; do
6768 if grep -qx "$q" <<<"$EXISTING_QUEUES"; then
6869 echo "queue $q exists"
6970 else
@@ -73,6 +74,10 @@
7374 done
7475 endlog
7576
77+# ------------------------------------------------------------------ Email Sending
78+# notifications@diffinery.com; warns (and email is skipped) until the domain is onboarded.
79+DIFFINERY_DOMAIN="$DOMAIN" "$ROOT/scripts/email-sending.sh" || warn "Email Sending check failed; email may not be delivered."
80+
7681 # ------------------------------------------------------------------ Access
7782 log "Cloudflare Access"
7883 TEAM="${ACCESS_TEAM_DOMAIN:-}"
addedscripts/email-sending.sh+74−0View file
@@ -1,0 +1,74 @@
1+#!/usr/bin/env bash
2+# Checks Cloudflare Email Sending for the domain Diffinery sends mail from
3+# (notifications@diffinery.com): that the domain is onboarded and that every DNS
4+# record Email Sending asks for (the cf-bounce MX and SPF, DKIM and DMARC) is
5+# published. It changes nothing. Onboard the domain once in the dashboard:
6+# Compute > Email Service > Email Sending > Onboard Domain.
7+#
8+# Requires: CLOUDFLARE_API_TOKEN (zone permissions Email Sending Read or Edit and
9+# DNS Read on the domain), jq, curl. Optional: DIFFINERY_DOMAIN, MAIL_DOMAIN.
10+# Always exits 0, warning about anything missing, so a deploy still goes out.
11+set -euo pipefail
12+
13+: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
14+DOMAIN="${MAIL_DOMAIN:-${DIFFINERY_DOMAIN:-diffinery.com}}"
15+CF="https://api.cloudflare.com/client/v4"
16+
17+cf() { curl -sS "$CF$1" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"; }
18+warn() { echo "::warning::$*" >&2; }
19+errors() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1"; }
20+
21+echo "::group::Email Sending for $DOMAIN"
22+trap 'echo "::endgroup::"' EXIT
23+
24+# The zone that holds the domain (the domain itself or a parent).
25+ZONE_ID="" ; name="$DOMAIN"
26+while [[ "$name" == *.* ]]; do
27+ ZONE_ID="$(cf "/zones?name=$name" | jq -r '.result[0].id // empty')"
28+ [[ -n "$ZONE_ID" ]] && break
29+ name="${name#*.}"
30+done
31+[[ -n "$ZONE_ID" ]] || { warn "Could not find the Cloudflare zone for $DOMAIN (the token needs Zone Read on it)."; exit 0; }
32+echo "zone $name ($ZONE_ID)"
33+
34+# Without Email Sending access on the token, look at the public DNS records onboarding creates instead.
35+public_check() {
36+ doh() { curl -sS "https://cloudflare-dns.com/dns-query?name=$1&type=$2" -H 'accept: application/dns-json' | jq -r '.Answer[]?.data' 2>/dev/null; }
37+ local ok=1
38+ if doh "cf-bounce.$DOMAIN" MX | grep -q 'mx.cloudflare.net'; then echo "ok MX cf-bounce.$DOMAIN"; else echo "missing MX cf-bounce.$DOMAIN"; ok=0; fi
39+ if doh "cf-bounce.$DOMAIN" TXT | grep -q 'v=spf1'; then echo "ok TXT cf-bounce.$DOMAIN (SPF)"; else echo "missing TXT cf-bounce.$DOMAIN (SPF)"; ok=0; fi
40+ if doh "_dmarc.$DOMAIN" TXT | grep -qi 'v=DMARC1'; then echo "ok TXT _dmarc.$DOMAIN"; else echo "missing TXT _dmarc.$DOMAIN"; ok=0; fi
41+ [[ "$ok" == 1 ]] && echo "$DOMAIN looks onboarded to Email Sending (public DNS)" || warn "$DOMAIN does not look onboarded to Email Sending, so mail may not be delivered. Onboard it under Compute > Email Service > Email Sending in the Cloudflare dashboard."
42+}
43+
44+LIST="$(cf "/zones/$ZONE_ID/email/sending/subdomains")"
45+if ! jq -e '.success' >/dev/null <<<"$LIST"; then
46+ echo "can't read Email Sending settings with this token ($(errors "$LIST")), checking public DNS instead"
47+ public_check
48+ exit 0
49+fi
50+TAG="$(jq -r --arg d "$DOMAIN" '.result[]? | select(.name == $d) | .tag' <<<"$LIST" | head -n1)"
51+if [[ -z "$TAG" ]]; then
52+ warn "$DOMAIN is not onboarded to Email Sending, so no email will be delivered. Onboard it under Compute > Email Service > Email Sending in the Cloudflare dashboard."
53+ exit 0
54+fi
55+cf "/zones/$ZONE_ID/email/sending/subdomains/$TAG" | jq -c '.result | {name, enabled, dkim_selector, return_path_domain}'
56+
57+WANT="$(cf "/zones/$ZONE_ID/email/sending/subdomains/$TAG/dns")"
58+jq -e '.success' >/dev/null <<<"$WANT" || { warn "Could not read the DNS records Email Sending needs ($(errors "$WANT"))."; exit 0; }
59+norm() { tr -d '"' | tr -s ' ' | sed 's/ *$//'; }
60+missing=0
61+while IFS= read -r rec; do
62+ type="$(jq -r .type <<<"$rec")"; rname="$(jq -r .name <<<"$rec")"
63+ want="$(jq -r .content <<<"$rec" | norm)"
64+ have="$(cf "/zones/$ZONE_ID/dns_records?type=$type&name=$rname&per_page=100")"
65+ if ! jq -e '.success' >/dev/null <<<"$have"; then echo "can't read DNS for $rname ($(errors "$have"))"; continue; fi
66+ if jq -r '.result[].content' <<<"$have" | norm | grep -qxF -- "$want"; then
67+ echo "ok $type $rname"
68+ elif [[ "$type" == TXT && "$rname" == _dmarc.* ]] && jq -r '.result[].content' <<<"$have" | grep -qi 'v=DMARC1'; then
69+ echo "ok TXT $rname (the domain's own DMARC policy)"
70+ else
71+ echo "missing $type $rname"; missing=1
72+ fi
73+done < <(jq -c '.result[]?' <<<"$WANT")
74+[[ "$missing" == 0 ]] && echo "Email Sending is set up for $DOMAIN" || warn "Some Email Sending DNS records for $DOMAIN are missing, so mail may not be delivered. Finish onboarding in the dashboard."