Diffinery

codician/diffinerypublic

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

Email suppression list with bounce and complaint intake, one-click unsubscribe by kind, and security.txt (#148)

- email_suppressions (migration 0026): hard bounces, spam complaints and manual entries.
  deliver() checks it before every send, sign-in links and recovery included; only the
  owner's verification email from Settings → Email gets past it, and its link lifts a
  bounce or complaint (a manual one needs a site administrator).
- Intake: the diffinery-mail-events queue (Cloudflare Email Sending event subscriptions),
  and POST /-/email/events signed with MAIL_EVENTS_SECRET (HMAC over timestamp and body,
  5-minute window; 404 while unset, 401 without a good signature).
- List-Unsubscribe (RFC 8058) now turns the email's kind off for the recipient; muting a
  thread stays a footer link.
- Site administration → Email lists suppressed addresses with Remove and Suppress an
  address, audited (email.suppression.add/remove/lift); REST endpoints for both sides.
- /.well-known/security.txt from SECURITY_CONTACT (404 when unset), Expires computed per
  request; docs/security/disclosure.md, linked from the docs index and security overview.


Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 9d3f439commit cd4c05c21d2b329f179ae2248f8d0330116c41c2Browse files
33 files changed+1206−39
Files (33)
modifiedapps/diffinery/docs/accounts/notifications.md+19−1View file
@@ -180,7 +180,7 @@
180180 | **Stop watching acme/website** | Emails you got because you watch the repository | Sets the repository back to participating |
181181 | **Unsubscribe from emails like this** | Every email you can turn off | Turns off email for that group (for example Watching) |
182182
183-The mail app's one-click button uses the first link: muting the thread for issue and pull request emails, turning off the group for the rest.
183+The mail app's one-click button (`List-Unsubscribe`) turns off email for the group the email belongs to, the same as **Unsubscribe from emails like this**: for an issue or pull request email, that's every Participating email (or every Watching one, when you got it because you watch the repository). To mute just one thread, use **Mute this thread** in the footer instead. You can turn the group back on in notification settings.
184184
185185 The links are signed, so they work without signing in and can't be altered to change someone else's settings. Opening one shows a page naming the change and the account (for example "Mute acme/website#42") with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links.
186186
@@ -221,6 +221,20 @@
221221 | `account` | You have a Diffinery account (account and membership changes). |
222222 | `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). |
223223
224+## When Diffinery stops emailing you
225+
226+When email to your address bounces for good (the mailbox doesn't exist or the server refuses it), or you mark an email from Diffinery as spam, Diffinery puts your address on its suppression list and sends nothing to it any more: no notifications, and no security alerts, sign-in links or account recovery emails either. A site administrator can also suppress an address by hand.
227+
228+**Settings → Email** (`/settings/email`) shows whether your address is suppressed, since when and why, and the notifications settings page shows a warning too. To get email again after a bounce or a spam report:
229+
230+1. Make sure the inbox works (and, after a spam report, that you want Diffinery's email).
231+2. On **Settings → Email**, click **Send a verification email**. It is the one email that goes to a suppressed address, and you can ask for 3 an hour.
232+3. Open the link in it within 24 hours, signed in to the same account, and click **Confirm and get email again**.
233+
234+If a site administrator suppressed your address, only an administrator can lift it. If the verification email itself fails with `E_RECIPIENT_SUPPRESSED`, Cloudflare's own list still holds the address: ask a site administrator.
235+
236+With the API: `GET /api/v3/diffinery/email` returns your address and `suppressed` (null, or `reason`, `detail`, `created_at` and `owner_can_lift`), and `POST /api/v3/diffinery/email/verification` sends the verification email.
237+
224238 ## Every email Diffinery sends
225239
226240 The **Kind** column is the `X-Diffinery-Kind` header. Site administrators can preview every one of these under **Settings → Email** (see [Site administration](/docs/admin/site-administration#email)).
@@ -244,6 +258,7 @@
244258 | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up |
245259 | `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. |
246260 | `signup_attempt` | `[Diffinery] Someone tried to sign up with your email address` | Account | `account` | With open sign-up, someone asked for a code for your address, which already has an account (no code is sent) |
261+| `email_verify` | `[Diffinery] Confirm your email address to get email again` | Account | `account` | You asked for it on **Settings → Email** while your address was suppressed. It is the only email sent to a suppressed address. See [When Diffinery stops emailing you](#when-diffinery-stops-emailing-you). |
247262 | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you |
248263 | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) |
249264 | `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) |
@@ -300,6 +315,8 @@
300315 | `DELETE /api/v3/repos/{owner}/{repo}/subscription` | Back to participating | `write` or `admin` |
301316 | `GET /api/v3/diffinery/emails` | Your recent emails: `kind`, `subject`, `status` (`sent`, `failed` or `skipped`), `detail`, `message_id`, `created_at`. Query: `kind`, `since` (ISO time), `per_page` (default 50, up to 500) | any |
302317 | `POST /api/v3/diffinery/emails/test` | Send yourself a test email now | `write` or `admin`, all repositories |
318+| `GET /api/v3/diffinery/email` | Your email address and whether it is suppressed | any |
319+| `POST /api/v3/diffinery/email/verification` | Send the email that lifts a bounce or complaint on your address | `write` or `admin`, all repositories |
303320
304321 The settings object, with its defaults:
305322
@@ -335,6 +352,7 @@
335352 | `no email address` | Your account has no email address. |
336353 | `account disabled` | Disabled accounts only get the email saying they were disabled or enabled. |
337354 | `email sending is not configured (no EMAIL binding)` | The instance can't send email; ask a site administrator. |
355+| `address is suppressed: ...` | Email to your address bounced or was reported as spam, or a site administrator suppressed it. See [When Diffinery stops emailing you](#when-diffinery-stops-emailing-you). |
338356
339357 ## How notifications differ from GitHub
340358
modifiedapps/diffinery/docs/admin/site-administration.md+56−3View file
@@ -12,7 +12,7 @@
1212 | Page | Path | What it is for |
1313 | --- | --- | --- |
1414 | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links |
15-| **Email** | `/settings/admin/email` | Email delivery status, template previews and the log of every email |
15+| **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email |
1616 | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance |
1717 | **Audit log** | `/settings/audit` | The most recent security-relevant events |
1818
@@ -224,15 +224,18 @@
224224 | **Sending** | "Cloudflare Email Sending is bound", or "not configured (no EMAIL binding): emails are logged, not sent" |
225225 | **Queue** | `diffinery-mail`, or "not bound: emails are dropped" |
226226 | **From** | The address emails are sent from: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty (see [Configuration](/docs/self-hosting/configuration)) |
227+| **Bounces and complaints** | Where they come from: the `diffinery-mail-events` queue, and whether the signed endpoint is on (`MAIL_EVENTS_SECRET` set) |
227228 | **Last 24 hours** | How many emails were sent, failed and skipped |
228229
229230 Buttons:
230231
231232 - **Send me a test email** sends one test email to you right away and shows the result.
232-- **Send every template to me** queues one sample of every template (33 emails) to your address, ignoring your notification settings. They show up under **Recent emails** as they are sent.
233+- **Send every template to me** queues one sample of every template to your address, ignoring your notification settings. They show up under **Recent emails** as they are sent.
233234
234235 Under **Templates**, pick a template from the menu to preview it in the page. **Open HTML** opens the HTML version in a new tab; **Plain text** shows the text version with its headers (`From`, `Subject`, `X-Diffinery-Reason`, `List-Unsubscribe` and the rest). The previews use sample data such as the repository `acme/website`.
235236
237+**Suppressed addresses** lists every address Diffinery sends nothing to, newest first, with why (`hard-bounce`, `complaint` or `manual`), where that came from and since when. **Remove** sends email to the address again; **Suppress an address** adds one by hand, with an optional note. Both are recorded in the audit log. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list).
238+
236239 **Recent emails** lists the last 100 emails sent to anyone, with the time, recipient address, subject, template kind, Cloudflare's message ID, the status (`sent`, `failed` or `skipped`) and the reason when it wasn't sent. The log keeps 90 days.
237240
238241 See [Notifications](/docs/accounts/notifications#every-email-diffinery-sends) for when each email is sent.
@@ -243,10 +246,57 @@
243246 | --- | --- | --- |
244247 | `GET /api/v3/diffinery/admin/email` | `sending` and `queue` (whether they are configured), `from` (the sender address: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty), `last_24h` counts (`sent`, `failed`, `skipped`) and the `last` email logged | site administrator, any token scope |
245248 | `POST /api/v3/diffinery/admin/emails/samples` | Send every template to yourself now, or only some with `{"only": ["signin", "invite-org"]}`. Returns `to`, `sent`, `total` and each result. | site administrator, with an `admin` token for all repositories or a browser session |
249+| `GET /api/v3/diffinery/admin/email/suppressions` | The suppression list, newest first: `email`, `reason`, `detail`, `source` (`cloudflare`, `webhook` or `admin`), `created_by`, `created_at` and `owner_can_lift` | site administrator, any token scope |
250+| `PUT /api/v3/diffinery/admin/email/suppressions/{email}` | Suppress an address by hand, with an optional `{"note": "..."}`. Audited as `email.suppression.add`. | site administrator, with an `admin` token or a browser session |
251+| `DELETE /api/v3/diffinery/admin/email/suppressions/{email}` | Remove an address from the list, whatever put it there; `204`, or `404` when it isn't there. Audited as `email.suppression.remove`. | site administrator, with an `admin` token or a browser session |
246252 | `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` |
247253
248-The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`.
254+The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `email-verify`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`.
249255
256+### Bounces, complaints and the suppression list
257+
258+Diffinery sends no email at all to an address on the suppression list: not notifications, not security alerts, not sign-in links or account recovery. Such an email shows in **Recent emails** as `skipped` with "address is suppressed: ..." as the reason. An address gets on the list three ways:
259+
260+| Reason | How |
261+| --- | --- |
262+| `hard-bounce` | Email to it bounced permanently (the mailbox or domain doesn't exist, or the receiving server refuses it for good). Soft bounces and delays change nothing. |
263+| `complaint` | Its owner marked an email from Diffinery as spam. |
264+| `manual` | A site administrator added it, under **Suppress an address** or with the API. |
265+
266+A newer event replaces an older one for the same address. Every addition is in the audit log as `email.suppression.add`.
267+
268+**Lifting a suppression.** A site administrator can remove any address (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`). People can lift a bounce or complaint on their own address themselves: **Settings → Email** shows that the address is suppressed and why, and **Send a verification email** sends one email to it, past the list. Following its link while signed in to the same account turns email back on (`email.suppression.lift` in the audit log). The link works for 24 hours, and at most 3 can be asked for in an hour. A `manual` suppression can only be removed by an administrator. See [Notifications](/docs/accounts/notifications#when-diffinery-stops-emailing-you).
269+
270+Cloudflare Email Sending keeps a suppression list of its own, for bounces and complaints it sees. An address on Cloudflare's list fails with `E_RECIPIENT_SUPPRESSED` in **Recent emails**, even after you remove it from Diffinery's; remove it in the Cloudflare dashboard too (**Email Service → Email Sending → Suppressions**).
271+
272+#### Where bounces and complaints come from
273+
274+Cloudflare Email Sending publishes delivery events (`message.delivered`, `message.bounced`, `message.complained` and others) through [event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) to a queue. Diffinery consumes the `diffinery-mail-events` queue: the deploy creates it and the Worker reads it, but **you must subscribe it to your sending domain's events once**, since Cloudflare has no setting for that in `wrangler.jsonc`:
275+
276+1. In the Cloudflare dashboard, open **Storage & databases → Queues** and select `diffinery-mail-events`.
277+2. On the **Subscriptions** tab, click **Subscribe to events**.
278+3. Name the subscription (for example `diffinery-bounces`), pick **Email Sending** as the source and your sending domain (the domain of `MAIL_FROM`), and select at least the **bounced** and **complained** events. Others are ignored.
279+4. Click **Subscribe**.
280+
281+Only Cloudflare can publish to the queue, so its messages need no signature. Until the subscription exists, nothing reaches the list automatically; administrators can still add and remove addresses by hand.
282+
283+**The signed endpoint.** Anything else that can relay bounces and complaints, such as another provider's webhook through a small relay, can POST them to `https://<your host>/-/email/events`. It is off (`404`) until you set the `MAIL_EVENTS_SECRET` Worker secret, and it refuses (`401`) any request without a good signature:
284+
285+- Header `X-Diffinery-Signature: t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256, keyed with `MAIL_EVENTS_SECRET`, of `<t>.<the raw request body>`. Requests more than 5 minutes old (or ahead) are refused, so a captured request can't be replayed later.
286+- The body is JSON: one event, an array of events, or `{"events": [...]}`, at most 1,000 events and 1 MB. Each event has Cloudflare's shape: a `type` ending in `message.bounced` or `message.complained`, and a `payload` with `recipient` and, for bounces, `bounce.type` (`hard` or `soft`) and `bounce.reason`.
287+- The answer is `{"suppressed": <n>, "ignored": <n>}`. Events of any other type, and soft bounces, are ignored.
288+
289+```sh
290+secret=... # the value of MAIL_EVENTS_SECRET
291+body='{"type":"message.bounced","payload":{"recipient":"someone@example.com","bounce":{"type":"hard","reason":"550 5.1.1 User unknown"}}}'
292+t=$(date +%s)
293+sig=$(printf '%s' "$t.$body" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //')
294+curl -sS https://diffinery.example/-/email/events -H 'content-type: application/json' \
295+ -H "X-Diffinery-Signature: t=$t,v1=$sig" --data "$body"
296+```
297+
298+Set the secret with `wrangler secret put MAIL_EVENTS_SECRET -c wrangler.deploy.jsonc` (see [Configuration](/docs/self-hosting/configuration#worker-secrets)).
299+
250300 ## Workflow scheduler
251301
252302 Scheduled workflows are started by Diffinery's own timer, every 5 minutes, which also catches up checks that came late (see [Scheduled workflows](/docs/actions/events#schedule)). When a scheduled run doesn't start, read the scheduler's state to tell a timer that stopped from a run that failed to start:
@@ -323,3 +373,6 @@
323373 | `403 only site admins can invite people without an organization` | Instance invites from the CLI or API need a site administrator; others must pass `--org`. |
324374 | **Sending** says "not configured" | The Worker has no `send_email` binding, so emails are logged as skipped. See [Configuration](/docs/self-hosting/configuration). |
325375 | Emails show as `failed` | The **Recent emails** detail has Cloudflare's error. Check the sending domain's DNS records and the queue. |
376+| Emails show as `skipped` with "address is suppressed" | The address bounced, reported spam or was suppressed by hand. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). |
377+| `E_RECIPIENT_SUPPRESSED` | The address is on Cloudflare's own suppression list. Remove it in the Cloudflare dashboard. |
378+| `401 missing or bad signature` from `/-/email/events` | The `X-Diffinery-Signature` header is missing, signed with another secret, or more than 5 minutes old. |
modifiedapps/diffinery/docs/index.md+1−0View file
@@ -21,6 +21,7 @@
2121 | Have one of your agents review pull requests for bugs, like Bugbot | [Agent review](/docs/agents/review) |
2222 | Script against Diffinery | [REST API guide](/docs/reference/rest-api), [REST API reference](/docs/api) and [CLI reference](/docs/reference/cli) |
2323 | Run your own instance | [Deploy Diffinery](/docs/self-hosting/deploy) |
24+| Report a security problem | [Reporting a vulnerability](/docs/security/disclosure) |
2425
2526 ## What you get
2627
modifiedapps/diffinery/docs/nav.json+2−1View file
@@ -84,7 +84,8 @@
8484 "pages": [
8585 "security/overview",
8686 "security/push-protection",
87- "security/audit-log"
87+ "security/audit-log",
88+ "security/disclosure"
8889 ]
8990 },
9091 {
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
@@ -115,6 +115,9 @@
115115 | `actions.unsuspend` | A site administrator lifts that suspension (**Lift**, or `DELETE` on the same path) | `owner/name`, or the account's login | `kind`, `via` |
116116 | `compute.pause` | A site administrator pauses hosted compute on the instance (**Pause hosted compute**, or `PUT /api/v3/admin/hosted-compute-pause`) | none | `reason`, `via`, `stopped` |
117117 | `compute.unpause` | A site administrator resumes it (**Resume hosted compute**, or `DELETE /api/v3/admin/hosted-compute-pause`) | none | `via` |
118+| `email.suppression.add` | An address goes on the email suppression list: a permanent bounce or a spam complaint arrives (no actor, shown as `system`), or a site administrator adds it (**Suppress an address** on **Settings → Email**, or `PUT /api/v3/diffinery/admin/email/suppressions/{email}`). See [the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | the email address | `reason` (`hard-bounce`, `complaint` or `manual`), `source` (`cloudflare`, `webhook` or `admin`), `detail` (the provider's words or the administrator's note) |
119+| `email.suppression.remove` | A site administrator removes an address from the list (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`) | the email address | `reason` and `source` of the entry removed |
120+| `email.suppression.lift` | Someone lifts a bounce or complaint on their own address by following the verification link from **Settings → Email** | the email address | `reason` of the entry lifted |
118121
119122 ### Repositories
120123
addedapps/diffinery/docs/security/disclosure.md+68−0View file
@@ -1,0 +1,68 @@
1+---
2+title: Reporting a vulnerability
3+description: How to report a security problem in Diffinery, what is in scope, safe harbour for good-faith research, the response times we aim for, and why there is no bounty.
4+---
5+
6+If you think you have found a security problem in Diffinery, please tell us privately first, so we can fix it before anyone else uses it. This page says how, what we look at, how you are protected when you research in good faith, and what to expect from us.
7+
8+## How to report
9+
10+1. Find the contact address in this instance's [`/.well-known/security.txt`](/.well-known/security.txt). It lists one or more `Contact:` lines, an email address or a web form. If the file answers 404, the operator of this instance has not set a security contact; reach them another way and ask for one, without putting the details of the problem in a public place.
11+2. Send a report that lets us reproduce the problem:
12+ - what is affected (a URL, an API endpoint, a git or MCP operation, a CLI command);
13+ - the steps, requests or a short script that show it, and what happened compared to what you expected;
14+ - what an attacker could do with it, as far as you know;
15+ - whether you have told anyone else, and when you would like to publish.
16+3. Write in English if you can. Plain text is fine; attach screenshots or logs if they help.
17+
18+Please don't report in a public issue, a pull request, a discussion or social media. If the report includes a secret you came across (a token, a key, someone's data), say so and don't keep a copy.
19+
20+## In scope
21+
22+- The Diffinery application: the web UI, the REST API (`/api/v3`), git over HTTPS, the MCP server, the container registry, webhooks, Actions (workflow runs, runners, OIDC tokens, secrets), agent tasks and sandboxes, and the `diffinery` CLI.
23+- The source code of Diffinery itself, including the scripts that deploy it.
24+- Problems such as: reading or changing data you shouldn't, getting around sign-in, branch protection, push protection, token scopes, agent guardrails or kill switches, escaping a job or sandbox container, reaching secrets, cross-site scripting or request forgery, and server-side request forgery.
25+
26+## Out of scope
27+
28+- Cloudflare's own platform and services, and other third-party services. Report those to their owners.
29+- Another operator's instance of Diffinery, unless the problem is in Diffinery's code: then report it here, and only test it on an instance you run yourself or have permission to test.
30+- Denial of service by sheer volume, spam, and load testing.
31+- Social engineering, phishing, and physical attacks on people or offices.
32+- Reports from automated scanners with no shown impact, missing headers that change nothing in practice, and self-XSS.
33+- Anything that needs a compromised device, browser or account to begin with.
34+
35+## Safe harbour
36+
37+If you research and report in good faith and follow this page, we will consider your research authorised, we won't take legal action against you or ask anyone else to, and if someone else does we will say that you acted with our permission. Good faith means you:
38+
39+- test only against accounts and repositories that you own or have permission to use, and stop as soon as you have shown the problem;
40+- don't access, change, keep or share other people's data beyond the minimum needed to show the problem, and don't degrade the service for others;
41+- don't use what you found for anything other than reporting it;
42+- give us a reasonable time to fix it before you publish anything, and agree the date with us.
43+
44+If you aren't sure whether something is allowed, ask first through the security contact.
45+
46+This safe harbour covers Diffinery's own claims only. It can't speak for Cloudflare, other services, or the operator of an instance you don't own.
47+
48+## What to expect
49+
50+These are the times we aim for, not promises: a small team runs Diffinery, and a hard problem can take longer.
51+
52+| Step | Target |
53+| --- | --- |
54+| We confirm we received your report | 3 working days |
55+| We tell you whether we can reproduce it, and how serious we think it is | 10 working days |
56+| We fix a critical or high-severity problem | 30 days |
57+| We fix other problems | 90 days |
58+
59+We keep you informed while we work on it, tell you when the fix is released, and, if you want, credit you by name in the release notes. We ask you to wait for the fix, or for 90 days from your report if we haven't agreed another date, before publishing.
60+
61+## No bounty
62+
63+There is no bug bounty: we don't pay for reports. We are grateful for every one, and are glad to credit you.
64+
65+## See also
66+
67+- [Security overview](/docs/security/overview), for how Diffinery protects accounts, code and secrets.
68+- [Configuration](/docs/self-hosting/configuration#security-contact), for operators: setting the contact that `security.txt` serves.
modifiedapps/diffinery/docs/security/overview.md+4−0View file
@@ -289,3 +289,7 @@
289289 - **Global data** (accounts, organizations, organization secrets, tokens, invites, notifications and the audit log) lives in a separate D1 database.
290290
291291 See [Architecture](/docs/self-hosting/architecture).
292+
293+## Reporting a vulnerability
294+
295+Found a security problem? Report it privately to the contact in [`/.well-known/security.txt`](/.well-known/security.txt). [Reporting a vulnerability](/docs/security/disclosure) explains what is in scope, the safe harbour for good-faith research and the response times we aim for.
modifiedapps/diffinery/docs/self-hosting/architecture.md+4−1View file
@@ -15,6 +15,7 @@
1515 | Large git objects, LFS objects, archived job logs, caches, artifacts, container image layers and manifests, the event archive, release assets, cached source archives, profile pictures | R2 | `BUCKET` |
1616 | Webhooks (and retries of failed deliveries), notifications, audit fan-out | Queues (`diffinery-events`, dead-letter queue `diffinery-events-dlq`) | `EVENTS` |
1717 | Email jobs | Queues (`diffinery-mail`, dead-letter queue `diffinery-mail-dlq`) | `MAIL` |
18+| Bounces and complaints | Queue `diffinery-mail-events`, fed by Email Sending's event subscriptions | (consumer only) |
1819 | Workflow run orchestration (durable across restarts) | Workflows (`diffinery-run`, class `RunWorkflow`) | `RUN_WORKFLOW` |
1920 | Hosted job runners | Containers (`RunnerContainer`, one container per job) | `RUNNER` |
2021 | Agent sandboxes | Containers (`SandboxContainer`, one container per sandbox) | `SANDBOX` |
@@ -144,8 +145,10 @@
144145
145146 The same queue carries webhook retries. When a delivery fails with a network error or timeout, `408`, `429` or a `5xx` status, the consumer puts a retry message for that one webhook back on the queue with a delay: 1 minute, then 5 minutes, 30 minutes, 1 hour and 3 hours, so a delivery is tried at most 6 times, the last one about 4 hours and 36 minutes after the first. A retry sends the same body with the same `X-GitHub-Delivery` ID, to the webhook's current URL and signed with its current secret; it is dropped if the webhook was deleted in the meantime. Other statuses, including `3xx` and other `4xx` answers, are not retried. Webhook and stage retry messages are not added to the R2 event archive.
146147
147-**`diffinery-mail`** carries email jobs. It is separate so that sign-in links and invitation links never land in the event archive. The consumer applies the recipient's notification settings, renders the HTML and text versions, and sends through Email Sending. Temporary failures are retried with backoff; after 6 attempts the email is logged as failed.
148+**`diffinery-mail`** carries email jobs. It is separate so that sign-in links and invitation links never land in the event archive. The consumer applies the recipient's notification settings, renders the HTML and text versions, and sends through Email Sending. Temporary failures are retried with backoff; after 6 attempts the email is logged as failed. Before sending, it checks the suppression list: nothing goes to an address that bounced for good or reported spam.
148149
150+**`diffinery-mail-events`** receives Email Sending's delivery events. The consumer adds the recipient of a permanent bounce or a spam complaint to the suppression list and ignores the rest.
151+
149152 ## Workflows
150153
151154 Each workflow run is one instance of the `diffinery-run` Workflow, with the run's ID as the instance ID. `RunWorkflow` makes a run durable: its steps, sleeps and waits survive restarts and deploys.
modifiedapps/diffinery/docs/self-hosting/configuration.md+18−1View file
@@ -41,6 +41,7 @@
4141 | `TERMINAL_POLL_MS` | Not set | Never in production | The end-to-end tests only: how often open web terminals are checked for their limits and their agent's access, in milliseconds, from `2000` to `60000` (the default). It counts only on a Worker on this machine; a deployed Worker ignores it. |
4242 | `EGRESS` | Not set | No | `web` limits hosted Actions jobs and agent sandboxes (including tasks, reviews and terminals) to HTTP and HTTPS (ports 80 and 443) and DNS. Any other value, or none, leaves them open to the whole internet. Accounts run by a site administrator stay open. See [EGRESS](#egress). |
4343 | `RUNNER_IMAGE` | Set by the deploy | No | The runner image's name and tag (for example `diffinery-runner:96dbbe31790f`), as jobs report it in `runner.image`. `scripts/render-config.mjs` sets it when the deploy uses a prebuilt runner image, and `GET /api/v3/meta` returns it as `runner_image` (`null` when not set). Don't set it by hand. |
44+| `SECURITY_CONTACT` | Not set | No | Where people report a vulnerability in your instance, served in `/.well-known/security.txt`: an email address or an `https://` URL (a web form), several separated by commas. Unset, `security.txt` answers 404. See [Security contact](#security-contact). |
4445 | `TERMINALS` | Not set | No | `off` switches [terminals](/docs/agents/terminal) off: nobody can open one, and open ones end at their next check, within a minute. Anything else, or not set, leaves them on. |
4546
4647 When either `ACCESS_TEAM_DOMAIN` or `ACCESS_AUD` is empty, email sign-in at `/auth/access` is off: the page answers "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't offer **Recover your account by email**. Passkeys, invitations and admin sign-in links keep working, but the first administrator can't sign in, because email sign-in is how an account in `BOOTSTRAP_ADMINS` is created.
@@ -122,6 +123,20 @@
122123
123124 All four steps pass. The last one fails if port 22 is reachable, which means the account is open (a site administrator runs it) or the setting isn't deployed.
124125
126+### Security contact
127+
128+`/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116)) tells security researchers where to report a problem. Diffinery serves it only when `SECURITY_CONTACT` is set, with:
129+
130+| Field | Value |
131+| --- | --- |
132+| `Contact` | One line per entry of `SECURITY_CONTACT`. An email address becomes a `mailto:` URI; `https://`, `mailto:` and `tel:` URIs are kept as they are; anything else is left out. |
133+| `Expires` | Midnight UTC 360 days after the request, so the file never goes stale. |
134+| `Policy` | `<PUBLIC_URL>/docs/security/disclosure`, the [disclosure policy](/docs/security/disclosure). |
135+| `Preferred-Languages` | `en` |
136+| `Canonical` | `<PUBLIC_URL>/.well-known/security.txt` |
137+
138+Use an address someone reads. The repository leaves it unset on purpose, so an instance never advertises an address its operator didn't choose. To set it, add `"SECURITY_CONTACT": "security@example.com"` under `vars` in `wrangler.jsonc`; the deploy keeps it.
139+
125140 ### DEV_AUTH
126141
127142 When `DEV_AUTH` is set, `ACCESS_AUD` is empty and both `PUBLIC_URL` and the request's own address are on the local machine (`localhost`, `127.x.x.x` or `[::1]`), every request without credentials (no token, no session) acts as the account with the email in `DEV_AUTH`, with full access. That account is created on first use and is a site administrator if the email is also in `BOOTSTRAP_ADMINS`. While `DEV_AUTH` is set, the email log also keeps each whole message, and `GET /api/v3/diffinery/emails` returns it, so tests can read emails without delivering them.
@@ -137,9 +152,10 @@
137152 | `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Open sign-up can't send codes. |
138153 | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. |
139154 | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. |
155+| `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. |
140156 | `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | You, optionally | With `SIGNUP=open` and `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code passed the Turnstile widget. See [SIGNUP](#signup). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured" (local development skips the check). Without `SIGNUP=open` it isn't used. |
141157
142-Neither secret is rotated automatically. Changing `MASTER_KEY` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out; keep a copy somewhere safe. To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`.
158+None of these secrets is rotated automatically. Changing `MASTER_KEY` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out; keep a copy somewhere safe. To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`.
143159
144160 To set a secret by hand (for example to restore a backup):
145161
@@ -159,6 +175,7 @@
159175 | `EVENTS` | Queue producer, with a consumer on the same Worker | `diffinery-events`, dead-letter queue `diffinery-events-dlq` | Repository events for webhooks, notifications and the audit log. Consumer: batches of up to 50, waiting up to 2 seconds, 8 retries. |
160176 | `MAIL` | Queue producer, with a consumer on the same Worker | `diffinery-mail`, dead-letter queue `diffinery-mail-dlq` | Email jobs. Consumer: batches of up to 20, waiting up to 2 seconds, 8 retries. Optional: without it emails are dropped with a warning in the logs. |
161177 | `EMAIL` | `send_email` (Email Sending) | Your onboarded domain | Sending email. Optional: without it every email is logged as `skipped`. |
178+| (none) | Queue consumer | `diffinery-mail-events` | Bounces and complaints from Email Sending's event subscriptions, which you subscribe this queue to (see [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)). Consumer: batches of up to 50, waiting up to 5 seconds, 8 retries. |
162179 | `RL_AUTH`, `RL_ANON`, `RL_ACCOUNT`, `RL_CREATE` | Rate limiting (`ratelimits`) | Namespaces `1001` to `1004`; 20, 300, 2,000 and 60 requests per 60 seconds | [Request rate limits](/docs/reference/rest-api#rate-limits): sign-in per IP address, anonymous requests per IP address, signed-in requests per account, and new content per account. Optional: a missing binding (the `dev` environment declares none) or one that fails lets requests through. If you change a limit, change `RATE_LIMITS` in `src/lib/rate-limit.ts` to match, since the 429 messages quote it. Namespace IDs are shared across your Cloudflare account, so pick others if another Worker already uses these. |
163180 | `REPO` | Durable Object namespace | Class `RepoDO` (SQLite) | One object per repository. |
164181 | `SCHEDULER` | Durable Object namespace | Class `SchedulerDO` (SQLite) | One object that starts scheduled workflow runs on its own alarm. |
modifiedapps/diffinery/docs/self-hosting/deploy.md+2−2View file
@@ -68,7 +68,7 @@
6868
6969 **Other workflows.** `.github/workflows/live-test.yml` and `.github/workflows/sync-to-diffinery.yml` test and mirror the public `diffinery.com` instance. Delete them, or change their `DIFFINERY` host and organization names before you enable them. The sync workflow runs only on GitHub: it copies each pushed branch or tag to the instance, copies the deploy secrets and the `DIFFINERY_DOMAIN`, `DIFFINERY_ZONE`, `MAIL_DOMAIN` and `ACCESS_TEAM_DOMAIN` variables, and for `main` waits for the Actions runs there, so the copy on Diffinery deploys itself (see [Configuration](/docs/self-hosting/configuration#repository-secrets-and-variables-for-the-workflows)). `ci.yml` (typecheck, tests and the end-to-end test) works as is. `dind-probe.yml` is a maintenance probe started by hand; you can delete it. `d1-replication.yml` is optional; see [Architecture](/docs/self-hosting/architecture#d1-and-read-replication).
7070
71-> **Note:** The scripts use fixed resource names: a D1 database `diffinery`, an R2 bucket `diffinery-data`, queues `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail` and `diffinery-mail-dlq`, and a Worker `diffinery`. One Cloudflare account holds one instance unless you rename them in `scripts/bootstrap.sh` and `wrangler.jsonc`.
71+> **Note:** The scripts use fixed resource names: a D1 database `diffinery`, an R2 bucket `diffinery-data`, queues `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail`, `diffinery-mail-dlq` and `diffinery-mail-events`, and a Worker `diffinery`. One Cloudflare account holds one instance unless you rename them in `scripts/bootstrap.sh` and `wrangler.jsonc`.
7272
7373 ## Step 2: Create a Cloudflare API token
7474
@@ -124,7 +124,7 @@
124124
125125 1. **D1 database.** Looks for a database named `diffinery` and creates it if missing.
126126 2. **R2 bucket.** Creates `diffinery-data` if missing, then sets its lifecycle rules, which delete short-lived data (such as expired artifacts, old job logs and unfinished uploads) by age and never touch git objects, LFS objects or release assets. The rules are set on every deploy and replace any lifecycle rules you added in the dashboard. If they can't be set, or a rule would still delete git objects, bootstrap stops the deploy. See [R2](/docs/self-hosting/architecture#r2) for the rules.
127-3. **Queues.** Creates `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail` and `diffinery-mail-dlq` if missing.
127+3. **Queues.** Creates `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail`, `diffinery-mail-dlq` and `diffinery-mail-events` if missing. Subscribing `diffinery-mail-events` to Email Sending's bounce and complaint events is a manual step, see [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list).
128128 4. **Email Sending.** Runs `scripts/email-sending.sh` for `MAIL_DOMAIN` (default `DIFFINERY_DOMAIN`). A failure here is only a warning: the deploy continues and email is skipped until sending works.
129129 5. **Cloudflare Access.** Finds your team domain (from `ACCESS_TEAM_DOMAIN`, else from your Zero Trust organization through the API). Then it creates, or updates, a self-hosted Access application named **Diffinery account recovery** that covers only `<domain>/auth/access`, with a 15-minute Access session, an HTTP-only cookie and a policy that lets anyone who proves their email through. Diffinery, not Access, decides what that person may do. It also deletes an application named **Diffinery machine endpoints** left by older versions. If it still has no team domain, it requests `https://<domain>/auth/access` and reads the team domain from the Access redirect.
130130 6. **Render config and migrate D1.** Runs `scripts/render-config.mjs` with `--public-url https://<DIFFINERY_DOMAIN>`, `--zone <DIFFINERY_ZONE>` (only when set) and `--mail-from notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`, and then `wrangler d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc`.
addedapps/diffinery/migrations/0027_email_suppressions.sql+12−0View file
@@ -1,0 +1,12 @@
1+-- Email suppressions (mail/suppression.ts): addresses Diffinery no longer sends any email to, because
2+-- mail to them bounced for good, their owner reported an email as spam, or a site administrator added
3+-- them. Every send checks this list first. One row per address; a newer event replaces the row.
4+CREATE TABLE IF NOT EXISTS email_suppressions (
5+ email TEXT PRIMARY KEY, -- lower case
6+ reason TEXT NOT NULL CHECK (reason IN ('hard-bounce', 'complaint', 'manual')),
7+ detail TEXT, -- the provider's words (an SMTP reply) or the administrator's note
8+ source TEXT NOT NULL, -- 'cloudflare' (Event Subscriptions queue), 'webhook' (signed intake) or 'admin'
9+ created_by TEXT, -- the administrator's login, for manual ones
10+ created_at TEXT NOT NULL
11+);
12+CREATE INDEX IF NOT EXISTS email_suppressions_at ON email_suppressions(created_at);
modifiedapps/diffinery/package.json+1−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit && tsc --noEmit -p islands",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.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 test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
1414 "build": "node islands/build.mjs && node scripts/build-docs.mjs",
1515 "predev": "node islands/build.mjs && node scripts/build-docs.mjs",
modifiedapps/diffinery/src/app.ts+6−0View file
@@ -27,6 +27,8 @@
2727 import { mailCron, notifyEvent } from "./mail/notify.js";
2828 import { schedulerStub } from "./lib/schedules.js";
2929 import { mailQueue } from "./mail/send.js";
30+import { mailEvents, mailEventsQueue } from "./mail/events.js";
31+import { securityTxtRoutes } from "./http/security-txt.js";
3032 import { onReviewEvent } from "./agentic/reviews.js";
3133 import { onVerifyEvent } from "./agentic/tasks.js";
3234 import { recordActivity } from "./agentic/today.js";
@@ -88,9 +90,11 @@
8890
8991 app.route("/", avatars);
9092 app.route("/", wellKnown);
93+app.route("/", securityTxtRoutes);
9194 app.route("/", git);
9295 app.route("/", runner);
9396 app.route("/", mailApi);
97+app.route("/", mailEvents);
9498 app.route("/", registry);
9599 app.route("/", packagesApi);
96100 app.route("/", actionsRuntime);
@@ -198,6 +202,8 @@
198202 }
199203
200204 export async function queue(batch: MessageBatch<DiffineryEvent | MailJob>, env: Env): Promise<void> {
205+ // Bounces and complaints from Cloudflare Email Sending's Event Subscriptions (mail/events.ts).
206+ if (batch.queue.startsWith("diffinery-mail-events")) return mailEventsQueue(batch as MessageBatch<unknown>, env);
201207 // Email has its own queue (diffinery-mail): its jobs can hold sign-in and invite links, so they stay out of the event archive.
202208 if (batch.queue.startsWith("diffinery-mail")) return mailQueue(batch as MessageBatch<MailJob>, env);
203209 return events(batch as MessageBatch<DiffineryEvent>, env);
modifiedapps/diffinery/src/docs/api-meta.ts+18−1View file
@@ -98,6 +98,10 @@
9898 "PUT /diffinery/notification-settings": { scope: "write" },
9999 "POST /diffinery/emails/test": { scope: "write" },
100100 "POST /diffinery/admin/emails/samples": { scope: "admin" },
101+ "POST /diffinery/email/verification": { scope: "write" },
102+ "GET /diffinery/admin/email/suppressions": { scope: "read" },
103+ "PUT /diffinery/admin/email/suppressions/{email}": { scope: "admin" },
104+ "DELETE /diffinery/admin/email/suppressions/{email}": { scope: "admin" },
101105 "PUT /user/avatar": { scope: "write" },
102106 "DELETE /user/avatar": { scope: "write" },
103107 "PUT /orgs/{org}/avatar": { scope: "write" },
@@ -1444,7 +1448,20 @@
14441448 query: { kind: ["string", "Only this kind of email."], since: ["string", "ISO timestamp."], per_page: ["integer", "At most this many (default 50)."], all: ["string", "`1` for everyone's (site admins)."] },
14451449 },
14461450 "POST /diffinery/emails/test": { summary: "Send yourself a test email", description: "At most 5 an hour; more answer 429 `you can send at most 5 test emails an hour; try again later`.", access: "account" },
1447- "GET /diffinery/admin/email": { summary: "Email delivery status", description: "Whether sending is configured, and counts for the last 24 hours.", access: "site-admin" },
1451+ "GET /diffinery/email": {
1452+ summary: "Your email address and whether it is suppressed",
1453+ description: "`suppressed` is null, or why Diffinery sends nothing to your address: `reason` (`hard-bounce`, `complaint` or `manual`), `detail`, `source`, `created_at` and `owner_can_lift`.",
1454+ access: "account",
1455+ },
1456+ "POST /diffinery/email/verification": {
1457+ summary: "Ask for the email that turns email back on",
1458+ description: "When your address is suppressed by a bounce or complaint, sends one email to it, past the suppression list; following its link while signed in lifts the suppression. 409 when it isn't suppressed, 403 for a suppression a site administrator added, at most 3 an hour.",
1459+ access: "account",
1460+ },
1461+ "GET /diffinery/admin/email": { summary: "Email delivery status", description: "Whether sending is configured, whether the signed bounce and complaint endpoint is on (`event_intake`), and counts for the last 24 hours.", access: "site-admin" },
1462+ "GET /diffinery/admin/email/suppressions": { summary: "List suppressed email addresses", description: "Newest first: `email`, `reason` (`hard-bounce`, `complaint` or `manual`), `detail`, `source` (`cloudflare`, `webhook` or `admin`), `created_by` and `created_at`.", access: "site-admin" },
1463+ "PUT /diffinery/admin/email/suppressions/{email}": { summary: "Suppress an email address", description: "Diffinery sends nothing to it until it is removed. Recorded in the audit log.", access: "site-admin", body: { note: ["string", "Why, kept with the entry."] } },
1464+ "DELETE /diffinery/admin/email/suppressions/{email}": { summary: "Remove an address from the suppression list", description: "Whatever put it there. 404 when it isn't suppressed. Recorded in the audit log.", access: "site-admin", status: 204 },
14481465 "GET /diffinery/admin/scheduler": {
14491466 summary: "Workflow scheduler state",
14501467 description: "`tick_minutes`, `last_tick_at` (when the last check finished), `covered_until` (the last minute whose schedules were started) and `next_tick_at` (null when the timer isn't set). Tells a scheduler that stopped ticking from a run that failed to start.",
modifiedapps/diffinery/src/docs/reference.ts+3−0View file
@@ -260,6 +260,9 @@
260260 ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."],
261261 ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."],
262262 ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."],
263+ ["GET /.well-known/security.txt", "Where to report a vulnerability (RFC 9116), from the instance's `SECURITY_CONTACT`; 404 when it isn't set. See [Reporting a vulnerability](/docs/security/disclosure)."],
264+ ["POST /-/email/events", "Signed bounce and complaint intake for the email suppression list; 404 until `MAIL_EVENTS_SECRET` is set. See [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)."],
265+ ["GET, POST /-/unsubscribe/{token}", "One-click unsubscribe from a notification email (RFC 8058): a POST with `List-Unsubscribe=One-Click` turns that kind of email off. See [Notifications](/docs/accounts/notifications)."],
263266 ["GET /healthz", "Health check: `{\"ok\": true}`."],
264267 ["GET /llms.txt", "Index of these docs for AI agents. See [Docs for AI agents](/docs/agents/docs-for-agents)."],
265268 ["GET /llms-full.txt", "All of these docs in one Markdown file."],
modifiedapps/diffinery/src/env.ts+7−0View file
@@ -70,6 +70,11 @@
7070 PUBLIC_URL: string;
7171 /** Sender address for every email, e.g. notifications@diffinery.com. */
7272 MAIL_FROM?: string;
73+ /**
74+ * Where security researchers report vulnerabilities, served in /.well-known/security.txt: an email
75+ * address or an https URL, several separated by commas. Unset, security.txt answers 404.
76+ */
77+ SECURITY_CONTACT?: string;
7378 /** Comma-separated emails or logins that become site admins on first sign-in. */
7479 BOOTSTRAP_ADMINS: string;
7580 /** e.g. codician.cloudflareaccess.com — empty disables Access verification. */
@@ -94,6 +99,8 @@
9499 MASTER_KEY?: string;
95100 /** Secret: ES256 private JWK (JSON) used to sign OIDC tokens for jobs. */
96101 OIDC_PRIVATE_KEY?: string;
102+ /** Secret: signs bounce and complaint events posted to /-/email/events (mail/events.ts); unset, that endpoint is off. */
103+ MAIL_EVENTS_SECRET?: string;
97104 /** Secret: Cloudflare Turnstile secret key, to check the sign-up page's widget (siteverify). */
98105 TURNSTILE_SECRET?: string;
99106 /** Optional GitHub Enterprise host (e.g. github.example.com) the importer may download release assets from. */
addedapps/diffinery/src/http/security-txt.ts+49−0View file
@@ -1,0 +1,49 @@
1+import { Hono } from "hono";
2+import type { AppVars } from "../auth/principal.js";
3+import type { Env } from "../env.js";
4+
5+/**
6+ * /.well-known/security.txt (RFC 9116): where to report a vulnerability in this instance. The
7+ * contact comes from SECURITY_CONTACT only; without it the file is 404 rather than an address
8+ * nobody reads. Expires is a year from the request, so the file never goes stale.
9+ */
10+
11+/** How far ahead Expires is (RFC 9116 recommends less than a year). */
12+export const EXPIRES_DAYS = 360;
13+
14+/** One Contact URI per entry of SECURITY_CONTACT: email addresses become mailto: URIs; https URLs stay as they are. */
15+export function contactUris(v: string | undefined): string[] {
16+ const out: string[] = [];
17+ for (const raw of (v ?? "").split(",")) {
18+ const s = raw.trim();
19+ if (!s) continue;
20+ if (/^mailto:[^\s@]+@[^\s@]+$/i.test(s) || /^https:\/\/\S+$/i.test(s) || /^tel:\+?[\d-]+$/i.test(s)) out.push(s);
21+ else if (/^[^\s@:/]+@[^\s@/]+\.[^\s@/]+$/.test(s)) out.push(`mailto:${s}`);
22+ }
23+ return out;
24+}
25+
26+/** The file's text, or null when no contact is configured. */
27+export function securityTxt(env: Pick<Env, "PUBLIC_URL" | "SECURITY_CONTACT">, nowMs = Date.now()): string | null {
28+ const contacts = contactUris(env.SECURITY_CONTACT);
29+ if (!contacts.length) return null;
30+ const base = new URL(env.PUBLIC_URL).origin;
31+ const expires = new Date(nowMs + EXPIRES_DAYS * 86400_000);
32+ expires.setUTCHours(0, 0, 0, 0);
33+ return [
34+ ...contacts.map((c) => `Contact: ${c}`),
35+ `Expires: ${expires.toISOString().replace(".000Z", "Z")}`,
36+ `Policy: ${base}/docs/security/disclosure`,
37+ "Preferred-Languages: en",
38+ `Canonical: ${base}/.well-known/security.txt`,
39+ "",
40+ ].join("\n");
41+}
42+
43+export const securityTxtRoutes = new Hono<{ Bindings: Env; Variables: AppVars }>();
44+
45+securityTxtRoutes.get("/.well-known/security.txt", (c) => {
46+ const txt = securityTxt(c.env);
47+ if (!txt) return c.text("No security contact is configured for this instance.\n", 404);
48+ return c.body(txt, 200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=86400" });
49+});
modifiedapps/diffinery/src/mail/api.ts+39−4View file
@@ -8,13 +8,15 @@
88 import { MailStore, mergePrefs, type WatchLevel } from "./prefs.js";
99 import { sampleJobs } from "./samples.js";
1010 import { deliver, senderAddress } from "./send.js";
11-import { sendTest } from "./web.js";
11+import { Suppressions } from "./suppression.js";
12+import { adminSuppress, adminUnsuppress, sendTest, sendVerification, suppressionJson } from "./web.js";
1213
1314 /**
1415 * Email over the REST API: repository watching in GitHub's shape
1516 * (`/repos/{owner}/{repo}/subscription`), plus Diffinery's own notification
16- * settings, email log, test email and, for site admins, delivery status and
17- * "send every template".
17+ * settings, email log, test email, whether your address is suppressed and,
18+ * for site admins, delivery status, the suppression list and "send every
19+ * template".
1820 */
1921 export const mailApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
2022
@@ -124,11 +126,44 @@
124126 return c.json(await sendTest(c, me));
125127 });
126128
129+/** Your address, and whether it is on the suppression list (suppression.ts). */
130+mailApi.get("/diffinery/email", async (c) => {
131+ const me = user(c);
132+ const s = me.email ? await new Suppressions(c.env.DB).get(me.email) : null;
133+ return c.json({ email: me.email ?? null, suppressed: s ? suppressionJson(s) : null });
134+});
135+
136+/** Email yourself the link that lifts a bounce or complaint on your address. */
137+mailApi.post("/diffinery/email/verification", async (c) => {
138+ requireAccountScope(c, "write");
139+ const me = writer(c);
140+ return c.json(await sendVerification(c, me));
141+});
142+
143+mailApi.get("/diffinery/admin/email/suppressions", async (c) => {
144+ siteAdmin(c, "read");
145+ return c.json((await new Suppressions(c.env.DB).list()).map(suppressionJson));
146+});
147+
148+mailApi.put("/diffinery/admin/email/suppressions/:email", async (c) => {
149+ requireAccountScope(c, "admin");
150+ const me = siteAdmin(c, "admin");
151+ const b = await json<{ note?: unknown }>(c).catch(() => ({}) as { note?: unknown });
152+ return c.json(suppressionJson(await adminSuppress(c, me, c.req.param("email"), typeof b.note === "string" ? b.note.trim() : "")));
153+});
154+
155+mailApi.delete("/diffinery/admin/email/suppressions/:email", async (c) => {
156+ requireAccountScope(c, "admin");
157+ const me = siteAdmin(c, "admin");
158+ await adminUnsuppress(c, me, c.req.param("email"));
159+ return c.body(null, 204);
160+});
161+
127162 mailApi.get("/diffinery/admin/email", async (c) => {
128163 siteAdmin(c, "read");
129164 const store = new MailStore(c.env.DB);
130165 const [day, last] = await Promise.all([store.counts(new Date(Date.now() - 86400_000).toISOString()), store.recent({ limit: 1 })]);
131- return c.json({ sending: !!c.env.EMAIL, queue: !!c.env.MAIL, from: senderAddress(c.env), last_24h: day, last: last[0] ? { ...last[0], dev_copy: undefined } : null });
166+ return c.json({ sending: !!c.env.EMAIL, queue: !!c.env.MAIL, from: senderAddress(c.env), event_intake: !!c.env.MAIL_EVENTS_SECRET, last_24h: day, last: last[0] ? { ...last[0], dev_copy: undefined } : null });
132167 });
133168
134169 /**
addedapps/diffinery/src/mail/events.ts+55−0View file
@@ -1,0 +1,55 @@
1+import { Hono } from "hono";
2+import type { AppVars } from "../auth/principal.js";
3+import type { Env } from "../env.js";
4+import { Directory } from "../lib/directory.js";
5+import { SIGNATURE_HEADER, checkSignature, recordMailEvents } from "./suppression.js";
6+
7+/**
8+ * Bounce and complaint intake, two ways in:
9+ *
10+ * - The `diffinery-mail-events` queue, which Cloudflare Email Sending's Event Subscriptions
11+ * publish to. Only Cloudflare can put messages there, so they need no signature.
12+ * - POST /-/email/events, for anything else that can relay events in the same shape. It is off
13+ * (404) until MAIL_EVENTS_SECRET is set, and refuses any request without a good, recent
14+ * signature (see checkSignature in suppression.ts).
15+ */
16+
17+/** Largest signed intake body, in bytes. */
18+export const MAX_EVENTS_BODY = 1 << 20;
19+
20+export const mailEvents = new Hono<{ Bindings: Env; Variables: AppVars }>();
21+
22+mailEvents.post("/-/email/events", async (c) => {
23+ const secret = c.env.MAIL_EVENTS_SECRET;
24+ if (!secret) return c.json({ message: "email event intake is off: MAIL_EVENTS_SECRET is not set" }, 404);
25+ if (Number(c.req.header("content-length") ?? 0) > MAX_EVENTS_BODY) return c.json({ message: "body too large" }, 413);
26+ const body = await c.req.text();
27+ if (body.length > MAX_EVENTS_BODY) return c.json({ message: "body too large" }, 413);
28+ if (!(await checkSignature(secret, c.req.header(SIGNATURE_HEADER), body))) return c.json({ message: "missing or bad signature" }, 401);
29+ let parsed: unknown;
30+ try {
31+ parsed = JSON.parse(body);
32+ } catch {
33+ return c.json({ message: "body must be JSON" }, 400);
34+ }
35+ // One event, an array of them, or {"events": [...]}.
36+ const events = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray((parsed as { events?: unknown }).events) ? (parsed as { events: unknown[] }).events : [parsed];
37+ if (events.length > 1000) return c.json({ message: "at most 1000 events at a time" }, 413);
38+ const dir = c.get("dir");
39+ const r = await recordMailEvents(c.env.DB, (e) => dir.audit(e), events, "webhook");
40+ return c.json({ suppressed: r.suppressed.length, ignored: r.ignored });
41+});
42+
43+/** Queue consumer for `diffinery-mail-events` (Cloudflare Email Sending's Event Subscriptions). */
44+export async function mailEventsQueue(batch: MessageBatch<unknown>, env: Env): Promise<void> {
45+ const dir = new Directory(env);
46+ for (const msg of batch.messages) {
47+ try {
48+ await recordMailEvents(env.DB, (e) => dir.audit(e), [msg.body], "cloudflare");
49+ msg.ack();
50+ } catch (e) {
51+ console.error("email event handling failed", e);
52+ msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) });
53+ }
54+ }
55+}
modifiedapps/diffinery/src/mail/samples.ts+1−0View file
@@ -79,6 +79,7 @@
7979 item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }),
8080 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() } }),
8181 item("signup-code", "Sign-up code", { kind: "signup_code", reason: "signup", data: { code: "042917", expiresAt: new Date(Date.now() + 600_000).toISOString() } }),
82+ item("email-verify", "Confirm an address after a bounce", { kind: "email_verify", reason: "account", data: { email: "diffy@example.com", url: `${pub}/settings/email/verify/sample`, expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
8283 item("signup-attempt", "Sign-up with an existing address", { kind: "signup_attempt", reason: "account", data: { email: "diffy@example.com" } }),
8384 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." } }),
8485 item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }),
modifiedapps/diffinery/src/mail/send.ts+27−6View file
@@ -4,13 +4,15 @@
44 import { MailStore, wantsEmail } from "./prefs.js";
55 import { compose, toHtml, toText, type RenderCtx } from "./templates.js";
66 import { categoryOf, isMandatory, type MailJob } from "./types.js";
7+import { REASON_TEXT, Suppressions } from "./suppression.js";
78 import { unsubUrl } from "./unsubscribe.js";
89
910 /**
1011 * Sending: jobs go on the `diffinery-mail` queue (so a slow or failing send
1112 * never holds up a request, and failures retry with backoff), and the queue
1213 * consumer checks the recipient's settings, renders and sends each one through
13- * Cloudflare Email Sending.
14+ * Cloudflare Email Sending. Nothing goes to an address on the suppression list
15+ * (suppression.ts).
1416 */
1517
1618 /** Queue emails. Never throws: email must not break the action that caused it. */
@@ -52,6 +54,8 @@
5254 const email = compose(job, pub, to.login);
5355 const category = categoryOf(job);
5456 const unsubscribe: Array<{ label: string; url: string }> = [];
57+ // The one-click List-Unsubscribe header (RFC 8058) turns this kind of email off for the recipient.
58+ let oneClick: string | undefined;
5559 if (to.accountId && !isMandatory(category)) {
5660 const a = to.accountId;
5761 if (job.kind === "thread") {
@@ -60,7 +64,8 @@
6064 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 } }) });
6165 }
6266 if (category !== "security" && category !== "account") {
63- unsubscribe.push({ label: "Unsubscribe from emails like this", url: await unsubUrl(env, { accountId: a, scope: { t: "category", category } }) });
67+ oneClick = await unsubUrl(env, { accountId: a, scope: { t: "category", category } });
68+ unsubscribe.push({ label: "Unsubscribe from emails like this", url: oneClick });
6469 }
6570 }
6671 const ctx: RenderCtx = { publicUrl: pub, login: to.login, reason: job.reason, unsubscribe, settingsUrl: to.accountId ? `${pub}/settings/notifications` : undefined };
@@ -82,8 +87,8 @@
8287 headers["List-Id"] = `${clean(email.context.label)} <${name ? `${listIdLabel(name)}.` : ""}${listIdLabel(owner!)}.${host}>`;
8388 headers["X-Diffinery-Repository"] = email.context.label;
8489 }
85- if (unsubscribe.length) {
86- headers["List-Unsubscribe"] = `<${unsubscribe[0]!.url}>`;
90+ if (oneClick) {
91+ headers["List-Unsubscribe"] = `<${oneClick}>`;
8792 headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
8893 }
8994 return {
@@ -96,8 +101,16 @@
96101 };
97102 }
98103
99-/** Send one job now: resolve the recipient, apply their settings, render and send. */
100-export async function deliver(env: Env, job: MailJob): Promise<{ status: "sent" | "skipped" | "failed"; detail?: string; messageId?: string }> {
104+export interface DeliverOptions {
105+ /**
106+ * Send even to a suppressed address. Only for the verification email the address's owner
107+ * asks for on Settings → Email (suppression.ts); never set on queued jobs.
108+ */
109+ ignoreSuppression?: boolean;
110+}
111+
112+/** Send one job now: resolve the recipient, apply their settings and the suppression list, render and send. */
113+export async function deliver(env: Env, job: MailJob, opts: DeliverOptions = {}): Promise<{ status: "sent" | "skipped" | "failed"; detail?: string; messageId?: string }> {
101114 const dir = new Directory(env);
102115 const store = new MailStore(env.DB);
103116 const category = categoryOf(job);
@@ -118,6 +131,14 @@
118131 } else {
119132 to = { email: job.to.email, name: job.to.name };
120133 }
134+ if (!opts.ignoreSuppression) {
135+ const s = await new Suppressions(env.DB).get(to.email);
136+ if (s) {
137+ const detail = `address is suppressed: ${REASON_TEXT[s.reason]}`;
138+ await store.log({ account_id: to.accountId ?? null, to_addr: to.email, kind: job.kind, subject: "", status: "skipped", detail, message_id: null, dev_copy: null });
139+ return { status: "skipped", detail };
140+ }
141+ }
121142
122143 const msg = await buildMessage(env, job, to);
123144 const devCopy = devAuth(env) ? JSON.stringify(msg) : null;
addedapps/diffinery/src/mail/suppression.ts+181−0View file
@@ -1,0 +1,181 @@
1+import { devAuth } from "../auth/dev.js";
2+import type { Env } from "../env.js";
3+import { b64url, fromB64url, hmacSha256Hex, now, safeEqual } from "../lib/util.js";
4+
5+/**
6+ * The suppression list: addresses Diffinery sends no email to at all, because mail to them
7+ * bounced for good (`hard-bounce`), their owner reported an email as spam (`complaint`), or a
8+ * site administrator added them (`manual`). deliver() in send.ts checks it before every send,
9+ * sign-in links and account recovery included. The one exception is the verification email the
10+ * address's owner asks for on Settings → Email: following its link lifts a bounce or complaint.
11+ *
12+ * Bounces and complaints come from Cloudflare Email Sending's Event Subscriptions (a queue,
13+ * mail/events.ts), or from a signed POST to /-/email/events for anything else.
14+ */
15+
16+export type SuppressionReason = "hard-bounce" | "complaint" | "manual";
17+export const SUPPRESSION_REASONS: readonly SuppressionReason[] = ["hard-bounce", "complaint", "manual"];
18+export type SuppressionSource = "cloudflare" | "webhook" | "admin";
19+
20+export interface Suppression {
21+ email: string;
22+ reason: SuppressionReason;
23+ detail: string | null;
24+ source: SuppressionSource;
25+ created_by: string | null;
26+ created_at: string;
27+}
28+
29+/** Why an address is suppressed, in words that follow "Diffinery stopped sending to it because". */
30+export const REASON_TEXT: Record<SuppressionReason, string> = {
31+ "hard-bounce": "email to it bounced permanently",
32+ complaint: "an email from Diffinery to it was reported as spam",
33+ manual: "a site administrator suppressed it",
34+};
35+
36+/** Longest detail kept, in characters. */
37+export const MAX_DETAIL = 500;
38+
39+/** Addresses compare without case. */
40+export const normalAddress = (email: string): string => email.trim().toLowerCase();
41+
42+/** Whether the address's owner may lift a suppression by verifying the address again (a manual one needs an administrator). */
43+export const ownerCanLift = (s: Pick<Suppression, "reason">): boolean => s.reason !== "manual";
44+
45+const clip = (s: string | null | undefined): string | null => (s ? s.replace(/\s+/g, " ").trim().slice(0, MAX_DETAIL) || null : null);
46+
47+/** The suppression list in D1. */
48+export class Suppressions {
49+ constructor(private db: D1Database) {}
50+
51+ async get(email: string): Promise<Suppression | null> {
52+ return this.db.prepare("SELECT * FROM email_suppressions WHERE email = ?").bind(normalAddress(email)).first<Suppression>();
53+ }
54+
55+ /** Suppress an address; a newer event replaces an older one. */
56+ async add(s: { email: string; reason: SuppressionReason; detail?: string | null; source: SuppressionSource; by?: string | null }): Promise<Suppression> {
57+ const row: Suppression = { email: normalAddress(s.email), reason: s.reason, detail: clip(s.detail), source: s.source, created_by: s.by ?? null, created_at: now() };
58+ await this.db
59+ .prepare(
60+ "INSERT INTO email_suppressions (email, reason, detail, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET reason = excluded.reason, detail = excluded.detail, source = excluded.source, created_by = excluded.created_by, created_at = excluded.created_at",
61+ )
62+ .bind(row.email, row.reason, row.detail, row.source, row.created_by, row.created_at)
63+ .run();
64+ return row;
65+ }
66+
67+ /** Remove an address from the list; the row that was there, or null. */
68+ async remove(email: string): Promise<Suppression | null> {
69+ return this.db.prepare("DELETE FROM email_suppressions WHERE email = ? RETURNING *").bind(normalAddress(email)).first<Suppression>();
70+ }
71+
72+ async list(limit = 500): Promise<Suppression[]> {
73+ return (await this.db.prepare("SELECT * FROM email_suppressions ORDER BY created_at DESC LIMIT ?").bind(Math.min(Math.max(limit, 1), 1000)).all<Suppression>()).results;
74+ }
75+}
76+
77+// ---------------------------------------------------------------- provider events
78+
79+/**
80+ * What a provider event means for the list: a permanent (hard) bounce or a spam complaint
81+ * suppresses the recipient; everything else (deliveries, soft bounces, deferrals) changes nothing.
82+ *
83+ * The shape is Cloudflare Email Sending's Event Subscriptions event: `type` ends in
84+ * `message.bounced` or `message.complained`, and `payload` holds `recipient`, `bounce`
85+ * (`type` hard or soft, `reason`) and `complaint`. The signed intake takes the same shape.
86+ */
87+export function suppressionFor(ev: unknown): { email: string; reason: Exclude<SuppressionReason, "manual">; detail: string | null } | null {
88+ if (!ev || typeof ev !== "object") return null;
89+ const e = ev as { type?: unknown; payload?: { recipient?: unknown; bounce?: { type?: unknown; reason?: unknown }; complaint?: { type?: unknown }; delivery?: { smtpResponse?: unknown } } };
90+ const type = typeof e.type === "string" ? e.type : "";
91+ const p = e.payload && typeof e.payload === "object" ? e.payload : null;
92+ const to = typeof p?.recipient === "string" ? normalAddress(p.recipient) : "";
93+ if (!p || !/^[^\s@]+@[^\s@]+$/.test(to) || to.length > 254) return null;
94+ const str = (v: unknown) => (typeof v === "string" ? v : null);
95+ if (type.endsWith("message.complained")) return { email: to, reason: "complaint", detail: clip(`Spam complaint${str(p.complaint?.type) ? ` (${str(p.complaint?.type)})` : ""}`) };
96+ // A bounced event is permanent by definition; only an explicit soft one is left alone.
97+ if (type.endsWith("message.bounced") && str(p.bounce?.type) !== "soft") return { email: to, reason: "hard-bounce", detail: clip(str(p.bounce?.reason) ?? str(p.delivery?.smtpResponse)) };
98+ return null;
99+}
100+
101+type Audit = (e: { action: string; target?: string; detail?: unknown }) => Promise<void>;
102+
103+/** Apply provider events to the list, auditing each address suppressed. */
104+export async function recordMailEvents(db: D1Database, audit: Audit, events: unknown[], source: Exclude<SuppressionSource, "admin">): Promise<{ suppressed: string[]; ignored: number }> {
105+ const list = new Suppressions(db);
106+ const suppressed: string[] = [];
107+ for (const ev of events) {
108+ const s = suppressionFor(ev);
109+ if (!s) continue;
110+ await list.add({ ...s, source });
111+ await audit({ action: "email.suppression.add", target: s.email, detail: { reason: s.reason, source, detail: s.detail } });
112+ suppressed.push(s.email);
113+ }
114+ return { suppressed, ignored: events.length - suppressed.length };
115+}
116+
117+// ---------------------------------------------------------------- the signed intake
118+
119+/** How old a signed intake request may be, in seconds (either way, for clock skew). */
120+export const SIGNATURE_TOLERANCE_S = 300;
121+export const SIGNATURE_HEADER = "x-diffinery-signature";
122+
123+/** The signature header for a body: `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`. */
124+export async function signEvents(secret: string, body: string, t = Math.floor(Date.now() / 1000)): Promise<string> {
125+ return `t=${t},v1=${await hmacSha256Hex(secret, `${t}.${body}`)}`;
126+}
127+
128+/** Whether a signature header is good for this body, and recent. */
129+export async function checkSignature(secret: string, header: string | null | undefined, body: string, nowMs = Date.now()): Promise<boolean> {
130+ if (!secret || !header || header.length > 1000) return false;
131+ const parts = new Map(header.split(",").map((kv) => kv.trim().split("=", 2) as [string, string]));
132+ const t = Number(parts.get("t"));
133+ const v1 = parts.get("v1") ?? "";
134+ if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(v1)) return false;
135+ if (Math.abs(nowMs / 1000 - t) > SIGNATURE_TOLERANCE_S) return false;
136+ return safeEqual(v1, await hmacSha256Hex(secret, `${t}.${body}`));
137+}
138+
139+// ---------------------------------------------------------------- verifying an address again
140+
141+/** How long a verification link works. */
142+export const VERIFY_HOURS = 24;
143+/** Verification emails one account may ask for in an hour. */
144+export const MAX_VERIFY_EMAILS_PER_HOUR = 3;
145+
146+export interface VerifyToken {
147+ accountId: string;
148+ email: string;
149+ expiresAt: number;
150+}
151+
152+async function verifyKey(env: Pick<Env, "MASTER_KEY" | "DEV_AUTH" | "ACCESS_AUD" | "PUBLIC_URL">): Promise<CryptoKey> {
153+ // Local development has no MASTER_KEY unless one is passed; production always does.
154+ const secret = env.MASTER_KEY ?? (devAuth(env) ? "diffinery-dev-verify-email" : null);
155+ if (!secret) throw new Error("MASTER_KEY is not set, so verification links can't be signed");
156+ return crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-verify-email-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
157+}
158+
159+async function signVerify(env: Env, payload: string): Promise<string> {
160+ return b64url(new Uint8Array(await crypto.subtle.sign("HMAC", await verifyKey(env), new TextEncoder().encode(payload))));
161+}
162+
163+/** A signed link token: this account may lift the suppression on this address until it expires. */
164+export async function verifyToken(env: Env, t: VerifyToken): Promise<string> {
165+ const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, e: normalAddress(t.email), x: t.expiresAt })));
166+ return `${payload}.${await signVerify(env, payload)}`;
167+}
168+
169+/** The token's contents when its signature is good and it hasn't expired; null otherwise. */
170+export async function readVerifyToken(env: Env, token: string, nowMs = Date.now()): Promise<VerifyToken | null> {
171+ const [payload, sig] = token.split(".");
172+ if (!payload || !sig || token.length > 1000) return null;
173+ if (!safeEqual(sig, await signVerify(env, payload))) return null;
174+ try {
175+ const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; e?: unknown; x?: unknown };
176+ if (typeof v.a !== "string" || typeof v.e !== "string" || typeof v.x !== "number" || v.x < nowMs) return null;
177+ return { accountId: v.a, email: v.e, expiresAt: v.x };
178+ } catch {
179+ return null;
180+ }
181+}
modifiedapps/diffinery/src/mail/templates.ts+13−0View file
@@ -637,6 +637,19 @@
637637 { p: ["It works once and expires ", at(job.data.expiresAt), ". If you didn't ask to sign up, ignore this email: nothing happens without the code."], muted: true },
638638 ],
639639 };
640+ case "email_verify":
641+ return {
642+ subject: "[Diffinery] Confirm your email address to get email again",
643+ preheader: `Confirm before ${day(job.data.expiresAt)} and Diffinery emails you again.`,
644+ eyebrow: { text: "Account", tone: "blue" },
645+ title: ["Confirm your email address"],
646+ blocks: [
647+ { p: ["Diffinery stopped emailing ", { b: job.data.email }, " after a bounce or a spam report. You asked to get email here again: confirm that this inbox is yours and works."] },
648+ { button: { label: "Confirm and get email again", url: job.data.url } },
649+ { p: ["The link works while you're signed in to Diffinery as ", ...(login ? [{ b: user(login) }] : ["yourself"]), " and expires ", at(job.data.expiresAt), ". If you didn't ask for it, ignore this email: nothing changes."], muted: true },
650+ ],
651+ url: job.data.url,
652+ };
640653 case "signup_attempt":
641654 return {
642655 subject: "[Diffinery] Someone tried to sign up with your email address",
modifiedapps/diffinery/src/mail/types.ts+2−0View file
@@ -89,6 +89,7 @@
8989 | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } }
9090 | { kind: "signup_code"; data: { code: string; expiresAt: string } }
9191 | { kind: "signup_attempt"; data: { email: string } }
92+ | { kind: "email_verify"; data: { email: string; url: string; expiresAt: string } }
9293 | { kind: "access_request"; data: { email: string; login: string; note: string } }
9394 | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
9495 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
@@ -129,6 +130,7 @@
129130 case "signin_link":
130131 case "signup_code":
131132 case "signup_attempt":
133+ case "email_verify":
132134 // It is about the payer's own model spend, so it always sends.
133135 case "review_limit_reached":
134136 case "test":
modifiedapps/diffinery/src/mail/web.tsx+247−4View file
@@ -14,11 +14,15 @@
1414 import { buildMessage, deliver, enqueueMail, senderAddress } from "./send.js";
1515 import type { Category, MailJob } from "./types.js";
1616 import { readUnsubToken, type UnsubScope, type UnsubToken } from "./unsubscribe.js";
17+import { normalEmail } from "../auth/signup.js";
18+import { MAX_DETAIL, MAX_VERIFY_EMAILS_PER_HOUR, REASON_TEXT, Suppressions, VERIFY_HOURS, normalAddress, ownerCanLift, readVerifyToken, verifyToken, type Suppression } from "./suppression.js";
1719
1820 /**
1921 * Email in the web UI: notification settings, one-click unsubscribe, watching
20- * repositories and subscribing to threads, and the Email page in site
21- * administration (delivery status, the log and a gallery of every template).
22+ * repositories and subscribing to threads, Settings → Email (is my address
23+ * suppressed, and verifying it again), and the Email page in site
24+ * administration (delivery status, the suppression list, the log and a
25+ * gallery of every template).
2226 */
2327
2428 const CATEGORY_TEXT: Record<Exclude<Category, "security" | "account">, string> = {
@@ -121,7 +125,7 @@
121125 const store = new MailStore(c.env.DB);
122126 const dir = c.get("dir");
123127 const t = csrfToken(c);
124- const [p, watches, recent] = await Promise.all([store.prefs(me.id), store.watchedRepos(me.id), store.recent({ accountId: me.id, limit: 20 })]);
128+ const [p, watches, recent, suppressed] = await Promise.all([store.prefs(me.id), store.watchedRepos(me.id), store.recent({ accountId: me.id, limit: 20 }), me.email ? new Suppressions(c.env.DB).get(me.email) : null]);
125129 const repos = (await Promise.all(watches.map(async (w) => ({ level: w.level, repo: await dir.repoById(w.repo_id) })))).filter((w) => w.repo);
126130 return settingsPage(
127131 c,
@@ -133,6 +137,11 @@
133137 <p class="muted">
134138 Emails go to <b>{me.email ?? "no address"}</b> from {senderAddress(c.env)}. Security and account emails always send.
135139 </p>
140+ {suppressed ? (
141+ <div class="flash error" role="alert">
142+ <SuppressedNote s={suppressed} /> See <a href="/settings/email">Settings → Email</a>.
143+ </div>
144+ ) : null}
136145 <Form action="/settings/notifications" t={t} class="box">
137146 <table class="tbl">
138147 <thead>
@@ -233,6 +242,111 @@
233242 return deliver(c.env, { kind: "test", data: { by: me.login }, to: { accountId: me.id }, reason: "account", at: now() });
234243 }
235244
245+// ---------------------------------------------------------------- the suppression list (suppression.ts)
246+
247+export const suppressionJson = (s: Suppression) => ({ email: s.email, reason: s.reason, detail: s.detail, source: s.source, created_by: s.created_by, created_at: s.created_at, owner_can_lift: ownerCanLift(s) });
248+
249+const audit = (c: AppContext, me: Account, action: string, target: string, detail: unknown) => c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action, target, detail });
250+
251+/**
252+ * Email yourself a link that lifts a bounce or complaint on your own address. It is sent now and
253+ * past the suppression list: the only email that is. A manual suppression needs an administrator.
254+ */
255+export async function sendVerification(c: AppContext, me: Account) {
256+ if (!me.email) throw new HttpError(422, "your account has no email address");
257+ const s = await new Suppressions(c.env.DB).get(me.email);
258+ if (!s) throw new HttpError(409, "email to your address isn't suppressed, so there is nothing to verify");
259+ if (!ownerCanLift(s)) throw new HttpError(403, "a site administrator suppressed your address; ask one to remove it");
260+ const sent = (await new MailStore(c.env.DB).recent({ accountId: me.id, kind: "email_verify", since: new Date(Date.now() - 3600_000).toISOString(), limit: MAX_VERIFY_EMAILS_PER_HOUR })).length;
261+ if (sent >= MAX_VERIFY_EMAILS_PER_HOUR) throw new HttpError(429, `you can ask for at most ${MAX_VERIFY_EMAILS_PER_HOUR} verification emails an hour; try again later`);
262+ const expiresAt = Date.now() + VERIFY_HOURS * 3600_000;
263+ const url = `${c.env.PUBLIC_URL}/settings/email/verify/${await verifyToken(c.env, { accountId: me.id, email: me.email, expiresAt })}`;
264+ return deliver(c.env, { kind: "email_verify", data: { email: me.email, url, expiresAt: new Date(expiresAt).toISOString() }, to: { accountId: me.id }, reason: "account", at: now() }, { ignoreSuppression: true });
265+}
266+
267+/** Follow a verification link: lift the suppression on your own address. Null when it was already lifted. */
268+export async function liftOwnSuppression(c: AppContext, me: Account, token: string): Promise<Suppression | null> {
269+ const t = await readVerifyToken(c.env, token);
270+ if (!t || t.accountId !== me.id) throw new HttpError(404, "this link doesn't work: it expired, or it was sent to another account");
271+ if (!me.email || normalAddress(me.email) !== t.email) throw new HttpError(409, "your email address changed after this link was sent");
272+ const list = new Suppressions(c.env.DB);
273+ const s = await list.get(t.email);
274+ if (!s) return null;
275+ if (!ownerCanLift(s)) throw new HttpError(403, "a site administrator suppressed your address; ask one to remove it");
276+ await list.remove(t.email);
277+ await audit(c, me, "email.suppression.lift", t.email, { reason: s.reason });
278+ return s;
279+}
280+
281+/** A site administrator stops all email to an address. */
282+export async function adminSuppress(c: AppContext, me: Account, email: string, note: string): Promise<Suppression> {
283+ const addr = normalEmail(email);
284+ if (!addr) throw new HttpError(422, "enter a valid email address");
285+ const s = await new Suppressions(c.env.DB).add({ email: addr, reason: "manual", detail: note.slice(0, MAX_DETAIL) || null, source: "admin", by: me.login });
286+ await audit(c, me, "email.suppression.add", addr, { reason: "manual", source: "admin", detail: s.detail });
287+ return s;
288+}
289+
290+/** A site administrator removes an address from the list, whatever put it there. */
291+export async function adminUnsuppress(c: AppContext, me: Account, email: string): Promise<Suppression> {
292+ const s = await new Suppressions(c.env.DB).remove(email);
293+ if (!s) throw new HttpError(404, "that address isn't suppressed");
294+ await audit(c, me, "email.suppression.remove", s.email, { reason: s.reason, source: s.source });
295+ return s;
296+}
297+
298+const SuppressedNote: FC<{ s: Suppression }> = ({ s }) => (
299+ <>
300+ Since <Time at={s.created_at} />, Diffinery sends no email to <b>{s.email}</b> because {REASON_TEXT[s.reason]}
301+ {s.detail ? <> ({s.detail})</> : null}.
302+ </>
303+);
304+
305+async function emailPage(c: AppContext, me: Account): Promise<Response> {
306+ const t = csrfToken(c);
307+ const s = me.email ? await new Suppressions(c.env.DB).get(me.email) : null;
308+ return settingsPage(
309+ c,
310+ me,
311+ "email-address",
312+ "Email",
313+ <>
314+ <h1>Email</h1>
315+ <div class="box">
316+ <div class="head">
317+ <h2>{me.email ?? "No email address"}</h2>
318+ {me.email ? s ? <span class="badge red">suppressed</span> : <span class="badge green">receiving email</span> : null}
319+ </div>
320+ <div class="body">
321+ {!me.email ? (
322+ <p class="m0">Your account has no email address, so Diffinery can't email you.</p>
323+ ) : !s ? (
324+ <p class="m0">
325+ Diffinery emails this address from {senderAddress(c.env)}. Choose what you get in <a href="/settings/notifications">notification settings</a>.
326+ </p>
327+ ) : (
328+ <>
329+ <p>
330+ <SuppressedNote s={s} /> That includes sign-in links, account recovery and security alerts.
331+ </p>
332+ {ownerCanLift(s) ? (
333+ <>
334+ <p class="muted">If the inbox works again, or the spam report was a mistake, confirm the address: we send one email to it, and following its link while you're signed in turns email back on.</p>
335+ <Form action="/settings/email/verify" t={t}>
336+ <button class="btn primary">Send a verification email</button>
337+ </Form>
338+ </>
339+ ) : (
340+ <p class="muted m0">Only a site administrator can lift a suppression they added. Ask one.</p>
341+ )}
342+ </>
343+ )}
344+ </div>
345+ </div>
346+ </>,
347+ );
348+}
349+
236350 async function readToken(c: AppContext): Promise<{ t: UnsubToken; account: Account } | null> {
237351 const t = await readUnsubToken(c.env, prm(c, "token")).catch(() => null);
238352 if (!t) return null;
@@ -289,6 +403,54 @@
289403 return c.redirect("/settings/notifications", 303);
290404 });
291405
406+ // ------------------------------------------------------------ Settings → Email: suppressed or not, and verifying the address again
407+
408+ web.get("/settings/email", async (c) => emailPage(c, requireUser(c)));
409+
410+ post(web, "/settings/email/verify", async (c) => {
411+ const me = requireUser(c);
412+ return act(c, "/settings/email", async () => {
413+ const r = await sendVerification(c, me);
414+ if (r.status === "sent") flash(c, "notice", `We sent a verification email to ${me.email}. Follow its link within ${VERIFY_HOURS} hours, signed in as ${me.login}.`);
415+ else flash(c, "error", `The verification email was ${r.status}: ${r.detail ?? "unknown reason"}`);
416+ });
417+ });
418+
419+ // The emailed link opens a page with a button: mail scanners that fetch links don't lift anything.
420+ web.get("/settings/email/verify/:token", async (c) => {
421+ const me = requireUser(c);
422+ const t = await readVerifyToken(c.env, prm(c, "token"));
423+ const ok = !!t && t.accountId === me.id;
424+ return render(
425+ c,
426+ { title: "Confirm your email address", status: ok ? 200 : 404 },
427+ ok ? (
428+ <UnsubPage title="Confirm your email address">
429+ <p>
430+ Turn email back on for <b>{t!.email}</b>, the address of <b>{me.login}</b>.
431+ </p>
432+ <Form action={c.req.path} t={csrfToken(c)}>
433+ <button class="btn primary">Confirm and get email again</button>
434+ </Form>
435+ </UnsubPage>
436+ ) : (
437+ <UnsubPage title="This link doesn't work">
438+ <p>
439+ It expired, or it was sent to another account. Ask for a new one in <a href="/settings/email">Settings → Email</a>.
440+ </p>
441+ </UnsubPage>
442+ ),
443+ );
444+ });
445+
446+ post(web, "/settings/email/verify/:token", async (c) => {
447+ const me = requireUser(c);
448+ return act(c, "/settings/email", async () => {
449+ const s = await liftOwnSuppression(c, me, prm(c, "token"));
450+ flash(c, "notice", s ? `Confirmed. Diffinery emails ${me.email} again.` : `${me.email} wasn't suppressed any more.`);
451+ });
452+ });
453+
292454 // ------------------------------------------------------------ unsubscribe (no sign-in: the signed link is the permission)
293455
294456 web.get("/-/unsubscribe/:token", async (c) => {
@@ -380,7 +542,7 @@
380542 const store = new MailStore(c.env.DB);
381543 const t = csrfToken(c);
382544 const since = new Date(Date.now() - 86400_000).toISOString();
383- const [counts, rows] = await Promise.all([store.counts(since), store.recent({ limit: 100 })]);
545+ const [counts, rows, suppressed] = await Promise.all([store.counts(since), store.recent({ limit: 100 }), new Suppressions(c.env.DB).list()]);
384546 const samples = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login);
385547 const pick = samples.find((s) => s.id === c.req.query("sample")) ?? samples[0]!;
386548 return settingsPage(
@@ -399,6 +561,10 @@
399561 <dd>{c.env.MAIL ? "diffinery-mail" : <span class="bad">not bound: emails are dropped</span>}</dd>
400562 <dt>From</dt>
401563 <dd>{senderAddress(c.env)}</dd>
564+ <dt>Bounces and complaints</dt>
565+ <dd>
566+ From the diffinery-mail-events queue (Email Sending event subscriptions) · signed endpoint {c.env.MAIL_EVENTS_SECRET ? <span class="ok">on</span> : <span class="faint">off (no MAIL_EVENTS_SECRET)</span>}
567+ </dd>
402568 <dt>Last 24 hours</dt>
403569 <dd>
404570 <span class="ok">{counts.sent} sent</span> · <span class={counts.failed ? "bad" : "faint"}>{counts.failed} failed</span> · <span class="faint">{counts.skipped} skipped</span>
@@ -415,6 +581,67 @@
415581 </Form>
416582 </div>
417583
584+ <h2 id="suppressions">Suppressed addresses</h2>
585+ <p class="muted">
586+ Diffinery sends nothing to these addresses, sign-in links included. Bounces and spam complaints add them; people lift a bounce or complaint on their own address by verifying it in Settings → Email. Adding and removing is in the <a href="/settings/audit">audit log</a>.
587+ </p>
588+ <div class="box">
589+ {suppressed.length ? (
590+ <table class="tbl">
591+ <thead>
592+ <tr>
593+ <th>Address</th>
594+ <th>Why</th>
595+ <th>Since</th>
596+ <th></th>
597+ </tr>
598+ </thead>
599+ <tbody>
600+ {suppressed.map((x) => (
601+ <tr>
602+ <td>{x.email}</td>
603+ <td class="small">
604+ {x.reason}
605+ <div class="muted small">
606+ {x.source === "admin" ? `by ${x.created_by ?? "an administrator"}` : `from ${x.source}`}
607+ {x.detail ? ` · ${x.detail}` : ""}
608+ </div>
609+ </td>
610+ <td class="small">
611+ <Time at={x.created_at} />
612+ </td>
613+ <td style="text-align:right">
614+ <Form action="/settings/admin/email/suppressions/remove" t={t} class="inline" confirm={`Send email to ${x.email} again?`}>
615+ <input type="hidden" name="email" value={x.email} />
616+ <button class="btn sm">Remove</button>
617+ </Form>
618+ </td>
619+ </tr>
620+ ))}
621+ </tbody>
622+ </table>
623+ ) : (
624+ <div class="body">
625+ <p class="muted m0">No address is suppressed.</p>
626+ </div>
627+ )}
628+ </div>
629+ <Form action="/settings/admin/email/suppressions" t={t} class="box mb">
630+ <div class="body">
631+ <div class="formrow">
632+ <div class="field">
633+ <label for="sup-email">Suppress an address</label>
634+ <input id="sup-email" name="email" type="email" required placeholder="someone@example.com" autocomplete="off" />
635+ </div>
636+ <div class="field grow">
637+ <label for="sup-note">Note</label>
638+ <input id="sup-note" name="note" maxlength={MAX_DETAIL} placeholder="e.g. asked us to stop all email" />
639+ </div>
640+ <button class="btn">Suppress</button>
641+ </div>
642+ </div>
643+ </Form>
644+
418645 <h2>Templates</h2>
419646 <form method="get" action="/settings/admin/email" class="row mb">
420647 <KSelect name="sample" options={samples.map((s) => ({ value: s.id, label: s.label }))} value={pick.id} label="Template" autosubmit />
@@ -433,6 +660,22 @@
433660 );
434661 });
435662
663+ post(web, "/settings/admin/email/suppressions", async (c, f) => {
664+ const me = requireSiteAdmin(c);
665+ return act(c, "/settings/admin/email#suppressions", async () => {
666+ const s = await adminSuppress(c, me, f.s("email"), f.s("note").trim());
667+ flash(c, "notice", `Diffinery sends no email to ${s.email} now.`);
668+ });
669+ });
670+
671+ post(web, "/settings/admin/email/suppressions/remove", async (c, f) => {
672+ const me = requireSiteAdmin(c);
673+ return act(c, "/settings/admin/email#suppressions", async () => {
674+ const s = await adminUnsuppress(c, me, f.s("email"));
675+ flash(c, "notice", `Removed ${s.email} from the suppression list: it gets email again.`);
676+ });
677+ });
678+
436679 web.get("/settings/admin/email/preview/:id", async (c) => {
437680 const me = requireSiteAdmin(c);
438681 const s = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).find((x) => x.id === c.req.param("id"));
modifiedapps/diffinery/src/web/account.tsx+4−1View file
@@ -644,7 +644,7 @@
644644
645645 // ---------------------------------------------------------------- /settings
646646
647-export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "linked" | "admin" | "email" | "audit" | "kill-switches" | "errors";
647+export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "linked" | "admin" | "email" | "audit" | "kill-switches" | "errors";
648648 /** The site administration pages: they show when hosted compute is paused. */
649649 export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "email", "audit", "kill-switches", "errors"];
650650
@@ -686,6 +686,9 @@
686686 <a href="/settings/notifications" class={tab === "notifications" ? "on" : ""}>
687687 Notifications
688688 </a>
689+ <a href="/settings/email" class={tab === "email-address" ? "on" : ""}>
690+ Email
691+ </a>
689692 <a href="/settings/linked-accounts" class={tab === "linked" ? "on" : ""}>
690693 Linked accounts
691694 </a>
modifiedapps/diffinery/test/e2e/email.test.mjs+97−8View file
@@ -6,12 +6,14 @@
66 //
77 // Covers: security emails (tokens, agents), notification settings, the test
88 // 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.
9+// own activity staying quiet, one-click unsubscribe (RFC 8058) turning that
10+// kind of email off, the footer link muting a thread while mentions still get
11+// through, failed workflow run emails, and the suppression list: signed bounce
12+// and complaint intake, no email to a suppressed address, lifting it again.
1113 import { spawn, execFileSync } from "node:child_process";
1214 import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
1315 import { tmpdir } from "node:os";
14-import { randomBytes } from "node:crypto";
16+import { createHmac, randomBytes } from "node:crypto";
1517 import { join, dirname, resolve } from "node:path";
1618 import { fileURLToPath } from "node:url";
1719 import { createServer } from "node:net";
@@ -26,6 +28,7 @@
2628 let devLog = "";
2729 let token;
2830 let me;
31+const eventsSecret = randomBytes(24).toString("hex");
2932
3033 const freePort = () =>
3134 new Promise((res) => {
@@ -124,7 +127,7 @@
124127 base = `http://127.0.0.1:${port}`;
125128 const persist = join(work, "state");
126129 execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
127- dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], {
130+ dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `MAIL_EVENTS_SECRET:${eventsSecret}`, "--show-interactive-dev-session=false"], {
128131 cwd: APP,
129132 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
130133 stdio: ["ignore", "pipe", "pipe"],
@@ -232,23 +235,35 @@
232235 assert.equal(reply.dev_copy.headers["X-Diffinery-Reason"], "comment");
233236 assert.equal(reply.dev_copy.headers["In-Reply-To"], opened.dev_copy.headers.References);
234237
235- // One-click unsubscribe from the mail app mutes the thread.
238+ // One-click unsubscribe from the mail app (RFC 8058) turns that kind of email off: here, Participating.
236239 const link = /^<(.+)>$/.exec(reply.dev_copy.headers["List-Unsubscribe"])[1];
237240 assert.ok(link.startsWith(`${base}/-/unsubscribe/`));
238241 assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click");
239242 const confirm = await fetch(link);
240- assert.match(await confirm.text(), new RegExp(`Mute ${owner}/mailbox#${issue.number}`));
243+ assert.match(await confirm.text(), /Turn off email about issues and pull requests you take part in/);
241244 const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
242245 assert.equal(unsub.status, 200, await unsub.clone().text());
246+ assert.equal((await ok("GET", "/diffinery/notification-settings")).participating.email, false);
247+ assert.equal((await fetch(`${link.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused");
248+ // Turned off: the next reply is skipped, not sent.
249+ await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Nobody hears this one." }, botToken);
250+ const quiet = await email("a skipped reply", (e) => e.kind === "thread" && e.status === "skipped" && e.detail === "turned off participating emails");
251+ assert.equal(quiet.dev_copy, undefined);
252+ await ok("PUT", "/diffinery/notification-settings", { participating: { email: true } });
253+
254+ // The footer's Mute this thread link mutes just the thread (one-click works there too).
255+ const mute = /Mute this thread: (\S+)/.exec(reply.dev_copy.text)[1];
256+ assert.match(await (await fetch(mute)).text(), new RegExp(`Mute ${owner}/mailbox#${issue.number}`));
257+ const muted = await fetch(mute, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
258+ assert.equal(muted.status, 200, await muted.clone().text());
243259 const page = await (await fetch(`${base}/${owner}/mailbox/issues/${issue.number}`)).text();
244260 assert.match(page, /You muted this issue/);
245- assert.equal((await fetch(`${link.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused");
246261
247262 // Muted: a plain comment stays quiet, a mention still gets through.
248263 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Another update nobody needs." }, botToken);
249264 await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: `@${owner} this one needs you.` }, botToken);
250265 await email("mention on a muted thread", (e) => e.kind === "thread" && e.dev_copy?.text.includes("this one needs you"));
251- const threadMails = (await emails("&kind=thread")).filter((e) => e.subject.includes(`(Issue #${issue.number})`));
266+ const threadMails = (await emails("&kind=thread")).filter((e) => e.status === "sent" && e.subject.includes(`(Issue #${issue.number})`));
252267 assert.deepEqual(
253268 threadMails.map((e) => e.dev_copy.headers["X-Diffinery-Reason"]).reverse(),
254269 ["mention", "comment", "mention"],
@@ -317,3 +332,77 @@
317332 assert.match(blocked.dev_copy.text, /config\.env/);
318333 assert.match(blocked.dev_copy.text, /AWS access key/);
319334 });
335+
336+/** POST bounce or complaint events to the signed intake; `sign` false sends no signature, a string signs with that secret. */
337+function postEvents(events, sign = eventsSecret, t = Math.floor(Date.now() / 1000)) {
338+ const body = JSON.stringify(events);
339+ const headers = { "content-type": "application/json" };
340+ if (sign) headers["x-diffinery-signature"] = `t=${t},v1=${createHmac("sha256", sign).update(`${t}.${body}`).digest("hex")}`;
341+ return fetch(`${base}/-/email/events`, { method: "POST", headers, body });
342+}
343+
344+test("email: bounces and complaints suppress an address; its owner lifts it by verifying, an admin removes it", async () => {
345+ const bounce = (recipient) => ({ type: "cf.email.sending.message.bounced", source: { type: "email.sending" }, payload: { recipient, bounce: { type: "hard", classification: "permanent_failure", reason: "550 5.1.1 User unknown" } } });
346+
347+ // The intake refuses anything without a good, recent signature, and changes nothing.
348+ for (const r of [await postEvents([bounce(me.email)], false), await postEvents([bounce(me.email)], "not-the-secret"), await postEvents([bounce(me.email)], eventsSecret, Math.floor(Date.now() / 1000) - 3600)]) {
349+ assert.equal(r.status, 401, await r.text());
350+ }
351+ assert.equal((await ok("GET", "/diffinery/email")).suppressed, null);
352+ assert.equal((await ok("POST", "/diffinery/emails/test")).status, "sent");
353+
354+ // An authenticated permanent bounce suppresses my address; a soft one and a delivery change nothing.
355+ const soft = { type: "cf.email.sending.message.deferred", payload: { recipient: "soft@example.com", bounce: { type: "soft" } } };
356+ const r = await postEvents({ events: [bounce(me.email.toUpperCase()), soft, { type: "cf.email.sending.message.delivered", payload: { recipient: "x@example.com" } }] });
357+ assert.equal(r.status, 200, await r.clone().text());
358+ assert.deepEqual(await r.json(), { suppressed: 1, ignored: 2 });
359+ const mine = await ok("GET", "/diffinery/email");
360+ assert.equal(mine.suppressed.reason, "hard-bounce");
361+ assert.equal(mine.suppressed.detail, "550 5.1.1 User unknown");
362+ assert.equal(mine.suppressed.owner_can_lift, true);
363+
364+ // Then nothing is sent to it: not the test email, not a security email.
365+ const test = await ok("POST", "/diffinery/emails/test");
366+ assert.deepEqual(test, { status: "skipped", detail: "address is suppressed: email to it bounced permanently" });
367+ await ok("POST", "/diffinery/tokens", { name: "after-bounce", scopes: ["read"] }, null);
368+ const skipped = await email("token email skipped", (e) => e.kind === "token_created" && e.status === "skipped");
369+ assert.match(skipped.detail, /address is suppressed/);
370+ assert.equal((await emails("&kind=token_created")).filter((e) => e.status === "sent" && e.created_at >= skipped.created_at).length, 0);
371+
372+ // The settings pages say so; admins see it in the list and the audit log.
373+ assert.match(await (await fetch(`${base}/settings/email`)).text(), /email to it bounced permanently[\s\S]*Send a verification email/);
374+ assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /Settings → Email/);
375+ const list = await ok("GET", "/diffinery/admin/email/suppressions");
376+ assert.ok(list.some((x) => x.email === me.email.toLowerCase() && x.source === "webhook"));
377+ assert.match(await (await fetch(`${base}/settings/admin/email`)).text(), /Suppressed addresses/);
378+
379+ // Re-verifying lifts it: the verification email is the one that goes through.
380+ const v = await ok("POST", "/diffinery/email/verification");
381+ assert.equal(v.status, "sent", JSON.stringify(v));
382+ const vmail = await email("verification email", (e) => e.kind === "email_verify" && e.status === "sent");
383+ const verifyUrl = /(http\S+\/settings\/email\/verify\/\S+)/.exec(vmail.dev_copy.text)[1];
384+ const form = await fetch(verifyUrl);
385+ const html = await form.text();
386+ assert.equal(form.status, 200, html);
387+ const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1];
388+ const cookie = /dfy_csrf=([^;]+)/.exec(form.headers.get("set-cookie") ?? "")?.[1];
389+ const lift = await fetch(verifyUrl, { method: "POST", body: new URLSearchParams({ _csrf: csrf }), redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" } });
390+ assert.equal(lift.status, 303);
391+ assert.equal((await ok("GET", "/diffinery/email")).suppressed, null);
392+ assert.equal((await ok("POST", "/diffinery/emails/test")).status, "sent");
393+ assert.equal((await fetch(`${verifyUrl.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused");
394+
395+ // A complaint about an address without an account; an admin removes it, audited.
396+ const complaint = await postEvents({ type: "cf.email.sending.message.complained", payload: { recipient: "friend@example.com", complaint: { type: "abuse" } } });
397+ assert.deepEqual(await complaint.json(), { suppressed: 1, ignored: 0 });
398+ const invite = await ok("POST", "/invitations", { email: "friend@example.com" });
399+ assert.ok(invite);
400+ const inviteMail = await until("invite email skipped", async () => (await ok("GET", "/diffinery/emails?all=1&kind=invite&per_page=50")).find((e) => e.to_addr === "friend@example.com"));
401+ assert.equal(inviteMail.status, "skipped");
402+ assert.match(inviteMail.detail, /reported as spam/);
403+ assert.equal((await api("DELETE", "/diffinery/admin/email/suppressions/friend@example.com")).status, 204);
404+ assert.equal((await api("DELETE", "/diffinery/admin/email/suppressions/friend@example.com")).status, 404);
405+ assert.ok(!(await ok("GET", "/diffinery/admin/email/suppressions")).some((x) => x.email === "friend@example.com"));
406+ const audit = await (await fetch(`${base}/settings/audit`)).text();
407+ for (const action of ["email.suppression.add", "email.suppression.lift", "email.suppression.remove"]) assert.match(audit, new RegExp(action.replaceAll(".", "\\.")));
408+});
addedapps/diffinery/test/e2e/security-txt.test.mjs+100−0View file
@@ -1,0 +1,100 @@
1+// End-to-end /.well-known/security.txt (RFC 9116): two real `wrangler dev` Workers, one without
2+// SECURITY_CONTACT (the file is 404, never a made-up address) and one with it (every field).
3+//
4+// pnpm --filter @diffinery/app exec node --test test/e2e/security-txt.test.mjs
5+import { spawn, execFileSync } from "node:child_process";
6+import { mkdtempSync, rmSync } from "node:fs";
7+import { tmpdir } from "node:os";
8+import { join, dirname, resolve } from "node:path";
9+import { fileURLToPath } from "node:url";
10+import { createServer } from "node:net";
11+import { test, after } from "node:test";
12+import assert from "node:assert/strict";
13+
14+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
15+const work = mkdtempSync(join(tmpdir(), "diffinery-securitytxt-e2e-"));
16+const running = [];
17+
18+const freePort = () =>
19+ new Promise((res) => {
20+ const s = createServer();
21+ s.listen(0, "127.0.0.1", () => {
22+ const { port } = s.address();
23+ s.close(() => res(port));
24+ });
25+ });
26+
27+/** Start a Worker with extra `--var`s; resolves to its base URL once /healthz answers. */
28+async function worker(name, vars) {
29+ const port = await freePort();
30+ const base = `http://127.0.0.1:${port}`;
31+ const persist = join(work, name);
32+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
33+ const args = ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"];
34+ for (const [k, v] of Object.entries(vars)) args.push("--var", `${k}:${v}`);
35+ const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true });
36+ let log = "";
37+ dev.stdout.on("data", (d) => (log += d));
38+ dev.stderr.on("data", (d) => (log += d));
39+ running.push(dev);
40+ const end = Date.now() + 120_000;
41+ while (Date.now() < end) {
42+ try {
43+ if ((await fetch(`${base}/healthz`)).ok) return { base, stop: () => stopWorker(dev) };
44+ } catch {}
45+ await new Promise((r) => setTimeout(r, 500));
46+ }
47+ assert.fail(`wrangler dev did not start\n${log.slice(-4000)}`);
48+}
49+
50+function stopWorker(dev) {
51+ try {
52+ process.kill(-dev.pid, "SIGTERM");
53+ } catch {}
54+}
55+
56+after(() => {
57+ for (const dev of running) stopWorker(dev);
58+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
59+});
60+
61+test("security.txt: 404 without SECURITY_CONTACT", async () => {
62+ const w = await worker("unset", {});
63+ try {
64+ const r = await fetch(`${w.base}/.well-known/security.txt`);
65+ assert.equal(r.status, 404);
66+ const body = await r.text();
67+ assert.doesNotMatch(body, /Contact:/);
68+ assert.match(body, /No security contact is configured/);
69+ } finally {
70+ w.stop();
71+ }
72+});
73+
74+test("security.txt: every RFC 9116 field with SECURITY_CONTACT set", async () => {
75+ const w = await worker("set", { SECURITY_CONTACT: "security@diffinery.test, https://diffinery.test/report" });
76+ try {
77+ const r = await fetch(`${w.base}/.well-known/security.txt`);
78+ assert.equal(r.status, 200);
79+ assert.match(r.headers.get("content-type") ?? "", /^text\/plain; charset=utf-8/);
80+ const lines = (await r.text()).trim().split("\n");
81+ const fields = (k) => lines.filter((l) => l.startsWith(`${k}: `)).map((l) => l.slice(k.length + 2));
82+ assert.deepEqual(fields("Contact"), ["mailto:security@diffinery.test", "https://diffinery.test/report"]);
83+ assert.deepEqual(fields("Policy"), [`${w.base}/docs/security/disclosure`]);
84+ assert.deepEqual(fields("Preferred-Languages"), ["en"]);
85+ assert.deepEqual(fields("Canonical"), [`${w.base}/.well-known/security.txt`]);
86+ // Expires: one RFC 3339 time, less than a year ahead and more than eleven months.
87+ const [expires] = fields("Expires");
88+ assert.match(expires, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
89+ const days = (Date.parse(expires) - Date.now()) / 86400_000;
90+ assert.ok(days > 330 && days <= 365, `Expires is ${days} days ahead`);
91+ // Every line is a known field.
92+ for (const l of lines) assert.match(l, /^(Contact|Expires|Policy|Preferred-Languages|Canonical): \S/);
93+ // The policy it points to is served.
94+ const policy = await fetch(fields("Policy")[0]);
95+ assert.equal(policy.status, 200);
96+ assert.match(await policy.text(), /Safe harbour/);
97+ } finally {
98+ w.stop();
99+ }
100+});
modifiedapps/diffinery/test/unit/docs.test.ts+1−1View file
@@ -42,7 +42,7 @@
4242 }
4343
4444 /** Paths outside /docs that pages may link to: real routes of the app. */
45-const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w-]+$/, /^\/healthz$/];
45+const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/healthz$/];
4646
4747 describe("docs pages", () => {
4848 it("has every page in the navigation, in order", () => {
modifiedapps/diffinery/test/unit/email.test.ts+18−1View file
@@ -15,7 +15,7 @@
1515 describe("email templates", () => {
1616 it("has a sample for every kind of email", () => {
1717 const kinds = new Set(samples.map((s) => s.job.kind));
18- for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "signup_code", "signup_attempt", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) {
18+ for (const k of ["email_verify", "signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "signup_code", "signup_attempt", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) {
1919 expect(kinds.has(k as MailJob["kind"]), k).toBe(true);
2020 }
2121 });
@@ -123,6 +123,23 @@
123123 });
124124 });
125125
126+describe("one-click unsubscribe", () => {
127+ it.each(samples.filter((s) => !isMandatory(categoryOf(s.job))).map((s) => [s.id, s.job] as const))("%s: List-Unsubscribe turns that kind of email off", async (_id, job) => {
128+ const msg = await buildMessage(env, job, { email: "diffy@example.com", login: "diffy", accountId: "acc1" });
129+ const link = /^<(.+)>$/.exec(msg.headers["List-Unsubscribe"]!)![1]!;
130+ const token = link.slice(`${PUB}/-/unsubscribe/`.length);
131+ expect(await readUnsubToken(env, token)).toEqual({ accountId: "acc1", scope: { t: "category", category: categoryOf(job) } });
132+ expect(msg.headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
133+ // Muting one thread stays a footer link.
134+ if (job.kind === "thread") expect(msg.text).toContain("Mute this thread");
135+ });
136+
137+ it("is left out without an account to change", async () => {
138+ const msg = await buildMessage(env, samples.find((s) => s.id === "comment")!.job, { email: "someone@example.com" });
139+ expect(msg.headers["List-Unsubscribe"]).toBeUndefined();
140+ });
141+});
142+
126143 describe("unsubscribe links", () => {
127144 it("round-trips and rejects tampering", async () => {
128145 const t = await unsubToken(env, { accountId: "acc1", scope: { t: "thread", repoId: "r1", number: 7, repo: "acme/website" } });
addedapps/diffinery/test/unit/suppression.test.ts+142−0View file
@@ -1,0 +1,142 @@
1+import { describe, expect, it } from "vitest";
2+import type { Env } from "../../src/env.js";
3+import { contactUris, securityTxt } from "../../src/http/security-txt.js";
4+import { deliver } from "../../src/mail/send.js";
5+import { checkSignature, readVerifyToken, recordMailEvents, signEvents, suppressionFor, Suppressions, verifyToken } from "../../src/mail/suppression.js";
6+import type { MailJob } from "../../src/mail/types.js";
7+import { testD1 } from "./stubs/d1.js";
8+
9+const at = "2026-10-01T00:00:00.000Z";
10+const PUB = "https://diffinery.example";
11+
12+/** A D1 with one person (ann, Ann@Example.com) and an EMAIL binding that records what it sends. */
13+function setup() {
14+ const DB = testD1();
15+ DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, email, status, created_at) VALUES ('ann', 'ann', 'user', 'Ann@Example.com', 'active', ?)").run(at);
16+ const sent: Array<{ to: unknown; subject: string }> = [];
17+ const EMAIL = { send: async (m: { to: unknown; subject: string }) => (sent.push(m), { messageId: `m${sent.length}` }) };
18+ const env = { DB, EMAIL, PUBLIC_URL: PUB, MASTER_KEY: "test-master-key", MAIL_FROM: "notifications@diffinery.example" } as unknown as Env;
19+ const log = () => DB.sqlite.prepare("SELECT to_addr, kind, status, detail FROM email_log ORDER BY rowid").all() as Array<{ to_addr: string; kind: string; status: string; detail: string | null }>;
20+ return { env, DB, sent, log };
21+}
22+
23+const job = (over: Partial<MailJob> = {}): MailJob => ({ kind: "test", data: { by: "ann" }, to: { accountId: "ann" }, reason: "account", at, ...over }) as MailJob;
24+const signinLink = (email: string): MailJob => ({ kind: "signin_link", data: { url: `${PUB}/signin/x`, admin: "root", expiresAt: at }, to: { email }, reason: "account", at });
25+
26+describe("the suppression check", () => {
27+ it("sends when the address isn't suppressed", async () => {
28+ const { env, sent, log } = setup();
29+ expect((await deliver(env, job())).status).toBe("sent");
30+ expect(sent).toHaveLength(1);
31+ expect(log()[0]).toMatchObject({ to_addr: "Ann@Example.com", status: "sent" });
32+ });
33+
34+ it("sends nothing to a suppressed address, security and sign-in email included, whatever its case", async () => {
35+ const { env, sent, log } = setup();
36+ await new Suppressions(env.DB).add({ email: "ann@example.COM", reason: "hard-bounce", detail: "550 5.1.1 User unknown", source: "cloudflare" });
37+ const notification = job({ kind: "repo_created", data: { repo: "acme/x", creator: "kim", visibility: "private", defaultBranch: "main", description: "" }, reason: "admin" } as Partial<MailJob>);
38+ const security = job({ kind: "passkey_added", data: { name: "laptop" }, reason: "security_alert" } as Partial<MailJob>);
39+ for (const j of [job(), notification, security, signinLink(" ANN@example.com "), { ...job(), force: true }]) {
40+ const r = await deliver(env, j);
41+ expect(r).toEqual({ status: "skipped", detail: "address is suppressed: email to it bounced permanently" });
42+ }
43+ expect(sent).toEqual([]);
44+ expect(log().every((r) => r.status === "skipped" && r.detail?.startsWith("address is suppressed"))).toBe(true);
45+ // Another address is not affected.
46+ expect((await deliver(env, signinLink("bob@example.com"))).status).toBe("sent");
47+ expect(sent).toHaveLength(1);
48+ });
49+
50+ it("lets only the owner's verification email through, and sends again once removed", async () => {
51+ const { env, sent } = setup();
52+ const list = new Suppressions(env.DB);
53+ await list.add({ email: "ann@example.com", reason: "complaint", source: "webhook" });
54+ const verify = job({ kind: "email_verify", data: { email: "ann@example.com", url: `${PUB}/settings/email/verify/x`, expiresAt: at } } as Partial<MailJob>);
55+ expect((await deliver(env, verify)).status).toBe("skipped");
56+ expect((await deliver(env, verify, { ignoreSuppression: true })).status).toBe("sent");
57+ expect(sent).toHaveLength(1);
58+ expect((await list.remove("ANN@example.com"))?.reason).toBe("complaint");
59+ expect((await deliver(env, job())).status).toBe("sent");
60+ });
61+});
62+
63+describe("bounce and complaint events", () => {
64+ const ev = (type: string, payload: Record<string, unknown>) => ({ type: `cf.email.sending.${type}`, source: { type: "email.sending" }, payload });
65+
66+ it("suppresses permanent bounces and complaints only", () => {
67+ expect(suppressionFor(ev("message.bounced", { recipient: "User@Example.net", bounce: { type: "hard", classification: "permanent_failure", reason: "550 5.1.1 User unknown" } }))).toEqual({ email: "user@example.net", reason: "hard-bounce", detail: "550 5.1.1 User unknown" });
68+ expect(suppressionFor(ev("message.complained", { recipient: "user@example.net", complaint: { type: "abuse" } }))).toEqual({ email: "user@example.net", reason: "complaint", detail: "Spam complaint (abuse)" });
69+ expect(suppressionFor(ev("message.bounced", { recipient: "user@example.net", bounce: { type: "soft" } }))).toBeNull();
70+ expect(suppressionFor(ev("message.deferred", { recipient: "user@example.net", bounce: { type: "soft" } }))).toBeNull();
71+ expect(suppressionFor(ev("message.delivered", { recipient: "user@example.net" }))).toBeNull();
72+ expect(suppressionFor(ev("message.bounced", { recipient: "not an address" }))).toBeNull();
73+ expect(suppressionFor("junk")).toBeNull();
74+ expect(suppressionFor(null)).toBeNull();
75+ });
76+
77+ it("records each suppression and audits it", async () => {
78+ const { env } = setup();
79+ const audits: unknown[] = [];
80+ const r = await recordMailEvents(env.DB, async (e) => void audits.push(e), [ev("message.bounced", { recipient: "a@example.net", bounce: { type: "hard" } }), ev("message.delivered", { recipient: "b@example.net" }), ev("message.complained", { recipient: "a@example.net" })], "cloudflare");
81+ expect(r).toEqual({ suppressed: ["a@example.net", "a@example.net"], ignored: 1 });
82+ // The newer event replaces the older one.
83+ expect(await new Suppressions(env.DB).get("a@example.net")).toMatchObject({ reason: "complaint", source: "cloudflare" });
84+ expect(await new Suppressions(env.DB).get("b@example.net")).toBeNull();
85+ expect(audits[0]).toEqual({ action: "email.suppression.add", target: "a@example.net", detail: { reason: "hard-bounce", source: "cloudflare", detail: null } });
86+ });
87+});
88+
89+describe("the signed intake", () => {
90+ const body = JSON.stringify({ type: "message.bounced", payload: { recipient: "a@example.net" } });
91+
92+ it("accepts a good, recent signature and nothing else", async () => {
93+ const t = Math.floor(Date.now() / 1000);
94+ const header = await signEvents("s3cret", body, t);
95+ expect(header).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/);
96+ expect(await checkSignature("s3cret", header, body)).toBe(true);
97+ expect(await checkSignature("other", header, body)).toBe(false);
98+ expect(await checkSignature("s3cret", header, body.replace("a@", "b@"))).toBe(false);
99+ expect(await checkSignature("s3cret", null, body)).toBe(false);
100+ expect(await checkSignature("s3cret", "", body)).toBe(false);
101+ expect(await checkSignature("", header, body)).toBe(false);
102+ expect(await checkSignature("s3cret", await signEvents("s3cret", body, t - 600), body)).toBe(false);
103+ expect(await checkSignature("s3cret", header, body, (t + 301) * 1000)).toBe(false);
104+ });
105+});
106+
107+describe("verification links", () => {
108+ const env = { PUBLIC_URL: PUB, MASTER_KEY: "test-master-key" } as unknown as Env;
109+
110+ it("round-trip, expire and refuse tampering", async () => {
111+ const t = await verifyToken(env, { accountId: "ann", email: "Ann@Example.com", expiresAt: Date.now() + 60_000 });
112+ expect(await readVerifyToken(env, t)).toMatchObject({ accountId: "ann", email: "ann@example.com" });
113+ expect(await readVerifyToken(env, t, Date.now() + 120_000)).toBeNull();
114+ const [, sig] = t.split(".");
115+ const forged = Buffer.from(JSON.stringify({ a: "bob", e: "bob@example.com", x: Date.now() + 60_000 })).toString("base64url");
116+ expect(await readVerifyToken(env, `${forged}.${sig}`)).toBeNull();
117+ expect(await readVerifyToken({ ...env, MASTER_KEY: "another" } as Env, t)).toBeNull();
118+ });
119+});
120+
121+describe("security.txt", () => {
122+ it("is not served without a contact", () => {
123+ expect(securityTxt({ PUBLIC_URL: PUB })).toBeNull();
124+ expect(securityTxt({ PUBLIC_URL: PUB, SECURITY_CONTACT: " " })).toBeNull();
125+ expect(securityTxt({ PUBLIC_URL: PUB, SECURITY_CONTACT: "not a contact" })).toBeNull();
126+ });
127+
128+ it("has every RFC 9116 field, with Expires about a year ahead", () => {
129+ const now = Date.parse("2026-10-14T15:30:00Z");
130+ const txt = securityTxt({ PUBLIC_URL: `${PUB}/`, SECURITY_CONTACT: "security@diffinery.example, https://diffinery.example/report" }, now)!;
131+ expect(txt.split("\n")).toEqual([
132+ "Contact: mailto:security@diffinery.example",
133+ "Contact: https://diffinery.example/report",
134+ "Expires: 2027-10-09T00:00:00Z",
135+ `Policy: ${PUB}/docs/security/disclosure`,
136+ "Preferred-Languages: en",
137+ `Canonical: ${PUB}/.well-known/security.txt`,
138+ "",
139+ ]);
140+ expect(contactUris("mailto:a@b.example,tel:+1-555-0100,http://insecure.example,javascript:alert(1)")).toEqual(["mailto:a@b.example", "tel:+1-555-0100"]);
141+ });
142+});
modifiedapps/diffinery/wrangler.jsonc+5−2View file
@@ -34,7 +34,9 @@
3434 ],
3535 "consumers": [
3636 { "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" }
37+ { "queue": "diffinery-mail", "max_batch_size": 20, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-mail-dlq" },
38+ // Bounces and complaints: subscribe this queue to Email Sending's events (docs/admin/site-administration.md).
39+ { "queue": "diffinery-mail-events", "max_batch_size": 50, "max_batch_timeout": 5, "max_retries": 8 }
3840 ]
3941 },
4042 // Cloudflare Email Sending (notifications, security alerts, invites). The sending
@@ -111,7 +113,8 @@
111113 ],
112114 "consumers": [
113115 { "queue": "diffinery-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 },
114- { "queue": "diffinery-mail-dev", "max_batch_size": 20, "max_batch_timeout": 1 }
116+ { "queue": "diffinery-mail-dev", "max_batch_size": 20, "max_batch_timeout": 1 },
117+ { "queue": "diffinery-mail-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 }
115118 ]
116119 },
117120 // Local email: Miniflare captures sent messages instead of delivering them.
modifiedscripts/bootstrap.sh+1−1View file
@@ -84,7 +84,7 @@
8484 # ------------------------------------------------------------------ Queues
8585 log "Queues"
8686 EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')"
87-for q in diffinery-events diffinery-events-dlq diffinery-mail diffinery-mail-dlq; do
87+for q in diffinery-events diffinery-events-dlq diffinery-mail diffinery-mail-dlq diffinery-mail-events; do
8888 if grep -qx "$q" <<<"$EXISTING_QUEUES"; then
8989 echo "queue $q exists"
9090 else