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)
- .github/workflows/live-test.yml+13−2
- README.md+52−4
- apps/diffinery/migrations/0004_email.sql+60−0
- apps/diffinery/package.json+1−1
- apps/diffinery/src/app.ts+18−33
- apps/diffinery/src/auth/sessions.ts+4−1
- apps/diffinery/src/env.ts+7−0
- apps/diffinery/src/http/api.ts+9−0
- apps/diffinery/src/index.ts+2−1
- apps/diffinery/src/lib/directory.ts+45−1
- apps/diffinery/src/mail/api.ts+144−0
- apps/diffinery/src/mail/notify.ts+369−0
- apps/diffinery/src/mail/prefs.ts+212−0
- apps/diffinery/src/mail/samples.ts+83−0
- apps/diffinery/src/mail/send.ts+155−0
- apps/diffinery/src/mail/templates.ts+636−0
- apps/diffinery/src/mail/types.ts+140−0
- apps/diffinery/src/mail/unsubscribe.ts+55−0
- apps/diffinery/src/mail/web.tsx+447−0
- apps/diffinery/src/repo/RepoDO.ts+2−2
- apps/diffinery/src/repo/collab.ts+14−0
- apps/diffinery/src/web/account.tsx+13−6
- apps/diffinery/src/web/auth.tsx+12−5
- apps/diffinery/src/web/collab.tsx+58−1
- apps/diffinery/src/web/routes.tsx+2−0
- apps/diffinery/src/web/ui.tsx+35−3
- apps/diffinery/test/e2e/email.test.mjs+257−0
- apps/diffinery/test/e2e/entry.ts+2−1
- apps/diffinery/test/unit/email.test.ts+105−0
- apps/diffinery/wrangler.jsonc+21−4
- packages/shared/src/events.ts+2−2
- scripts/bootstrap.sh+7−2
- scripts/email-sending.sh+74−0
modified.github/workflows/live-test.yml+13−2View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | name: Live test |
| 2 | 2 | |
| 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, | |
| 5 | 5 | # webhooks, scheduled workflows, Git LFS, and pull requests (test merge commits, pull_request_target and the merge |
| 6 | 6 | # queue). Each run works in a throwaway private repository that it |
| 7 | 7 | # deletes at the end. |
| @@ -46,6 +46,17 @@ | ||
| 46 | 46 | echo "OWNER=$login" >> "$GITHUB_ENV" |
| 47 | 47 | echo "testing as $login in $login/$REPO" |
| 48 | 48 | |
| 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 | + | |
| 49 | 60 | - name: Create a throwaway repository with a webhook |
| 50 | 61 | if: steps.token.outputs.skip != 'true' |
| 51 | 62 | run: | |
modifiedREADME.md+52−4View file
| @@ -87,11 +87,11 @@ | ||
| 87 | 87 | code, and add a passkey on the page that follows. |
| 88 | 88 | 2. **Inviting someone.** An admin or org owner creates an invite from |
| 89 | 89 | *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. | |
| 92 | 92 | 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. | |
| 95 | 95 | 4. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or |
| 96 | 96 | keep one in a password manager that syncs. The same page signs out other |
| 97 | 97 | browsers. |
| @@ -106,6 +106,54 @@ | ||
| 106 | 106 | diffinery login && diffinery auth setup-git # or let the CLI handle credentials |
| 107 | 107 | ``` |
| 108 | 108 | |
| 109 | ||
| 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 | + | |
| 109 | 157 | ## Agents |
| 110 | 158 | |
| 111 | 159 | 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 @@ | ||
| 9 | 9 | "typecheck": "tsc --noEmit && tsc --noEmit -p islands", |
| 10 | 10 | "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'", |
| 11 | 11 | "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", | |
| 13 | 13 | "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev", |
| 14 | 14 | "build": "node islands/build.mjs", |
| 15 | 15 | "predev": "node islands/build.mjs" |
modifiedapps/diffinery/src/app.ts+18−33View file
| @@ -12,10 +12,14 @@ | ||
| 12 | 12 | import { git } from "./http/git.js"; |
| 13 | 13 | import { runner } from "./http/runner.js"; |
| 14 | 14 | import { api, wellKnown } from "./http/api.js"; |
| 15 | +import { mailApi } from "./mail/api.js"; | |
| 15 | 16 | import { mcp } from "./mcp/server.js"; |
| 16 | 17 | import { registry } from "./registry/http.js"; |
| 17 | 18 | import { packagesApi } from "./registry/api.js"; |
| 18 | 19 | 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"; | |
| 19 | 23 | |
| 20 | 24 | export const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 21 | 25 | |
| @@ -63,6 +67,7 @@ | ||
| 63 | 67 | app.route("/", wellKnown); |
| 64 | 68 | app.route("/", git); |
| 65 | 69 | app.route("/", runner); |
| 70 | +app.route("/", mailApi); | |
| 66 | 71 | app.route("/", registry); |
| 67 | 72 | app.route("/", packagesApi); |
| 68 | 73 | app.route("/", api); |
| @@ -149,34 +154,6 @@ | ||
| 149 | 154 | ); |
| 150 | 155 | } |
| 151 | 156 | |
| 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 | - | |
| 180 | 157 | async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { |
| 181 | 158 | if (ev.type === "audit") { |
| 182 | 159 | 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 @@ | ||
| 188 | 165 | if (ev.type === "pull_request" && ev.action === "merged") { |
| 189 | 166 | 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 }); |
| 190 | 167 | } |
| 191 | - await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]); | |
| 168 | + await Promise.all([deliverWebhooks(env, ev), notifyEvent(env, dir, ev)]); | |
| 192 | 169 | } |
| 193 | 170 | |
| 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> { | |
| 195 | 178 | const dir = new Directory(env); |
| 196 | 179 | // Keep an append-only copy of every event in R2 for long-term audit retention. |
| 197 | 180 | const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/"); |
| @@ -219,13 +202,15 @@ | ||
| 219 | 202 | end.setUTCSeconds(0, 0); |
| 220 | 203 | const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000)); |
| 221 | 204 | 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) => { | |
| 224 | 207 | try { |
| 225 | 208 | if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron); |
| 226 | 209 | } catch (e) { |
| 227 | 210 | console.error("schedule failed", s.repo_id, s.workflow_path, e); |
| 228 | 211 | } |
| 229 | 212 | }), |
| 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 | + ]); | |
| 231 | 216 | } |
modifiedapps/diffinery/src/auth/sessions.ts+4−1View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | import type { Account } from "../lib/directory.js"; |
| 3 | 3 | import { newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 4 | +import { enqueueMail } from "../mail/send.js"; | |
| 4 | 5 | |
| 5 | 6 | /** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */ |
| 6 | 7 | export const SESSION_COOKIE = "__Host-dfy_session"; |
| @@ -180,7 +181,9 @@ | ||
| 180 | 181 | .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") |
| 181 | 182 | .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt) |
| 182 | 183 | .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 }; | |
| 184 | 187 | } |
| 185 | 188 | |
| 186 | 189 | async signinLink(token: string): Promise<{ hash: string; account: Account } | null> { |
modifiedapps/diffinery/src/env.ts+7−0View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 2 | 2 | import type { RepoDO } from "./repo/RepoDO.js"; |
| 3 | 3 | import type { RunnerContainer, SandboxContainer } from "./runner/containers.js"; |
| 4 | +import type { MailJob } from "./mail/types.js"; | |
| 4 | 5 | |
| 5 | 6 | export interface Env { |
| 6 | 7 | DB: D1Database; |
| @@ -10,9 +11,15 @@ | ||
| 10 | 11 | RUNNER: DurableObjectNamespace<RunnerContainer>; |
| 11 | 12 | SANDBOX: DurableObjectNamespace<SandboxContainer>; |
| 12 | 13 | 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>; | |
| 13 | 18 | |
| 14 | 19 | /** Canonical origin, e.g. https://diffinery.com */ |
| 15 | 20 | PUBLIC_URL: string; |
| 21 | + /** Sender address for every email, e.g. notifications@diffinery.com. */ | |
| 22 | + MAIL_FROM?: string; | |
| 16 | 23 | /** Comma-separated emails or logins that become site admins on first sign-in. */ |
| 17 | 24 | BOOTSTRAP_ADMINS: string; |
| 18 | 25 | /** e.g. codician.cloudflareaccess.com — empty disables Access verification. */ |
modifiedapps/diffinery/src/http/api.ts+9−0View file
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import type { RunInfo } from "../repo/actions.js"; |
| 11 | 11 | import type { QueueEntry } from "../repo/queue.js"; |
| 12 | 12 | import { loadRepo, repoStub } from "./common.js"; |
| 13 | +import { repoCreated } from "../mail/notify.js"; | |
| 13 | 14 | |
| 14 | 15 | /** |
| 15 | 16 | * REST API. Paths and payloads follow GitHub's REST API v3 where practical so |
| @@ -50,6 +51,11 @@ | ||
| 50 | 51 | } |
| 51 | 52 | } |
| 52 | 53 | |
| 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 | +}; | |
| 53 | 59 | const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! }); |
| 54 | 60 | const num = (c: AppContext, name = "number") => { |
| 55 | 61 | const n = Number(c.req.param(name)); |
| @@ -119,6 +125,7 @@ | ||
| 119 | 125 | const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); |
| 120 | 126 | const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a; |
| 121 | 127 | 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) })); | |
| 122 | 129 | return c.json(repoJson(c, r), 201); |
| 123 | 130 | }); |
| 124 | 131 | api.get("/users/:login/repos", async (c) => { |
| @@ -139,6 +146,7 @@ | ||
| 139 | 146 | if (!org || org.kind !== "org") throw new HttpError(404, "organization not found"); |
| 140 | 147 | const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); |
| 141 | 148 | 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) })); | |
| 142 | 150 | return c.json(repoJson(c, r), 201); |
| 143 | 151 | }); |
| 144 | 152 | api.get("/repos/:owner/:repo", async (c) => { |
| @@ -154,6 +162,7 @@ | ||
| 154 | 162 | const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 155 | 163 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories"); |
| 156 | 164 | 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}` })); | |
| 157 | 166 | return c.body(null, 204); |
| 158 | 167 | }); |
| 159 | 168 | api.get("/repos/:owner/:repo/collaborators", async (c) => { |
modifiedapps/diffinery/src/index.ts+2−1View file
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 2 | 2 | import type { Env } from "./env.js"; |
| 3 | +import type { MailJob } from "./mail/types.js"; | |
| 3 | 4 | import { app, queue, scheduled } from "./app.js"; |
| 4 | 5 | import { web } from "./web/routes.js"; |
| 5 | 6 | |
| @@ -14,4 +15,4 @@ | ||
| 14 | 15 | fetch: app.fetch, |
| 15 | 16 | queue, |
| 16 | 17 | 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 @@ | ||
| 2 | 2 | import type { RepoMeta, Role } from "../repo/types.js"; |
| 3 | 3 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 4 | 4 | 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"; | |
| 5 | 7 | |
| 6 | 8 | export interface Account { |
| 7 | 9 | id: string; |
| @@ -134,6 +136,15 @@ | ||
| 134 | 136 | } |
| 135 | 137 | |
| 136 | 138 | 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> { | |
| 137 | 148 | if (role === null) { |
| 138 | 149 | 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 }>(); |
| 139 | 150 | if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner"); |
| @@ -149,8 +160,13 @@ | ||
| 149 | 160 | } |
| 150 | 161 | |
| 151 | 162 | async setStatus(id: string, status: Account["status"]): Promise<void> { |
| 163 | + const before = await this.account(id); | |
| 152 | 164 | await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run(); |
| 153 | 165 | 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 | + } | |
| 154 | 170 | } |
| 155 | 171 | |
| 156 | 172 | // ---------------------------------------------------------------- agents |
| @@ -190,6 +206,7 @@ | ||
| 190 | 206 | .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") |
| 191 | 207 | .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()) |
| 192 | 208 | .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)); | |
| 193 | 210 | return { token, id, expiresAt }; |
| 194 | 211 | } |
| 195 | 212 | |
| @@ -225,7 +242,19 @@ | ||
| 225 | 242 | // If the person already exists, activate them now so the invite also works from the dashboard. |
| 226 | 243 | const existing = await this.accountByEmail(input.email); |
| 227 | 244 | 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 }; | |
| 229 | 258 | } |
| 230 | 259 | |
| 231 | 260 | async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> { |
| @@ -252,10 +281,20 @@ | ||
| 252 | 281 | 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())); |
| 253 | 282 | 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)); |
| 254 | 283 | 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 | + } | |
| 255 | 291 | } |
| 256 | 292 | |
| 257 | 293 | async requestAccess(accountId: string, note: string): Promise<void> { |
| 258 | 294 | 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() }))); | |
| 259 | 298 | } |
| 260 | 299 | |
| 261 | 300 | async accessRequests(): Promise<Array<{ account: Account; note: string; createdAt: string }>> { |
| @@ -348,8 +387,11 @@ | ||
| 348 | 387 | } |
| 349 | 388 | |
| 350 | 389 | 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; | |
| 351 | 391 | if (role) await this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(repoId, accountId, role).run(); |
| 352 | 392 | 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() }]); | |
| 353 | 395 | } |
| 354 | 396 | |
| 355 | 397 | /** Effective role of an account on a repository (null = no access). */ |
| @@ -405,6 +447,8 @@ | ||
| 405 | 447 | .prepare("INSERT INTO audit_log (id, at, actor_id, actor_login, on_behalf_of, action, target, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") |
| 406 | 448 | .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) |
| 407 | 449 | .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)); | |
| 408 | 452 | } |
| 409 | 453 | |
| 410 | 454 | 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 ? ` ${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)}${" ͏ ".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(" · ")}</p>` : ""} | |
| 211 | +<p style="margin:0">Diffinery · Code hosting and CI on Cloudflare · <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 @@ | ||
| 255 | 255 | if (reason && caller.role === "admin" && caller.kind === "user") { |
| 256 | 256 | messages.push(`Push protection bypassed by ${caller.login}: ${reason}`); |
| 257 | 257 | 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() }); | |
| 259 | 259 | } else { |
| 260 | 260 | messages.push("Push blocked: possible secrets detected."); |
| 261 | 261 | for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); |
| 262 | 262 | messages.push("Remove the secret from your commits, or rotate it and contact an admin."); |
| 263 | 263 | if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection."); |
| 264 | 264 | 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() }); | |
| 266 | 266 | return reportStatus(req, null, results, messages); |
| 267 | 267 | } |
| 268 | 268 | } |
modifiedapps/diffinery/src/repo/collab.ts+14−0View file
| @@ -301,6 +301,14 @@ | ||
| 301 | 301 | if (action === "closed" && this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref); |
| 302 | 302 | } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t }); |
| 303 | 303 | } |
| 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 | + } | |
| 304 | 312 | this.repo.broadcast(`pr:${number}`, { action: "updated" }); |
| 305 | 313 | return this.toIssue(this.row(number)); |
| 306 | 314 | } |
| @@ -413,6 +421,12 @@ | ||
| 413 | 421 | const p = this.pullRow(number); |
| 414 | 422 | const set = new Set<string>([...JSON.parse(p.requested_reviewers), ...reviewers]); |
| 415 | 423 | 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 | + } | |
| 416 | 430 | } |
| 417 | 431 | |
| 418 | 432 | /** 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 @@ | ||
| 10 | 10 | import { KCheckbox, KRadio, KSelect } from "./kumo.js"; |
| 11 | 11 | import { csrfToken } from "./csrf.js"; |
| 12 | 12 | import { Auth } from "../auth/sessions.js"; |
| 13 | +import { repoCreated } from "../mail/notify.js"; | |
| 13 | 14 | import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js"; |
| 14 | 15 | import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; |
| 15 | 16 | |
| @@ -310,6 +311,7 @@ | ||
| 310 | 311 | const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private"; |
| 311 | 312 | const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" }); |
| 312 | 313 | 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 })); | |
| 313 | 315 | return c.redirect(repoUrl(repo), 303); |
| 314 | 316 | } catch (e) { |
| 315 | 317 | if (!(e instanceof HttpError) || e.status >= 500) throw e; |
| @@ -492,9 +494,9 @@ | ||
| 492 | 494 | |
| 493 | 495 | // ---------------------------------------------------------------- /settings |
| 494 | 496 | |
| 495 | -type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "admin" | "audit"; | |
| 497 | +export type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit"; | |
| 496 | 498 | |
| 497 | -const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( | |
| 499 | +export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( | |
| 498 | 500 | <aside class="side"> |
| 499 | 501 | <section> |
| 500 | 502 | <div class="row mb"> |
| @@ -520,12 +522,17 @@ | ||
| 520 | 522 | <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}> |
| 521 | 523 | Organizations |
| 522 | 524 | </a> |
| 523 | - <a href="/notifications">Notifications</a> | |
| 525 | + <a href="/settings/notifications" class={tab === "notifications" ? "on" : ""}> | |
| 526 | + Notifications | |
| 527 | + </a> | |
| 524 | 528 | {me.site_admin ? ( |
| 525 | 529 | <> |
| 526 | 530 | <a href="/settings/admin" class={tab === "admin" ? "on" : ""}> |
| 527 | 531 | Site administration |
| 528 | 532 | </a> |
| 533 | + <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}> | |
| 534 | ||
| 535 | + </a> | |
| 529 | 536 | <a href="/settings/audit" class={tab === "audit" ? "on" : ""}> |
| 530 | 537 | Audit log |
| 531 | 538 | </a> |
| @@ -1253,7 +1260,7 @@ | ||
| 1253 | 1260 | if (target.status === "disabled") throw new HttpError(422, "enable this account first"); |
| 1254 | 1261 | const link = await new Auth(c.env).createSigninLink(me, target); |
| 1255 | 1262 | 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.` }); | |
| 1257 | 1264 | }); |
| 1258 | 1265 | |
| 1259 | 1266 | post(web, "/settings/admin/invite", async (c, f) => { |
| @@ -1262,7 +1269,7 @@ | ||
| 1262 | 1269 | if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); |
| 1263 | 1270 | const inv = await c.get("dir").createInvite(me, { email, role: "read" }); |
| 1264 | 1271 | 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.` }); | |
| 1266 | 1273 | }); |
| 1267 | 1274 | |
| 1268 | 1275 | web.get("/settings/audit", async (c) => { |
| @@ -1538,7 +1545,7 @@ | ||
| 1538 | 1545 | <div class="box" style="border-color:var(--amber)"> |
| 1539 | 1546 | <div class="body"> |
| 1540 | 1547 | <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> | |
| 1542 | 1549 | <div class="row"> |
| 1543 | 1550 | <div class="secret grow">{invite.url}</div> |
| 1544 | 1551 | <CopyButton text={invite.url} /> |
modifiedapps/diffinery/src/web/auth.tsx+12−5View file
| @@ -49,6 +49,13 @@ | ||
| 49 | 49 | return [os, browser].filter(Boolean).join(" · ") || "Passkey"; |
| 50 | 50 | } |
| 51 | 51 | |
| 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 | + | |
| 52 | 59 | async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> { |
| 53 | 60 | const old = getCookie(c, SESSION_COOKIE); |
| 54 | 61 | if (old) await auth.endSession(old); |
| @@ -262,7 +269,7 @@ | ||
| 262 | 269 | if (account.status === "disabled") throw new HttpError(403, "Your account is disabled."); |
| 263 | 270 | await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp); |
| 264 | 271 | 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) } })); | |
| 266 | 273 | return c.json({ redirect: safeNext(body.next) }); |
| 267 | 274 | }); |
| 268 | 275 | |
| @@ -348,7 +355,7 @@ | ||
| 348 | 355 | const me = browserAccount(c); |
| 349 | 356 | if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey."); |
| 350 | 357 | 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) } })); | |
| 352 | 359 | flash(c, "notice", `Passkey "${label}" added.`); |
| 353 | 360 | return c.json({ redirect: "/settings/security" }); |
| 354 | 361 | } else if (s.kind === "invite") { |
| @@ -359,7 +366,7 @@ | ||
| 359 | 366 | // Creating the account accepts every open invite for this email, including this one. |
| 360 | 367 | account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId }); |
| 361 | 368 | 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) } })); | |
| 363 | 370 | flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`); |
| 364 | 371 | redirect = "/"; |
| 365 | 372 | } else if (s.kind === "link") { |
| @@ -368,7 +375,7 @@ | ||
| 368 | 375 | if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled."); |
| 369 | 376 | account = found; |
| 370 | 377 | 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) } })); | |
| 372 | 379 | flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`); |
| 373 | 380 | redirect = "/settings/security"; |
| 374 | 381 | } else { |
| @@ -405,7 +412,7 @@ | ||
| 405 | 412 | if (account.status === "disabled") throw new HttpError(403, "Your account is disabled."); |
| 406 | 413 | const auth = new Auth(c.env); |
| 407 | 414 | 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) } })); | |
| 409 | 416 | if (account.status === "pending") return c.redirect("/", 302); |
| 410 | 417 | const keys = await auth.passkeys(account.id); |
| 411 | 418 | 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 @@ | ||
| 9 | 9 | import { ICONS } from "./assets.js"; |
| 10 | 10 | import { KCheckbox, KRadio, KSelect } from "./kumo.js"; |
| 11 | 11 | import { csrfToken } from "./csrf.js"; |
| 12 | +import { MailStore, type WatchLevel } from "../mail/prefs.js"; | |
| 13 | +import { mentions } from "../mail/notify.js"; | |
| 12 | 14 | import { |
| 13 | 15 | Actor, |
| 14 | 16 | CommitRow, |
| @@ -37,6 +39,7 @@ | ||
| 37 | 39 | type LineThread, |
| 38 | 40 | type WebApp, |
| 39 | 41 | prm, |
| 42 | + principalAccount, | |
| 40 | 43 | } from "./ui.js"; |
| 41 | 44 | |
| 42 | 45 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| @@ -246,6 +249,8 @@ | ||
| 246 | 249 | reviews: Review[]; |
| 247 | 250 | labels: LabelDef[]; |
| 248 | 251 | 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 }; | |
| 249 | 254 | } |
| 250 | 255 | |
| 251 | 256 | 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 @@ | ||
| 397 | 402 | ); |
| 398 | 403 | }; |
| 399 | 404 | |
| 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 | + | |
| 400 | 449 | const Sidebar: FC<{ x: ConvCtx }> = ({ x }) => { |
| 401 | 450 | const base = repoUrl(x.l.repo); |
| 402 | 451 | const t = csrfToken(x.c); |
| @@ -488,6 +537,7 @@ | ||
| 488 | 537 | </details> |
| 489 | 538 | ) : null} |
| 490 | 539 | </section> |
| 540 | + {x.sub ? <SubscriptionBox x={x} sub={x.sub} /> : null} | |
| 491 | 541 | <section> |
| 492 | 542 | <h3>Participants</h3> |
| 493 | 543 | <div class="row" style="gap:4px"> |
| @@ -859,7 +909,14 @@ | ||
| 859 | 909 | const n = issueNumber(c); |
| 860 | 910 | const [data, labels] = await Promise.all([l.stub.issue(n), l.stub.labels()]); |
| 861 | 911 | 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 }; | |
| 863 | 920 | } |
| 864 | 921 | |
| 865 | 922 | async function issuePage(c: AppContext): Promise<Response> { |
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | import type { Env } from "../env.js"; |
| 4 | 4 | import type { AppVars } from "../auth/principal.js"; |
| 5 | 5 | import { registerAccount } from "./account.js"; |
| 6 | +import { registerMailWeb } from "../mail/web.js"; | |
| 6 | 7 | import { registerAuth } from "./auth.js"; |
| 7 | 8 | import { registerActions } from "./actions.js"; |
| 8 | 9 | import { registerCode } from "./code.js"; |
| @@ -33,6 +34,7 @@ | ||
| 33 | 34 | registerKumoAssets(web); |
| 34 | 35 | registerAuth(web); |
| 35 | 36 | registerPackages(web); |
| 37 | +registerMailWeb(web); | |
| 36 | 38 | registerAccount(web); |
| 37 | 39 | registerRepoSettings(web); |
| 38 | 40 | registerActions(web); |
modifiedapps/diffinery/src/web/ui.tsx+35−3View file
| @@ -12,7 +12,8 @@ | ||
| 12 | 12 | import { CSS, ICONS, JS, type IconName } from "./assets.js"; |
| 13 | 13 | import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; |
| 14 | 14 | 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"; | |
| 16 | 17 | import { SeoHead, SITE } from "./seo.js"; |
| 17 | 18 | |
| 18 | 19 | export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; |
| @@ -262,13 +263,32 @@ | ||
| 262 | 263 | home?: boolean; |
| 263 | 264 | } |
| 264 | 265 | |
| 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 }) => { | |
| 266 | 281 | const base = repoUrl(r.repo); |
| 267 | 282 | const tab = r.tab ?? ""; |
| 268 | 283 | const vis = r.repo.visibility; |
| 269 | 284 | return ( |
| 270 | 285 | <div class="repohead"> |
| 271 | 286 | <div class="wrap"> |
| 287 | + {watch ? ( | |
| 288 | + <div style="float:right"> | |
| 289 | + <WatchControl base={base} level={watch} t={t} /> | |
| 290 | + </div> | |
| 291 | + ) : null} | |
| 272 | 292 | <h1> |
| 273 | 293 | <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span> |
| 274 | 294 | <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a> |
| @@ -325,6 +345,18 @@ | ||
| 325 | 345 | |
| 326 | 346 | /** Render a full page. */ |
| 327 | 347 | 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> { | |
| 328 | 360 | csrfToken(c); |
| 329 | 361 | const nonce = randomToken(16); |
| 330 | 362 | const fl = takeFlash(c); |
| @@ -404,7 +436,7 @@ | ||
| 404 | 436 | </nav> |
| 405 | 437 | </div> |
| 406 | 438 | </header> |
| 407 | - {r ? <RepoHeader r={r} /> : null} | |
| 439 | + {r ? <RepoHeader r={r} watch={watch} t={csrfToken(c)} /> : null} | |
| 408 | 440 | <main class="wrap" style={opts.wide ? "max-width:none" : undefined}> |
| 409 | 441 | {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null} |
| 410 | 442 | {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 @@ | ||
| 1 | 1 | // Worker entry for end-to-end tests while the web UI is optional: same app, no HTML routes. |
| 2 | 2 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 3 | 3 | import type { Env } from "../../src/env.js"; |
| 4 | +import type { MailJob } from "../../src/mail/types.js"; | |
| 4 | 5 | import { app, queue, scheduled } from "../../src/app.js"; |
| 5 | 6 | |
| 6 | 7 | export { RepoDO } from "../../src/repo/RepoDO.js"; |
| 7 | 8 | export { RunnerContainer, SandboxContainer } from "../../src/runner/containers.js"; |
| 8 | 9 | export { RunWorkflow } from "../../src/runner/workflow.js"; |
| 9 | 10 | |
| 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 @@ | ||
| 17 | 17 | |
| 18 | 18 | "vars": { |
| 19 | 19 | "PUBLIC_URL": "https://diffinery.com", |
| 20 | + "MAIL_FROM": "notifications@diffinery.com", | |
| 20 | 21 | "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com", |
| 21 | 22 | "ACCESS_TEAM_DOMAIN": "", |
| 22 | 23 | "ACCESS_AUD": "" |
| @@ -27,11 +28,18 @@ | ||
| 27 | 28 | ], |
| 28 | 29 | "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data" }], |
| 29 | 30 | "queues": { |
| 30 | - "producers": [{ "binding": "EVENTS", "queue": "diffinery-events" }], | |
| 31 | + "producers": [ | |
| 32 | + { "binding": "EVENTS", "queue": "diffinery-events" }, | |
| 33 | + { "binding": "MAIL", "queue": "diffinery-mail" } | |
| 34 | + ], | |
| 31 | 35 | "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" } | |
| 33 | 38 | ] |
| 34 | 39 | }, |
| 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" }], | |
| 35 | 43 | "durable_objects": { |
| 36 | 44 | "bindings": [ |
| 37 | 45 | { "name": "REPO", "class_name": "RepoDO" }, |
| @@ -54,6 +62,7 @@ | ||
| 54 | 62 | "workers_dev": true, |
| 55 | 63 | "vars": { |
| 56 | 64 | "PUBLIC_URL": "http://localhost:8787", |
| 65 | + "MAIL_FROM": "notifications@diffinery.com", | |
| 57 | 66 | "BOOTSTRAP_ADMINS": "dev@diffinery.local", |
| 58 | 67 | "ACCESS_TEAM_DOMAIN": "", |
| 59 | 68 | "ACCESS_AUD": "", |
| @@ -62,9 +71,17 @@ | ||
| 62 | 71 | "d1_databases": [{ "binding": "DB", "database_name": "diffinery-dev", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }], |
| 63 | 72 | "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data-dev" }], |
| 64 | 73 | "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 | + ] | |
| 67 | 82 | }, |
| 83 | + // Local email: Miniflare captures sent messages instead of delivering them. | |
| 84 | + "send_email": [{ "name": "EMAIL" }], | |
| 68 | 85 | "durable_objects": { |
| 69 | 86 | "bindings": [ |
| 70 | 87 | { "name": "REPO", "class_name": "RepoDO" }, |
modifiedscripts/bootstrap.sh+7−2View file
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | #!/usr/bin/env bash |
| 2 | 2 | # Idempotent production setup for Diffinery on a Cloudflare account. |
| 3 | 3 | # |
| 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 | |
| 5 | 6 | # Cloudflare Access application for /auth/access, the email-based account |
| 6 | 7 | # recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners |
| 7 | 8 | # use Diffinery tokens. |
| @@ -63,7 +64,7 @@ | ||
| 63 | 64 | # ------------------------------------------------------------------ Queues |
| 64 | 65 | log "Queues" |
| 65 | 66 | 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 | |
| 67 | 68 | if grep -qx "$q" <<<"$EXISTING_QUEUES"; then |
| 68 | 69 | echo "queue $q exists" |
| 69 | 70 | else |
| @@ -73,6 +74,10 @@ | ||
| 73 | 74 | done |
| 74 | 75 | endlog |
| 75 | 76 | |
| 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 | + | |
| 76 | 81 | # ------------------------------------------------------------------ Access |
| 77 | 82 | log "Cloudflare Access" |
| 78 | 83 | 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." |