171 lines · 16.0 KB

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 until the 5-minute cron (flushNotificationDigests) finds them due, then go out as one email per category.

  • 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+<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_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@<domain> matches every reply+…@<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 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

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/<id> (one message, ?format=json for 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/messages returns 204.
  • NOTIFY_DIGEST_DELAY_MS (dev forges only) overrides the daily digest window; 0 makes 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_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.