# Notifications and email This page lists every user-relevant event and who hears about it. It also covers the web and email defaults, the preference key that controls each event, and the email template it uses. ## How delivery works - **Thread notifications** (`emitNotification`, queue message `notify`) cover issues, pull requests, reviews, releases and CI-adjacent thread activity. - Recipients come from `directRecipients`, thread participants, `@mentions`, repo watchers (by watch level) and thread subscriptions. - Each recipient's global `notification_settings` decide delivery: participating or watching, on the web and/or by email. - **Categorised notifications** (`notifyUsers` / `notifyQuietly` in `src/server/notify/deliver.ts`, queue message `notify.direct`) cover everything else. - The caller names the recipients and an `access` rule. - Delivery follows the per-category prefs in `notification_category_prefs`. Defaults are in `src/lib/notification-categories.ts`. - **Account security notices** (`notifySecurityEvent`, `noteSignIn`) are email-only. They go only to the account's own verified address and cannot be turned off. ### Guarantees - **Access is re-checked at send time** for every recipient (`recipientHasAccess`), and again when a digest is flushed. Losing access after the event drops the notification. | Rule | Who passes | | ---------- | ------------------------------------------------------------------------------------------------------------- | | `read` | Repo read access | | `admin` | Owner, org owners and admin collaborators | | `security` | Admins plus org security managers (`security_managers`) | | `account` | The user, or the org's owners | | `advisory` | Security admins, plus the author and the collaborators of that advisory (lets reporters see their own report) | - **No secret values.** Secret-scanning notices name the detector and the file and line only; previews are not included. Token notices name the token and never include its value. Free-text details are clipped to 500 characters. - **Email budget.** Each recipient gets at most 20 emails per hour (`EMAILS_PER_HOUR`), counted across thread, category and security emails. - Categorised emails over budget go into a catch-up digest that is sent an hour later. - Thread and security emails over budget are dropped. The web notification still arrives. - **Digests.** Categories marked digestable can be switched to "Daily digest". Items wait in `notification_digest_items`; inserting one arms the recipient's AccountState alarm for its `flush_after`, and the alarm (`flushAccountDigests`) sends one email per due category. No cron walks the accounts. - **Watch levels.** - `ignore` on a repo also mutes the `ci`, `ci_watching`, `deploy` and `mergefix` categories for that repo. - Security, quota, runner, import and integration notices are never muted by watch level. - **Unsubscribe.** - Every notification email carries a manage-prefs link and a per-category unsubscribe link, plus `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) headers. - `GET /email/unsubscribe` only shows a confirmation form. `POST` turns email off for that category, or for the participating/watching email switch. - The link carries a per-user random token (`notification_unsubscribe_tokens`). Web notifications are not affected. ## Matrix **Columns:** - **Web** and **Email** are the defaults for new users. - **Prefs key** is the setting that controls the event: - `participating` / `watching` are the global switches in `notification_settings`. - Category keys live in `notification_category_prefs`. - `—` means the notice is always sent. - **Template** is the `emailTemplates.*` function that renders the email. - **Digest:** ✓ means the category can be switched to a daily digest. ### Thread and repository activity | Event | Recipients | Web | Email | Prefs key | Template | Digest | | ----------------------------------------------- | -------------------------------------------------------------- | --- | ------------------------------ | ------------------------ | -------------- | ------ | | Issue/PR comment | Participants, thread subscribers, `all` watchers, `@mentioned` | on | participating on, watching off | participating / watching | `notification` | | | `@mention` (issues, PRs, comments, discussions) | Mentioned users with read access | on | on | participating | `notification` | | | Review requested (manual, CODEOWNERS) | Requested reviewers | on | on | participating | `notification` | | | Review submitted, dismissed | PR participants | on | on | participating / watching | `notification` | | | Issue/PR closed, reopened, merged; labels | Participants and watchers | on | participating on | participating / watching | `notification` | | | Discussion reply | Participants and watchers | on | participating on | participating / watching | `notification` | | | Release published | `all` and `releases` watchers | on | watching off | watching | `notification` | | ### Security | Event | Recipients | Web | Email | Prefs key | Template | Digest | | -------------------------------------- | ----------------------------------------------------------------- | --- | ----- | ---------------------- | ---------- | ------ | | Secret scanning alert (push scan) | Repo admins and security managers | on | on | `security_alert` | `category` | ✓ | | Push-protection bypass | Repo admins and security managers, except the person who bypassed | on | on | `security_alert` | `category` | ✓ | | Code scanning alerts (SARIF upload) | Repo admins and security managers | on | on | `security_alert` | `category` | ✓ | | Newly vulnerable dependencies (rescan) | Repo admins and security managers | on | on | `security_alert` | `category` | ✓ | | Private vulnerability report filed | Repo admins and security managers | on | on | `vulnerability_report` | `category` | | | Advisory state change or comment | Admins and security managers (minus the actor), plus the reporter | on | on | `vulnerability_report` | `category` | | ### CI, deploys and runners | Event | Recipients | Web | Email | Prefs key | Template | Digest | | ------------------------------------------------------------------ | ---------------------------------------------------------------- | --- | ----- | -------------------- | ---------- | ------ | | Workflow run failed | Run actor, PR author for PR runs | on | on | `ci` | `category` | ✓ | | Workflow run failed (watchers) | `all` watchers who enabled "CI activity in watched repositories" | off | off | `ci_watching` | `category` | ✓ | | Workflow fixed (success after failure, same workflow and ref) | Same as failed | on | on | `ci` / `ci_watching` | `category` | ✓ | | Sites deploy failed | Pusher or requester, plus repo admins | on | on | `deploy` | `category` | ✓ | | Sites deploy failed | Pusher or requester, plus repo admins | on | on | `deploy` | `category` | ✓ | | Self-hosted runner offline (more than 10 minutes, once per outage) | Repo runners: repo admins. Owner runners: the user or org owners | on | on | `runner` | `category` | ✓ | | Merge-fix applied, suggested or failed | The user who started it | on | off | `mergefix` | `category` | ✓ | ### Accounts and integrations | Event | Recipients | Web | Email | Prefs key | Template | Digest | | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | --- | ------ | ------------- | ------------------------------ | ------ | | Import or fork failed | Whoever started it (falls back to the owning account's admins) | on | on | `import` | `category` | | | LFS storage or bandwidth at 80% or 100% (once per month per threshold) | The user, or the org's owners | on | on | `quota` | `category` | | | Metering quota or budget threshold | The user, or the org's owners (`notifyQuotaThreshold`) | on | on | `quota` | `category` | | | Integration delivery or import job dead-lettered (once an hour per integration) | Repo admins, or org owners | on | on | `integration` | `category` | ✓ | | Email verification, password reset | The address itself | — | always | — | `verifyEmail`, `passwordReset` | | | Password changed or reset; 2FA on or off; passkey added or removed; recovery code used; recovery codes regenerated; email change requested | The account | — | always | — | `securityNotice` | | | New sign-in from an unseen IP and user agent (not the first sign-in) | The account | — | always | — | `securityNotice` | | | Personal access token created | The account | — | always | — | `securityNotice` | | ### Not yet notified These events don't exist yet, or aren't wired up: - **Org invitations and repository transfer requests.** Neither flow exists. Membership and transfers apply immediately. - **OAuth/MCP token minting and refresh** (`src/server/mcp/grants.ts`). It runs on every refresh, so it would be noise. - **A newly published advisory.** It doesn't re-alert repos that already depend on the package. Alerts fire on the next dependency rescan. ## Replying by email Thread notification emails for issues and pull requests quote the triggering text and carry a per-recipient `Reply-To` address, `reply+..@`. A reply posts the text as a comment by that user. - The MAC (HMAC-SHA256 under `SECRETS_KEY`) binds the address to one user and one thread, so only mail that user received can be answered. - The reply must come from an address that is verified on that account. Access, locks, archived repos and the write rate limit are checked as for a web comment. Failures bounce with the reason. - Auto-replies (`Auto-Submitted`, `Precedence: bulk/list/auto_reply`) are dropped silently. Quoted text, signatures and mobile footers are stripped (`src/lib/email-reply-text.ts`). - Setup: `EMAIL_REPLY_DOMAIN` sets the domain. When it is empty, the domain of `EMAIL_FROM` is used, and a localhost sender turns replies off. Then route the reply address to the Worker. With subaddressing enabled, a literal rule for `reply@` matches every `reply+…@` address. Don't use the catch-all. ```sh wrangler email routing rules create gitfor.dev --name "issue replies" --match-type literal \ --match-field to --match-value reply@mail.gitfor.dev --action-type worker --action-value gitfor ``` The rule can only be created once the Worker is deployed. In `email()`, `reply+` addresses go to `src/server/email-reply.ts` and `abuse@` goes to the abuse handler. Anything else is rejected. ## Interface for other workstreams ```ts import { notifyQuietly } from "~/server/notify/deliver"; await notifyQuietly({ category: "deploy", repoId, accountId: null, access: "read", recipients: [userId], title: "…", details: ["…"], url: "/owner/repo/…", }); import { notifyQuotaThreshold } from "~/server/notify/quota"; await notifyQuotaThreshold({ accountId, metric: "budget", label: "Spending budget", percent: 80, used: "$40.00", limit: "$50.00", url: "/settings/billing", period: "2026-10", }); ``` - `recipients` may contain nulls and duplicates; they are dropped. - Callers must never pass secret values in `title` or `details`. - `notifyQuotaThreshold` deduplicates per `(account, metric, period, threshold)` when `period` is set. ## Local development and tests - The dev mail sink is opt-in. Set `EMAIL_DEV_SINK=kv` (store in KV for 24 hours) or `EMAIL_DEV_SINK=log` (print to the console) in `.dev.vars`. It is honoured only when `PUBLIC_URL` is a loopback host; otherwise mail is sent normally (or dropped with a warning when no verified `EMAIL_FROM` is set). - Viewer routes work only when the sink is `kv`, `PUBLIC_URL` is loopback and the request itself targets a loopback host; otherwise they return 404: - `/__dev/mail` (HTML list), `/__dev/mail/` (one message, `?format=json` for JSON), `POST /__dev/mail/clear`. - Harness API (gfd `sinks.MailSink`): `GET /__dev/mail/messages?to=` returns `[{id, from, to[], subject, text, html, headers, received_at}]`, oldest first; `DELETE /__dev/mail/messages` returns 204. - `NOTIFY_DIGEST_DELAY_MS` (dev forges only) overrides the daily digest window; `0` makes the recipient's AccountState alarm flush the digest within seconds. - Email text bodies contain full absolute URLs (verify, reset, unsubscribe and manage-prefs links). - `bun run test:e2e:notify` (`scripts/e2e-notify.ts`, `BASE_URL` defaults to `http://localhost:3000`) runs against a dev server from the same worktree. It triggers each category over HTTP, then asserts the web notification and the captured email, along with: - the headers - redaction - access checks - one-click unsubscribe Cases: `security advisory ci runner deploy digest import quota account unsubscribe`.