Skip to main content

max / makenotwork

Give repositories their own issue notification lists Step 6 of wiki [[mnw-mailing-lists]], the last structural piece. Issue mail rode users.notify_issues, one account-wide bool, so somebody who wanted out of one noisy repository had to leave every repository they could see. That is not a preference anybody would choose; it was the only one on offer. SEMANTICS UNCHANGED, on purpose. Who is eligible for an issue email is still the repo owner for a new issue and the issue's participants for a comment. What changed is what a person can opt out OF. Making the repo list decide eligibility instead would mean commenting once on one issue subscribed you to every issue in that repository, which is more mail than anyone asked for. So the gate reads "not opted out of this repo" rather than "opted in to it", and an absent row still means nothing has been said. The account bool still gates alongside the per-repo mute. Its remaining reads move in GoingsOn e5b6475a, and it retires there rather than here, so this commit cannot be the one that stops somebody's mail. Lists are seeded by trigger, like 187. Repositories are created by the web handler, the git-over-SSH push path and the seed flow, and a creation path that forgets is a repository whose watchers can never opt out. A second trigger renames the list with the repository, since that title is the one place a subscriber reads the name. The control sits on the repository page and posts to the API tree rather than to git_routes, which is a bare Router with no CSRF envelope; a POST registered there would silently skip it. It is hidden when logged out, because there is nothing to mute for somebody we could not mail. Also fixes `subscribe` leaving unsubscribed_at NULL when writing an unsubscribed state. A row that says unsubscribed without saying when cannot answer the first question asked of an opt-out. It affected the notification-preference path from step 5 as well. Releases lists are not created yet. An empty list on every repository is a row nobody reads and a toggle nobody wants; adding it later is one INSERT.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 04:25 UTC
Signed with PGP, not checked
Commit: 9558d4cbb38a084957925f8e2423455083edd678
Parent: 293df44
9 files changed, +463 insertions, -4 deletions
@@ -75,6 +75,10 @@
75 75 Subscriber::Email(addr) => (None, Some(addr.to_lowercase())),
76 76 };
77 77 let confirmed_at = (state == SubscriptionState::Confirmed).then(chrono::Utc::now);
78 + // Kept in step with `state` here rather than at each call site: a row that
79 + // says unsubscribed with no unsubscribed_at cannot answer "when", which is
80 + // the first question asked of an opt-out.
81 + let unsubscribed_at = (state == SubscriptionState::Unsubscribed).then(chrono::Utc::now);
78 82
79 83 let mut tx = pool.begin().await?;
80 84
@@ -87,12 +91,13 @@
87 91 Subscriber::Email(_) => "(list_id, email) WHERE email IS NOT NULL",
88 92 };
89 93 let sql = format!(
90 - "INSERT INTO list_subscriptions (list_id, user_id, email, state, source, confirmed_at) \
91 - VALUES ($1, $2, $3, $4, $5, $6) \
94 + "INSERT INTO list_subscriptions \
95 + (list_id, user_id, email, state, source, confirmed_at, unsubscribed_at) \
96 + VALUES ($1, $2, $3, $4, $5, $6, $7) \
92 97 ON CONFLICT {conflict_target} \
93 98 DO UPDATE SET state = EXCLUDED.state, \
94 99 confirmed_at = EXCLUDED.confirmed_at, \
95 - unsubscribed_at = NULL \
100 + unsubscribed_at = EXCLUDED.unsubscribed_at \
96 101 RETURNING id"
97 102 );
98 103
@@ -103,6 +108,7 @@
103 108 .bind(state.to_string())
104 109 .bind(source.to_string())
105 110 .bind(confirmed_at)
111 + .bind(unsubscribed_at)
106 112 .fetch_one(&mut *tx)
107 113 .await?;
108 114
@@ -397,6 +403,80 @@
397 403 Ok(required.unwrap_or(false))
398 404 }
399 405
406 + // ── Per-repo notification lists ──
407 +
408 + /// Whether this user has opted out of a repo's notifications.
409 + ///
410 + /// The gate is "not opted out" rather than "opted in", which is what keeps the
411 + /// behaviour identical to the account-wide bool it replaces: eligibility is
412 + /// still repo ownership or issue participation, and an absent row still means
413 + /// nothing has been said. Opting in to a repo you have nothing to do with would
414 + /// not get you mail, because it would not make you a participant.
415 + #[tracing::instrument(skip_all)]
416 + pub async fn repo_notifications_muted(
417 + pool: &PgPool,
418 + repo_id: uuid::Uuid,
419 + user_id: UserId,
420 + kind: ListKind,
421 + ) -> Result<bool> {
422 + let muted = sqlx::query_scalar::<_, bool>(
423 + "SELECT EXISTS( \
424 + SELECT 1 FROM list_subscriptions ls \
425 + JOIN lists l ON l.id = ls.list_id \
426 + WHERE l.scope = 'repo' AND l.scope_id = $1 AND l.kind = $2 \
427 + AND ls.user_id = $3 AND ls.state = 'unsubscribed')",
428 + )
429 + .bind(repo_id)
430 + .bind(kind.to_string())
431 + .bind(user_id)
432 + .fetch_one(pool)
433 + .await?;
434 + Ok(muted)
435 + }
436 +
437 + /// Mute or unmute a repo's notifications for one user.
438 + ///
439 + /// Muting records an explicit `unsubscribed` row; unmuting moves it back.
440 + /// Either way a consent event is appended, so "when did I turn this off" has an
441 + /// answer.
442 + #[tracing::instrument(skip_all)]
443 + pub async fn set_repo_muted(
444 + pool: &PgPool,
445 + repo_id: uuid::Uuid,
446 + user_id: UserId,
447 + kind: ListKind,
448 + muted: bool,
449 + ) -> Result<()> {
450 + let Some(list_id) = find_list(pool, ListScope::Repo, Some(repo_id), kind).await? else {
451 + return Ok(());
452 + };
453 +
454 + if muted {
455 + subscribe(
456 + pool,
457 + list_id,
458 + &Subscriber::User(user_id),
459 + SubscriptionState::Unsubscribed,
460 + SubscriptionSource::ProjectPage,
461 + ConsentEvent::OptOut,
462 + Some("Muted from the repository page."),
463 + )
464 + .await?;
465 + } else {
466 + subscribe(
467 + pool,
468 + list_id,
469 + &Subscriber::User(user_id),
470 + SubscriptionState::Confirmed,
471 + SubscriptionSource::ProjectPage,
472 + ConsentEvent::OptIn,
473 + Some("Unmuted from the repository page."),
474 + )
475 + .await?;
476 + }
477 + Ok(())
478 + }
479 +
400 480 // ── Account notification preferences ──
401 481 //
402 482 // Seven bool columns on `users` and seven platform lists describe the same
@@ -891,3 +891,160 @@
891 891 "unsubscribe-from-all left an ordinary preference on"
892 892 );
893 893 }
894 +
895 + // ── Step 6: per-repo issue notifications ──
896 +
897 + /// Create a repo through the API and return (owner_id, owner_name, repo_id).
898 + async fn repo_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, String, uuid::Uuid) {
899 + let owner = h
900 + .signup("repoowner", "repoowner@test.com", "password123")
901 + .await;
902 + h.grant_creator(owner).await;
903 + h.client.post_form("/logout", "").await;
904 + h.login("repoowner", "password123").await;
905 +
906 + let repo_id: uuid::Uuid = sqlx::query_scalar(
907 + "INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, 'noisy', 'public') RETURNING id",
908 + )
909 + .bind(owner)
910 + .fetch_one(&h.db)
911 + .await
912 + .expect("create repo");
913 + (owner, "repoowner".to_string(), repo_id)
914 + }
915 +
916 + /// Creating a repository mirrors its issues list, whichever path made it. The
917 + /// trigger is what covers the SSH push and seed paths that never touch Rust.
918 + #[tokio::test]
919 + async fn creating_a_repo_seeds_its_issues_list() {
920 + let mut h = TestHarness::new().await;
921 + let (_owner, _name, repo_id) = repo_with_list(&mut h).await;
922 +
923 + let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues)
924 + .await
925 + .expect("query");
926 + assert!(list.is_some(), "repo has no issues list");
927 + }
928 +
929 + /// Muting is per repository. The default is unmuted, which keeps the behaviour
930 + /// the account-wide bool had: eligibility decides who is mailed, and the mute
931 + /// only takes people out.
932 + #[tokio::test]
933 + async fn muting_one_repo_leaves_another_alone() {
934 + let mut h = TestHarness::new().await;
935 + let (owner, _name, noisy) = repo_with_list(&mut h).await;
936 +
937 + let quiet: uuid::Uuid = sqlx::query_scalar(
938 + "INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, 'quiet', 'public') RETURNING id",
939 + )
940 + .bind(owner)
941 + .fetch_one(&h.db)
942 + .await
943 + .unwrap();
944 +
945 + assert!(
946 + !lists::repo_notifications_muted(&h.db, noisy, owner, ListKind::Issues)
947 + .await
948 + .unwrap(),
949 + "default should be unmuted"
950 + );
951 +
952 + lists::set_repo_muted(&h.db, noisy, owner, ListKind::Issues, true)
953 + .await
954 + .unwrap();
955 +
956 + assert!(
957 + lists::repo_notifications_muted(&h.db, noisy, owner, ListKind::Issues)
958 + .await
959 + .unwrap()
960 + );
961 + assert!(
962 + !lists::repo_notifications_muted(&h.db, quiet, owner, ListKind::Issues)
963 + .await
964 + .unwrap(),
965 + "muting one repository silenced another"
966 + );
967 + }
968 +
969 + /// Unmuting moves the row back, and the consent log keeps both events.
970 + #[tokio::test]
971 + async fn unmuting_restores_and_records_both_events() {
972 + let mut h = TestHarness::new().await;
973 + let (owner, _name, repo_id) = repo_with_list(&mut h).await;
974 +
975 + lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, true)
976 + .await
977 + .unwrap();
978 + lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, false)
979 + .await
980 + .unwrap();
981 +
982 + assert!(
983 + !lists::repo_notifications_muted(&h.db, repo_id, owner, ListKind::Issues)
984 + .await
985 + .unwrap()
986 + );
987 +
988 + let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues)
989 + .await
990 + .unwrap()
991 + .unwrap();
992 + let events: Vec<String> = sqlx::query_scalar(
993 + "SELECT ce.event FROM consent_events ce \
994 + JOIN list_subscriptions ls ON ls.id = ce.subscription_id \
995 + WHERE ls.list_id = $1 AND ls.user_id = $2 ORDER BY ce.at",
996 + )
997 + .bind(list)
998 + .bind(owner)
999 + .fetch_all(&h.db)
1000 + .await
1001 + .unwrap();
1002 + assert_eq!(events, vec!["opt_out".to_string(), "opt_in".to_string()]);
1003 + }
1004 +
1005 + /// An unsubscribed row records when it happened. A row that says unsubscribed
1006 + /// with no timestamp cannot answer the first question asked of an opt-out.
1007 + #[tokio::test]
1008 + async fn muting_records_when_it_happened() {
1009 + let mut h = TestHarness::new().await;
1010 + let (owner, _name, repo_id) = repo_with_list(&mut h).await;
1011 + lists::set_repo_muted(&h.db, repo_id, owner, ListKind::Issues, true)
1012 + .await
1013 + .unwrap();
1014 +
1015 + let list = lists::find_list(&h.db, ListScope::Repo, Some(repo_id), ListKind::Issues)
1016 + .await
1017 + .unwrap()
1018 + .unwrap();
1019 + let at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
1020 + "SELECT unsubscribed_at FROM list_subscriptions WHERE list_id = $1 AND user_id = $2",
1021 + )
1022 + .bind(list)
1023 + .bind(owner)
1024 + .fetch_one(&h.db)
1025 + .await
1026 + .unwrap();
1027 + assert!(at.is_some(), "unsubscribed row has no unsubscribed_at");
1028 + }
1029 +
1030 + /// Renaming a repository renames its list, which is the one place a subscriber
1031 + /// reads that name.
1032 + #[tokio::test]
1033 + async fn renaming_a_repo_renames_its_list() {
1034 + let mut h = TestHarness::new().await;
1035 + let (_owner, _name, repo_id) = repo_with_list(&mut h).await;
1036 +
1037 + sqlx::query("UPDATE git_repos SET name = 'renamed' WHERE id = $1")
1038 + .bind(repo_id)
1039 + .execute(&h.db)
1040 + .await
1041 + .unwrap();
1042 +
1043 + let title: String =
1044 + sqlx::query_scalar("SELECT title FROM lists WHERE scope = 'repo' AND scope_id = $1")
1045 + .bind(repo_id)
1046 + .fetch_one(&h.db)
1047 + .await
1048 + .unwrap();
1049 + assert_eq!(title, "renamed: issues");
1050 + }
@@ -36,6 +36,7 @@
36 36 mod project_sections;
37 37 mod projects;
38 38 mod promo_codes;
39 + mod repo_notifications;
39 40 mod reports;
40 41 pub(crate) mod ssh_keys;
41 42 mod subscriptions;
@@ -528,6 +529,11 @@
528 529 .route("/api/domains/{id}", delete_csrf(domains::remove_domain))
529 530 // Invite codes
530 531 .route("/api/invites/create", post_csrf(users::create_invite))
532 + // Per-repository issue notifications (mailing-list step 6)
533 + .route(
534 + "/api/repos/notifications",
535 + post_csrf(repo_notifications::set_repo_notifications),
536 + )
531 537 .route_layer(GovernorLayer::new(write_rate_limit));
532 538
533 539 // Password/code-verifying TOTP mutations, strict auth-strength rate limit
@@ -73,6 +73,18 @@
73 73 Some(resolved.db_repo.description.clone())
74 74 };
75 75
76 + let issues_muted = match maybe_user.as_ref() {
77 + Some(u) => db::lists::repo_notifications_muted(
78 + &db,
79 + *resolved.db_repo.id.as_uuid(),
80 + u.id,
81 + db::ListKind::Issues,
82 + )
83 + .await
84 + .ok(),
85 + None => None,
86 + };
87 +
76 88 Ok(GitRepoTemplate {
77 89 csrf_token,
78 90 session_user: maybe_user,
@@ -89,6 +101,8 @@
89 101 release_items,
90 102 open_issue_count,
91 103 is_owner,
104 + issues_muted,
105 + repo_id: resolved.db_repo.id.to_string(),
92 106 active_tab: "files",
93 107 })
94 108 }
@@ -135,6 +149,20 @@
135 149 Some(resolved.db_repo.description.clone())
136 150 };
137 151
152 + // Same control on the ref-specific view, so it does not appear and vanish
153 + // as somebody navigates branches.
154 + let issues_muted = match maybe_user.as_ref() {
155 + Some(u) => db::lists::repo_notifications_muted(
156 + &db,
157 + *resolved.db_repo.id.as_uuid(),
158 + u.id,
159 + db::ListKind::Issues,
160 + )
161 + .await
162 + .ok(),
163 + None => None,
164 + };
165 +
138 166 Ok(GitRepoTemplate {
139 167 csrf_token,
140 168 session_user: maybe_user,
@@ -151,6 +179,8 @@
151 179 release_items,
152 180 open_issue_count,
153 181 is_owner,
182 + issues_muted,
183 + repo_id: resolved.db_repo.id.to_string(),
154 184 active_tab: "files",
155 185 })
156 186 }
@@ -236,7 +236,19 @@
236 236 );
237 237
238 238 // Notify repo owner (if different from sender)
239 - if sender.id != owner_user.id && owner_user.notify_issues {
239 + // Per-repo mute, on top of the account-wide bool. Eligibility is unchanged
240 + // (the repo owner, for a new issue); what changed is that opting out of one
241 + // noisy repo no longer means opting out of every repo. The account bool
242 + // retires when its remaining reads move (GoingsOn e5b6475a).
243 + let owner_muted = db::lists::repo_notifications_muted(
244 + &db,
245 + *repo.id.as_uuid(),
246 + owner_user.id,
247 + db::ListKind::Issues,
248 + )
249 + .await
250 + .unwrap_or(false);
251 + if sender.id != owner_user.id && owner_user.notify_issues && !owner_muted {
240 252 let email_client = email.clone();
241 253 let host_url = config.host_url.clone();
242 254 let signing_secret = config.signing_secret.clone();
@@ -471,6 +483,18 @@
471 483 if !user.notify_issues {
472 484 continue;
473 485 }
486 + // Same per-repo mute for issue participants.
487 + if db::lists::repo_notifications_muted(
488 + &db,
489 + *repo_id.as_uuid(),
490 + user.id,
491 + db::ListKind::Issues,
492 + )
493 + .await
494 + .unwrap_or(false)
495 + {
496 + continue;
497 + }
474 498 let participant_id = user.id;
475 499 let unsub_url = crate::email::generate_unsubscribe_url(
476 500 &host_url, participant_id, crate::email::UnsubscribeAction::Issue, &participant_id.to_string(), &signing_secret,