Commit aab3eb12e3

aab3eb12e38dc40d0910014cb152add5c862e290

parent: c2923bffd4

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-07 22:53 UTC

store, control, web, wiki: notifications settings mail on|off

There was no way to say inbox but no mail: an account reading the inbox
from the CLI got every row twice, and the only way to stop the mail
from a repository was repo mute, which stops the inbox too.

notifications settings mail on|off (and show) is a per-account switch
the mail half consults; inbox rows are filed either way, and login
links are not activity so they still arrive. The dependency worker's
mail honours it too. The account page carries the same checkbox.
Parity row, Users page.

The default watch state for repositories an account gains access to,
the other half of the issue, is not in this change.

Ref #194
.gitbay/wiki/Parity.org +1
@@ -277,6 +277,7 @@ client has no use for one (krz/gitbay#57).
277277| email list, remove, primary | yes | yes | no |
278278| dashboard aggregate | yes | yes | yes |
279279| notification inbox | yes | yes | yes |
280| activity mail on, off | yes | yes | no |
280281| API token mint | yes | no | no |
281282| account export bundle | yes | yes | n/a |
282283| profile set | yes | yes | yes |
.gitbay/wiki/Users.org +6 −4
@@ -515,10 +515,12 @@ handled.
515515
516516* Notifications
517517
518When the instance has SMTP configured, activity mails you: someone
519opens an issue or MR on your repository, comments where you are a
520participant (author, commenter, reviewer, or mentioned), reviews,
521closes, or merges. Writing =@name= in an issue, merge request or
518When the instance has SMTP configured, activity mails you as well as
519filing the inbox row: someone opens an issue or MR on your repository,
520comments where you are a participant (author, commenter, reviewer, or
521mentioned), reviews, closes, or merges. =notifications settings mail
522off= keeps the inbox and stops that mail (login links are not activity
523and still arrive); the account page has the same switch. Writing =@name= in an issue, merge request or
522524comment files "mentioned you" in that account's inbox and makes them a
523525participant of the thread, provided they can read the repository and
524526have not muted it; watchers are not told about a mention addressed to
cmd/gitbay/main.go +4
@@ -66,6 +66,10 @@ func newRoot() *cobra.Command {
6666 passOpts{server: []string{"notifications", "list"}}),
6767 pass("read", "mark notifications read: <id>... | --all",
6868 passOpts{server: []string{"notifications", "read"}}),
69 group("settings", "notification preferences",
70 pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}),
71 pass("mail", "activity by mail as well as the inbox: on|off", passOpts{server: []string{"notifications", "settings", "mail"}}),
72 ),
6973 ),
7074 group("wiki", "a repository's wiki pages",
7175 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
e2e/mailpref_test.go added +69
@@ -0,0 +1,69 @@
1package e2e
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "testing"
8 "time"
9)
10
11// notifications settings mail off keeps the inbox and stops activity
12// mail; on brings it back. The account page carries the same switch
13// (#194).
14func TestMailPreference(t *testing.T) {
15 smtp := startFakeSMTP(t)
16 inst := startInstanceWith(t, fmt.Sprintf(
17 "[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n[web]\nmode = \"accounts\"\n", smtp.addr))
18 aliceKey := inst.newKey(t, "alice")
19 bobKey := inst.newKey(t, "bob")
20 inst.admin(t, "admin", "user", "create", "alice",
21 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
22 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26
27 out, _, code := inst.ssh(t, aliceKey, "", "notifications", "settings", "show", "--json")
28 if code != 0 || !strings.Contains(out, `"mail":true`) {
29 t.Fatalf("settings show: %d %s", code, out)
30 }
31 if _, _, code := inst.ssh(t, aliceKey, "", "notifications", "settings", "mail", "sometimes"); code != 2 {
32 t.Fatalf("bad value accepted: %d", code)
33 }
34 if out, _, code := inst.ssh(t, aliceKey, "", "notifications", "settings", "mail", "off", "--json"); code != 0 || !strings.Contains(out, `"mail":false`) {
35 t.Fatalf("mail off: %d %s", code, out)
36 }
37
38 // Mail off: the inbox row is filed, no mail goes out.
39 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'leak'"); code != 0 {
40 t.Fatalf("issue create: %s", errOut)
41 }
42 rows := notices(t, inst, aliceKey)
43 if len(rows) != 1 || !strings.Contains(rows[0].Summary, "opened issue #1") {
44 t.Fatalf("inbox with mail off: %+v", rows)
45 }
46 time.Sleep(5 * time.Second) // the mailer ticks every two seconds
47 if got := smtp.mailTo("alice@example.test"); len(got) != 0 {
48 t.Fatalf("mail sent with the preference off:\n%s", got[0])
49 }
50
51 // The account page shows it off and turns it back on.
52 alice := inst.login(t, aliceKey)
53 set := inst.base() + "/settings"
54 if _, body := browserGet(t, alice, set); !strings.Contains(body, `name="mail" value="on">`) || strings.Contains(body, `name="mail" value="on" checked`) {
55 t.Fatalf("account page does not show mail off:\n%s", body)
56 }
57 if status, _ := browserPost(t, alice, set, url.Values{"field": {"notify-mail"}, "mail": {"on"}}); status != 200 {
58 t.Fatalf("settings post: %d", status)
59 }
60 if out, _, _ := inst.ssh(t, aliceKey, "", "notifications", "settings", "show", "--json"); !strings.Contains(out, `"mail":true`) {
61 t.Fatalf("web toggle did not turn mail on: %s", out)
62 }
63 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'still leaking'"); code != 0 {
64 t.Fatalf("second issue: %s", errOut)
65 }
66 if m := smtp.waitFor(t, "alice@example.test", "still leaking"); !strings.Contains(m, "bob opened issue #2") {
67 t.Fatalf("mail after turning it on:\n%s", m)
68 }
69}
e2e/readonly_test.go +70 −69
@@ -83,75 +83,76 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
8383 must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created")
8484
8585 readArgs := map[string][]string{
86 "help": {},
87 "whoami": {},
88 "dashboard": {},
89 "feed": {},
90 "explore": {},
91 "audit": {},
92 "keys list": {},
93 "email list": {},
94 "pgp list": {},
95 "token list": {},
96 "web sessions list": {},
97 "account export": {},
98 "org list": {},
99 "repo list": {},
100 "admin user list": {},
101 "admin runners": {},
102 "admin repo list": {},
103 "admin stats": {},
104 "admin user show": {"alice"},
105 "profile show": {"alice"},
106 "org show": {"theorg"},
107 "org members list": {"theorg"},
108 "org team list": {"theorg"},
109 "org team show": {"theorg", "core"},
110 "repo search": {"app"},
111 "repo show": {"alice/app"},
112 "repo access list": {"alice/app"},
113 "repo settings show": {"alice/app"},
114 "repo topics": {"alice/app"},
115 "repo refs": {"alice/app"},
116 "repo log": {"alice/app"},
117 "repo tree": {"alice/app"},
118 "repo cat": {"alice/app", "f.go"},
119 "repo blame": {"alice/app", "f.go"},
120 "repo grep": {"alice/app", "hello"},
121 "repo diff": {"alice/app", "main", "feat"},
122 "repo commit": {"alice/app", sha},
123 "repo download": {"alice/app"},
124 "repo deploy-key list": {"alice/app"},
125 "repo secret list": {"alice/app"},
126 "repo mirror list": {"alice/app"},
127 "repo domain list": {"alice/app"},
128 "repo deps status": {"alice/app"},
129 "status list": {"alice/app", sha},
130 "issue list": {"alice/app"},
131 "issue show": {"alice/app", "1"},
132 "issue templates": {"alice/app"},
133 "label list": {"alice/app"},
134 "milestone list": {"alice/app"},
135 "mr list": {"alice/app"},
136 "mr show": {"alice/app", "1"},
137 "mr diff": {"alice/app", "1"},
138 "mr threads": {"alice/app", "1"},
139 "build list": {"alice/app"},
140 "build jobs": {"alice/app"},
141 "build show": {"alice/app", "1"},
142 "build log": {"alice/app", "1"},
143 "release list": {"alice/app"},
144 "release show": {"alice/app", "v1"},
145 "release asset get": {"alice/app", "v1", "a.txt"},
146 "notifications list": nil,
147 "repo bookmarks": nil,
148 "search": {"app"},
149 "mr revisions": {"alice/app", "1"},
150 "mr range-diff": {"alice/app", "1"},
151 "webhook list": {"alice/app"},
152 "webhook deliveries": {"alice/app"},
153 "wiki list": {"alice/app"},
154 "wiki show": {"alice/app"},
86 "help": {},
87 "whoami": {},
88 "dashboard": {},
89 "feed": {},
90 "explore": {},
91 "audit": {},
92 "keys list": {},
93 "email list": {},
94 "pgp list": {},
95 "token list": {},
96 "web sessions list": {},
97 "account export": {},
98 "org list": {},
99 "repo list": {},
100 "admin user list": {},
101 "admin runners": {},
102 "admin repo list": {},
103 "admin stats": {},
104 "admin user show": {"alice"},
105 "profile show": {"alice"},
106 "org show": {"theorg"},
107 "org members list": {"theorg"},
108 "org team list": {"theorg"},
109 "org team show": {"theorg", "core"},
110 "repo search": {"app"},
111 "repo show": {"alice/app"},
112 "repo access list": {"alice/app"},
113 "repo settings show": {"alice/app"},
114 "repo topics": {"alice/app"},
115 "repo refs": {"alice/app"},
116 "repo log": {"alice/app"},
117 "repo tree": {"alice/app"},
118 "repo cat": {"alice/app", "f.go"},
119 "repo blame": {"alice/app", "f.go"},
120 "repo grep": {"alice/app", "hello"},
121 "repo diff": {"alice/app", "main", "feat"},
122 "repo commit": {"alice/app", sha},
123 "repo download": {"alice/app"},
124 "repo deploy-key list": {"alice/app"},
125 "repo secret list": {"alice/app"},
126 "repo mirror list": {"alice/app"},
127 "repo domain list": {"alice/app"},
128 "repo deps status": {"alice/app"},
129 "status list": {"alice/app", sha},
130 "issue list": {"alice/app"},
131 "issue show": {"alice/app", "1"},
132 "issue templates": {"alice/app"},
133 "label list": {"alice/app"},
134 "milestone list": {"alice/app"},
135 "mr list": {"alice/app"},
136 "mr show": {"alice/app", "1"},
137 "mr diff": {"alice/app", "1"},
138 "mr threads": {"alice/app", "1"},
139 "build list": {"alice/app"},
140 "build jobs": {"alice/app"},
141 "build show": {"alice/app", "1"},
142 "build log": {"alice/app", "1"},
143 "release list": {"alice/app"},
144 "release show": {"alice/app", "v1"},
145 "release asset get": {"alice/app", "v1", "a.txt"},
146 "notifications list": nil,
147 "notifications settings show": nil,
148 "repo bookmarks": nil,
149 "search": {"app"},
150 "mr revisions": {"alice/app", "1"},
151 "mr range-diff": {"alice/app", "1"},
152 "webhook list": {"alice/app"},
153 "webhook deliveries": {"alice/app"},
154 "wiki list": {"alice/app"},
155 "wiki show": {"alice/app"},
155156 }
156157 // Reads whose subject legitimately does not exist in this fixture.
157158 notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
internal/control/notifications.go +41 −1
@@ -20,6 +20,13 @@ func init() {
2020 register(Command{Path: []string{"notifications", "read"},
2121 Summary: "mark notifications read",
2222 Usage: "notifications read <id>... | --all", Run: runNotificationsRead})
23 register(Command{Path: []string{"notifications", "settings", "show"},
24 Summary: "your notification preferences",
25 Usage: "notifications settings show",
26 ReadOnly: true, Run: runNotificationsSettingsShow})
27 register(Command{Path: []string{"notifications", "settings", "mail"},
28 Summary: "activity by mail as well as the inbox (login links are unaffected)",
29 Usage: "notifications settings mail on|off", Run: runNotificationsSettingsMail})
2330 register(Command{Path: []string{"repo", "watch"},
2431 Summary: "hear about all activity on a repository",
2532 Usage: "repo watch <owner/name>", Run: runRepoWatch})
@@ -66,7 +73,7 @@ func notify(c *Ctx, userIDs []int64, n notice) {
6673 if !sendMail {
6774 continue
6875 }
69 email, err := c.Store.PrimaryVerifiedEmail(id)
76 email, err := c.Store.ActivityMailAddress(id)
7077 if err != nil || email == "" {
7178 continue
7279 }
@@ -136,6 +143,39 @@ func mrSubject(repo store.Repo, number int64, title string) string {
136143 return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
137144}
138145
146func emitNotificationSettings(c *Ctx) int {
147 on, err := c.Store.MailEnabled(c.User.ID)
148 if err != nil {
149 return c.fail(protocol.ExitFailure, "%v", err)
150 }
151 return c.emit(map[string]bool{"mail": on}, func(w io.Writer) {
152 state := "off"
153 if on {
154 state = "on"
155 }
156 fmt.Fprintf(w, "mail: %s\n", state)
157 })
158}
159
160func runNotificationsSettingsShow(c *Ctx, args []string) int {
161 if len(args) != 0 {
162 return c.fail(protocol.ExitUsage, "usage: notifications settings show")
163 }
164 return emitNotificationSettings(c)
165}
166
167// runNotificationsSettingsMail is the "inbox but no mail" switch: the
168// inbox is filed either way, the mail half consults it (#194).
169func runNotificationsSettingsMail(c *Ctx, args []string) int {
170 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
171 return c.fail(protocol.ExitUsage, "usage: notifications settings mail on|off")
172 }
173 if err := c.Store.SetMailEnabled(c.User.ID, args[0] == "on"); err != nil {
174 return c.fail(protocol.ExitFailure, "%v", err)
175 }
176 return emitNotificationSettings(c)
177}
178
139179// noticesDefaultLimit caps a bare list; pagination reaches further back.
140180const noticesDefaultLimit = 50
141181
internal/deps/worker.go +1 −1
@@ -251,7 +251,7 @@ func (w *Worker) notify(repo store.Repo, number int64, action, body string) {
251251 if w.Cfg.Mail.SMTPHost == "" {
252252 continue
253253 }
254 email, err := w.St.PrimaryVerifiedEmail(id)
254 email, err := w.St.ActivityMailAddress(id)
255255 if err != nil || email == "" {
256256 continue
257257 }
internal/httpd/account.go +13 −1
@@ -52,6 +52,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
5252
5353 var profile control.ProfileOut
5454 s.runControlInto(u, []string{"profile", "show"}, &profile)
55 mailOn, _ := s.st.MailEnabled(u.ID)
5556
5657 s.render(w, "account.html", struct {
5758 basePage
@@ -64,8 +65,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
6465 Host string
6566 Notice string
6667 Message string
68 MailOn bool
6769 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
68 s.takeFlash(w, r), r.URL.Query().Get("m")})
70 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn})
6971}
7072
7173// accountExport hands the browser the same bundle `account export`
@@ -191,6 +193,16 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
191193 return
192194 }
193195 back("", "primary address changed")
196 case "notify-mail":
197 state := "off"
198 if r.FormValue("mail") == "on" {
199 state = "on"
200 }
201 if _, msg, ok := s.runControl(u, []string{"notifications", "settings", "mail", state}); !ok {
202 back(msg, "")
203 return
204 }
205 back("", "notification preferences saved")
194206 case "profile":
195207 format := r.FormValue("format")
196208 if format != "org" {
internal/store/migrations/0048_notify_mail.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users DROP COLUMN notify_mail;
internal/store/migrations/0048_notify_mail.up.sql added +4
@@ -0,0 +1,4 @@
1-- Whether activity notifications reach the account by mail as well as
2-- the inbox. Off leaves inbox rows untouched and skips the mail half
3-- only; login links and verification mail are not activity (#194).
4ALTER TABLE users ADD COLUMN notify_mail INTEGER NOT NULL DEFAULT 1;
internal/store/mrs.go +11
@@ -494,6 +494,17 @@ func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
494494 return addr, err
495495}
496496
497// ActivityMailAddress returns where activity mail for an account goes:
498// its verified primary address, or "" when there is none or the account
499// turned activity mail off (#194).
500func (s *Store) ActivityMailAddress(userID int64) (string, error) {
501 on, err := s.MailEnabled(userID)
502 if err != nil || !on {
503 return "", err
504 }
505 return s.PrimaryVerifiedEmail(userID)
506}
507
497508// PreferredVerifiedEmail returns the primary address if it is verified,
498509// otherwise the account's other verified address that sorts first by
499510// address; "" if none is verified. Unlike PrimaryVerifiedEmail, a verified
internal/store/users.go +20
@@ -195,6 +195,26 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
195195 return err
196196}
197197
198// MailEnabled reports whether activity notifications reach the account
199// by mail as well as the inbox.
200func (s *Store) MailEnabled(userID int64) (bool, error) {
201 var on int
202 err := s.DB.QueryRow("SELECT notify_mail FROM users WHERE id = ?", userID).Scan(&on)
203 if errors.Is(err, sql.ErrNoRows) {
204 return false, ErrNotFound
205 }
206 return on != 0, err
207}
208
209func (s *Store) SetMailEnabled(userID int64, on bool) error {
210 v := 0
211 if on {
212 v = 1
213 }
214 _, err := s.DB.Exec("UPDATE users SET notify_mail = ? WHERE id = ?", v, userID)
215 return err
216}
217
198218func (s *Store) UserByID(id int64) (User, error) {
199219 var u User
200220 var admin, pending, disabled int
internal/web/templates/account.html +9
@@ -98,6 +98,15 @@ account, and where notifications go.</p>
9898 </form>
9999</details>
100100
101<h2>Notifications</h2>
102<form method="post" action="/settings" class="setform">
103 <input type="hidden" name="field" value="notify-mail">
104 <label for="notify-mail">Activity by mail</label>
105 <input type="checkbox" id="notify-mail" name="mail" value="on"{{if .MailOn}} checked{{end}}>
106 <button type="submit">Save</button>
107</form>
108<p class="meta">The inbox is filed either way; this is the mail half. Login links are not activity and still arrive.</p>
109
101110<h2>Export</h2>
102111<p class="meta">Your profile, repositories, issues and merge requests as one
103112JSON bundle, the same one <code>gitbay account export</code> writes. Keys are