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 messagenotify) 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_settingsdecide delivery: participating or watching, on the web and/or by email.
- Recipients come from
- Categorised notifications (
notifyUsers/notifyQuietlyinsrc/server/notify/deliver.ts, queue messagenotify.direct) cover everything else.- The caller names the recipients and an
accessrule. - Delivery follows the per-category prefs in
notification_category_prefs. Defaults are insrc/lib/notification-categories.ts.
- The caller names the recipients and an
- 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 readRepo read access adminOwner, org owners and admin collaborators securityAdmins plus org security managers ( security_managers)accountThe user, or the org's owners advisorySecurity 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_itemsuntil the 5-minute cron (flushNotificationDigests) finds them due, then go out as one email per category. -
Watch levels.
ignoreon a repo also mutes theci,ci_watching,deployandmergefixcategories 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-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058) headers. GET /email/unsubscribeonly shows a confirmation form.POSTturns 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.
- Every notification email carries a manage-prefs link and a per-category unsubscribe link, plus
Matrix
Columns:
- Web and Email are the defaults for new users.
- Prefs key is the setting that controls the event:
participating/watchingare the global switches innotification_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 | 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 | 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 | 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 | 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+<issue>.<user>.<mac>@<EMAIL_REPLY_DOMAIN>. 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_DOMAINsets the domain. When it is empty, the domain ofEMAIL_FROMis used, and a localhost sender turns replies off. Then route the reply address to the Worker. With subaddressing enabled, a literal rule forreply@<domain>matches everyreply+…@<domain>address. Don't use the catch-all.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 gitforThe rule can only be created once the Worker is deployed. In
email(),reply+addresses go tosrc/server/email-reply.tsandabuse@goes to the abuse handler. Anything else is rejected.
Interface for other workstreams
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",
});
recipientsmay contain nulls and duplicates; they are dropped.- Callers must never pass secret values in
titleordetails. notifyQuotaThresholddeduplicates per(account, metric, period, threshold)whenperiodis set.
Local development and tests
-
The dev mail sink is opt-in. Set
EMAIL_DEV_SINK=kv(store in KV for 24 hours) orEMAIL_DEV_SINK=log(print to the console) in.dev.vars. It is honoured only whenPUBLIC_URLis a loopback host; otherwise mail is sent normally (or dropped with a warning when no verifiedEMAIL_FROMis set). -
Viewer routes work only when the sink is
kv,PUBLIC_URLis loopback and the request itself targets a loopback host; otherwise they return 404:/__dev/mail(HTML list),/__dev/mail/<id>(one message,?format=jsonfor JSON),POST /__dev/mail/clear.- Harness API (gfd
sinks.MailSink):GET /__dev/mail/messages?to=<addr>returns[{id, from, to[], subject, text, html, headers, received_at}], oldest first;DELETE /__dev/mail/messagesreturns 204.
-
NOTIFY_DIGEST_DELAY_MS(dev forges only) overrides the daily digest window;0makes the next cron run (GET /cdn-cgi/handler/scheduled) flush immediately. -
Email text bodies contain full absolute URLs (verify, reset, unsubscribe and manage-prefs links).
-
bun run test:e2e:notify(scripts/e2e-notify.ts,BASE_URLdefaults tohttp://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.