# Notifications In-app notifications for activity on issues, PRs and discussions you opened, @-mentions of you, and repositories you watch, with optional email delivery (see [Email](#email)). ## Notification Types | Type | Triggered when | | ------------------ | ----------------------------------------------------------------------------------------------------- | | `issue_comment` | Someone comments on an issue you opened | | `pr_comment` | Someone comments on a PR you opened | | `issue_closed` | Someone closes an issue you opened, by hand or with a closing keyword in a merged PR or pushed commit | | `issue_reopened` | Someone reopens an issue you opened | | `pr_merged` | Someone merges a PR you opened | | `pr_closed` | Someone closes a PR you opened | | `pr_review` | Someone reviews a PR you opened | | `mention` | Someone @-mentions you in a comment | | `discussion_reply` | Someone replies to a discussion you started | | `repo_transfer` | Someone wants to transfer a repository to you; links to `/repos/transfers` | | `pr_opened` | (type reserved; not currently auto-fired) | Notifications are never created when `actorID == authorID` (self-actions are silent), which covers the comment-only review a PR's author may leave on their own PR; the author cannot approve or request changes (`ErrOwnPullVerdict`). Every notification, watchers' copies included, is created only if its recipient can read the repo at that moment (`RepoService.CanRead`), so watching a repo or having opened the issue, PR or discussion stops counting once the user is removed as a collaborator, removed from the org or demoted from org owner. `CommentService` likewise records an @-mention only for users who can read the repo. The exception is `repo_transfer`: its recipient can't read a private repo until they accept it. ## Who is notified Comments, reviews, state changes and discussion replies go through `NotificationService.notifyThread`. The recipients are the thread's author, its `subscribed` rows (see `ThreadSubscriptionService`) and, except for discussion replies, the repo's non-ignoring watchers. It drops the actor, users with a `muted` row on the thread, and anyone who can't read the repo. A subscriber needn't watch the repo, and an `ignoring` watcher who subscribed to the thread is still notified. An author with no row, such as one on a thread that predates subscriptions, is still notified; a muted author is not. Discussion replies never reached watchers, so they still don't: author and subscribers only. `NotifyMention` ignores a mute. After creating the notification it calls `SubscribeOnMention`, which flips the row to `subscribed`. A mention for a user who can't read the repo creates neither. A failure to list muted users or subscribers is logged and the fan-out is skipped, rather than notifying someone who muted the thread. ## Auto-subscribe Taking part in a thread subscribes you to it through `ThreadSubscriptionService.AutoSubscribe`, which inserts only if you have no row: the first reason is kept and a mute is never undone. | Action | Service | Reason | Who | | ------------------------------------------------ | ---------------------------------------------------------------------------------- | --------- | --------------------------- | | Open an issue, PR or discussion | `IssueService.Create`, `PullService.Create`, `DiscussionService.Create` | `author` | the author | | Comment on an issue or PR, reply to a discussion | `CommentService.CreateForIssue` / `CreateForPull`, `DiscussionService.CreateReply` | `comment` | the commenter | | Submit a PR review | `PullReviewService.SubmitReview` | `review` | the reviewer | | Assign someone | `AssigneeService.AddToIssue` / `AddToPull` | `assign` | the assignee, not the actor | Each service gets the subscriber through `WithThreadSubscriptions`; without it nothing is recorded. A failed write is logged and the request still succeeds. Mentions subscribe through `NotifyMention` instead. Threads that existed before the feature get no rows. ## Sidebar control The issue, PR and discussion sidebars show a Notifications section to logged-in viewers (`fragments.ThreadSubscription`, built from `view.ThreadSubscriptionData`). It shows `ThreadSubscriptionService.Status`: a Subscribe or Unsubscribe button, a "Muted" pill and a reason line. The button calls `PUT .../{issues|pulls|discussions}/{number}/subscription` (`setThreadSubscription`), which calls `Set` and swaps the section in place. Subscribe writes `subscribed/manual`; Unsubscribe writes `muted`, even for a user who only watches the repo. The control never touches the repo watch, which is only changed from the repo page's Watch control. ## Thread kind `notifications.subject_kind` is `issue`, `pull` or `discussion`: the kind of thread `subject_id` numbers, since the number alone doesn't say. Every `Notify*` call sets it except `NotifyRepoTransfer`, which leaves it NULL. Migration 115 backfilled older rows from `type`, and mentions from the section of `subject_url`; a mention whose URL names no section stays NULL. `model.Notification.SubjectKind` is empty for NULL. ## Email Sent only when SMTP is configured. Users set these on `/settings#notifications` (saved by POST `/settings/notifications`); they never affect in-app notifications. | `users` column | Effect | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `email_notifications` | Master switch; off means no email at all | | `email_digest` | `immediate` (one email per notification addressed to you), `daily` / `weekly` (digest at 08:00 UTC, weekly on Mondays), `never` | | `notify_mention` | Off suppresses email for `mention` | | `notify_pr_review` | Off suppresses email for `pr_review` | `wantsEmail(user, type, digestMode)` in `internal/service/email_service.go` decides per notification, and says no for a [suspended account](./access-control.md#suspended-accounts): `EmailService.SendNotification` calls it with `immediate`, and `NotificationService.ListUnreadForDigest` calls it with the digest mode. The digest job (`runEmailDigest` in `cmd/server/main.go`) first narrows users with `UserService.ListUsersForDigest`, whose SQL repeats the master-switch and digest-mode check. Immediate email goes to the notification's direct recipient (the subject's author, or the mentioned user) and to users with a `subscribed` row on the thread. Watchers without a row get in-app copies of issue/PR notifications; those are never emailed immediately, but digests draw from all unread notifications, so daily/weekly users also get watched-repo activity, and `notify_pr_review` filters watched-repo reviews there too. The digest re-checks read access (`NotificationStore.ListUnreadReadable`, with `readableBy`) and leaves out notifications on repos the user can no longer read, keeping a `repo_transfer` notification while its transfer is still offered to the user. ## Unread Count in Navbar `basePage()` calls `NotificationService.CountUnread` on every page render and passes the count as `BasePage.UnreadNotifCount`. Templates show a badge next to the Notifications link. ## Pages & API | Route / Endpoint | Auth | Description | | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | GET `/notifications` | Required | Inbox, 25 thread groups per page: `?filter=inbox\|unread\|read`, `?reason=mention\|review_requested\|assigned\|subscribed`, `?page=N` (clamped to the last page) | | PATCH `/api/notifications/{id}` | Required | Mark single notification as read (HTMX-aware) | | POST `/api/notifications/read-all` | Required | Mark all notifications as read (HTMX-aware) | | GET `/api/notifications/unread-count` | Required | Returns `{"count": N}` JSON | | POST `/api/notifications/done` | Required | Mark the notifications in repeated form field `ids` as read (at most 100; other users' ids are ignored) | | POST `/api/notifications/unsubscribe` | Required | Mute the thread behind each notification in `ids` and mark that thread's unread notifications read (other users' ids are ignored) | The mark-read and bulk endpoints take the same `?filter`, `?reason` and `?page`, and their HTMX responses swap `pages.NotificationsInbox` into `#notifications-view`, so the view stays on the caller's page. Each row has a checkbox, the actor's avatar and, on hover or focus, Done and Unsubscribe buttons; ticking rows swaps the list header for a bulk bar. Unsubscribe mutes the notification's thread (`NotificationService.MuteThreads`) and never touches the repo watch, which only the repo page's Watch control changes. It writes one `muted` row per distinct thread among the caller's own selected notifications, using `subject_kind` and `subject_id`, then marks every unread notification on those threads read, selected or not, so they leave the unread filter and the email digest; a muted thread creates no new ones. Done marks the selected group and its older thread notifications read; newer arrivals remain unread. Every row has the button (tooltip "Unsubscribe: mute this thread. Repo watch unchanged."), but it is disabled on `repo_transfer` rows and on rows with no `subject_kind`, which name no thread. Unmuting is the thread sidebar's Subscribe button. Done is mark-read: the row stays in the inbox and moves to the Read filter. Notifications are grouped by thread before pagination, then displayed under their repo. Reason filters match mentions or current review requests, assignments, and subscriptions. The inbox supports `j`/`k` navigation, `e` to mark done, `x` to select, and `?` for the shortcut list. A row shows `subject_title` when set, with the "@actor did X" text beside it; mentions, repo transfers and rows created before migration 112 have no title and show only the action text. ## NotificationService (`internal/service/notification_service.go`) - `ListPage(ctx, userID, filter, reason, page)` → `(NotificationPage, error)` — one page plus the inbox/unread/read counts the sidebar shows - `List(ctx, userID)` → `([]Notification, error)` — newest 50 - `ListUnreadForDigest(ctx, u, mode)` → `([]Notification, error)` - `MarkDone(ctx, userID, ids)` → `error` — ids that aren't the user's are ignored - `MuteThreads(ctx, userID, ids)` → `error` — see [Pages & API](#pages--api); needs `WithThreadSubscriptions` - `CountUnread(ctx, userID)` → `(int, error)` - `MarkRead(ctx, id, userID)` → `error` - `MarkAllRead(ctx, userID)` → `error` - `NotifyIssueComment(ctx, repo, issue, actorID, actorName)` — call from `CreateIssueComment` handler - `NotifyPRComment(ctx, repo, pr, actorID, actorName)` — call from `CreatePullComment` handler - `NotifyIssueStateChange(ctx, repo, issue, actorID, actorName)` — call from `UpdateIssue` handler; `IssueCloser` calls it for each issue a closing keyword closes, with the merger or pusher as actor - `NotifyPRStateChange(ctx, repo, pr, actorID, actorName)` — call from `UpdatePull` handler - `NotifyPRReview(ctx, repo, pr, actorID, actorName)` — call from `SubmitReview` handler - `NotifyDiscussionReply(ctx, repo, discussion, actorID, actorName)` — call from `CreateReply` handler; author and subscribers only - `WithThreadSubscriptions(svc)` — builder; without it no thread rows are consulted - `NotifyMention(ctx, repo, actorID, actorName, mentionedUserID, kind, subjectNumber, subjectURL)` — called by `CommentService` for each readable @-mention in a new issue/PR comment; `kind` is `model.ThreadKindIssue` or `ThreadKindPull` - `NotifyRepoTransfer(ctx, transfer)` — call from `TransferRepo` handler when the transfer awaits its recipient; `SubjectID` is the transfer ID. It skips the `CanRead` check, since the recipient can't read a private repo until they accept Handlers run the `Notify*` calls in a goroutine with `context.WithoutCancel(r.Context())`: net/http cancels the request context as soon as the handler returns, which would abort the inserts and the email.