store, control, web, wiki: notifications settings mail on|off !345
13 files changed, +250 −76
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -277,6 +277,7 @@ client has no use for one (krz/gitbay#57). | |||
| 277 | | email list, remove, primary | yes | yes | no | | 277 | | email list, remove, primary | yes | yes | no | |
| 278 | | dashboard aggregate | yes | yes | yes | | 278 | | dashboard aggregate | yes | yes | yes | |
| 279 | | notification inbox | yes | yes | yes | | 279 | | notification inbox | yes | yes | yes | |
| 280 | | activity mail on, off | yes | yes | no | | ||
| 280 | | API token mint | yes | no | no | | 281 | | API token mint | yes | no | no | |
| 281 | | account export bundle | yes | yes | n/a | | 282 | | account export bundle | yes | yes | n/a | |
| 282 | | profile set | yes | yes | yes | | 283 | | profile set | yes | yes | yes | |
.gitbay/wiki/Users.org +6 −4
| @@ -515,10 +515,12 @@ handled. | |||
| 515 | 515 | ||
| 516 | * Notifications | 516 | * Notifications |
| 517 | 517 | ||
| 518 | When the instance has SMTP configured, activity mails you: someone | 518 | When the instance has SMTP configured, activity mails you as well as |
| 519 | opens an issue or MR on your repository, comments where you are a | 519 | filing the inbox row: someone opens an issue or MR on your repository, |
| 520 | participant (author, commenter, reviewer, or mentioned), reviews, | 520 | comments where you are a participant (author, commenter, reviewer, or |
| 521 | closes, or merges. Writing =@name= in an issue, merge request or | 521 | mentioned), reviews, closes, or merges. =notifications settings mail |
| 522 | off= keeps the inbox and stops that mail (login links are not activity | ||
| 523 | and still arrive); the account page has the same switch. Writing =@name= in an issue, merge request or | ||
| 522 | comment files "mentioned you" in that account's inbox and makes them a | 524 | comment files "mentioned you" in that account's inbox and makes them a |
| 523 | participant of the thread, provided they can read the repository and | 525 | participant of the thread, provided they can read the repository and |
| 524 | have not muted it; watchers are not told about a mention addressed to | 526 | have 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 { | |||
| 66 | passOpts{server: []string{"notifications", "list"}}), | 66 | passOpts{server: []string{"notifications", "list"}}), |
| 67 | pass("read", "mark notifications read: <id>... | --all", | 67 | pass("read", "mark notifications read: <id>... | --all", |
| 68 | passOpts{server: []string{"notifications", "read"}}), | 68 | 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 | ), | ||
| 69 | ), | 73 | ), |
| 70 | group("wiki", "a repository's wiki pages", | 74 | group("wiki", "a repository's wiki pages", |
| 71 | pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}), | 75 | pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}), |
e2e/mailpref_test.go added +69
| @@ -0,0 +1,69 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 14 | func 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) { | |||
| 83 | must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created") | 83 | must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created") |
| 84 | 84 | ||
| 85 | readArgs := map[string][]string{ | 85 | readArgs := map[string][]string{ |
| 86 | "help": {}, | 86 | "help": {}, |
| 87 | "whoami": {}, | 87 | "whoami": {}, |
| 88 | "dashboard": {}, | 88 | "dashboard": {}, |
| 89 | "feed": {}, | 89 | "feed": {}, |
| 90 | "explore": {}, | 90 | "explore": {}, |
| 91 | "audit": {}, | 91 | "audit": {}, |
| 92 | "keys list": {}, | 92 | "keys list": {}, |
| 93 | "email list": {}, | 93 | "email list": {}, |
| 94 | "pgp list": {}, | 94 | "pgp list": {}, |
| 95 | "token list": {}, | 95 | "token list": {}, |
| 96 | "web sessions list": {}, | 96 | "web sessions list": {}, |
| 97 | "account export": {}, | 97 | "account export": {}, |
| 98 | "org list": {}, | 98 | "org list": {}, |
| 99 | "repo list": {}, | 99 | "repo list": {}, |
| 100 | "admin user list": {}, | 100 | "admin user list": {}, |
| 101 | "admin runners": {}, | 101 | "admin runners": {}, |
| 102 | "admin repo list": {}, | 102 | "admin repo list": {}, |
| 103 | "admin stats": {}, | 103 | "admin stats": {}, |
| 104 | "admin user show": {"alice"}, | 104 | "admin user show": {"alice"}, |
| 105 | "profile show": {"alice"}, | 105 | "profile show": {"alice"}, |
| 106 | "org show": {"theorg"}, | 106 | "org show": {"theorg"}, |
| 107 | "org members list": {"theorg"}, | 107 | "org members list": {"theorg"}, |
| 108 | "org team list": {"theorg"}, | 108 | "org team list": {"theorg"}, |
| 109 | "org team show": {"theorg", "core"}, | 109 | "org team show": {"theorg", "core"}, |
| 110 | "repo search": {"app"}, | 110 | "repo search": {"app"}, |
| 111 | "repo show": {"alice/app"}, | 111 | "repo show": {"alice/app"}, |
| 112 | "repo access list": {"alice/app"}, | 112 | "repo access list": {"alice/app"}, |
| 113 | "repo settings show": {"alice/app"}, | 113 | "repo settings show": {"alice/app"}, |
| 114 | "repo topics": {"alice/app"}, | 114 | "repo topics": {"alice/app"}, |
| 115 | "repo refs": {"alice/app"}, | 115 | "repo refs": {"alice/app"}, |
| 116 | "repo log": {"alice/app"}, | 116 | "repo log": {"alice/app"}, |
| 117 | "repo tree": {"alice/app"}, | 117 | "repo tree": {"alice/app"}, |
| 118 | "repo cat": {"alice/app", "f.go"}, | 118 | "repo cat": {"alice/app", "f.go"}, |
| 119 | "repo blame": {"alice/app", "f.go"}, | 119 | "repo blame": {"alice/app", "f.go"}, |
| 120 | "repo grep": {"alice/app", "hello"}, | 120 | "repo grep": {"alice/app", "hello"}, |
| 121 | "repo diff": {"alice/app", "main", "feat"}, | 121 | "repo diff": {"alice/app", "main", "feat"}, |
| 122 | "repo commit": {"alice/app", sha}, | 122 | "repo commit": {"alice/app", sha}, |
| 123 | "repo download": {"alice/app"}, | 123 | "repo download": {"alice/app"}, |
| 124 | "repo deploy-key list": {"alice/app"}, | 124 | "repo deploy-key list": {"alice/app"}, |
| 125 | "repo secret list": {"alice/app"}, | 125 | "repo secret list": {"alice/app"}, |
| 126 | "repo mirror list": {"alice/app"}, | 126 | "repo mirror list": {"alice/app"}, |
| 127 | "repo domain list": {"alice/app"}, | 127 | "repo domain list": {"alice/app"}, |
| 128 | "repo deps status": {"alice/app"}, | 128 | "repo deps status": {"alice/app"}, |
| 129 | "status list": {"alice/app", sha}, | 129 | "status list": {"alice/app", sha}, |
| 130 | "issue list": {"alice/app"}, | 130 | "issue list": {"alice/app"}, |
| 131 | "issue show": {"alice/app", "1"}, | 131 | "issue show": {"alice/app", "1"}, |
| 132 | "issue templates": {"alice/app"}, | 132 | "issue templates": {"alice/app"}, |
| 133 | "label list": {"alice/app"}, | 133 | "label list": {"alice/app"}, |
| 134 | "milestone list": {"alice/app"}, | 134 | "milestone list": {"alice/app"}, |
| 135 | "mr list": {"alice/app"}, | 135 | "mr list": {"alice/app"}, |
| 136 | "mr show": {"alice/app", "1"}, | 136 | "mr show": {"alice/app", "1"}, |
| 137 | "mr diff": {"alice/app", "1"}, | 137 | "mr diff": {"alice/app", "1"}, |
| 138 | "mr threads": {"alice/app", "1"}, | 138 | "mr threads": {"alice/app", "1"}, |
| 139 | "build list": {"alice/app"}, | 139 | "build list": {"alice/app"}, |
| 140 | "build jobs": {"alice/app"}, | 140 | "build jobs": {"alice/app"}, |
| 141 | "build show": {"alice/app", "1"}, | 141 | "build show": {"alice/app", "1"}, |
| 142 | "build log": {"alice/app", "1"}, | 142 | "build log": {"alice/app", "1"}, |
| 143 | "release list": {"alice/app"}, | 143 | "release list": {"alice/app"}, |
| 144 | "release show": {"alice/app", "v1"}, | 144 | "release show": {"alice/app", "v1"}, |
| 145 | "release asset get": {"alice/app", "v1", "a.txt"}, | 145 | "release asset get": {"alice/app", "v1", "a.txt"}, |
| 146 | "notifications list": nil, | 146 | "notifications list": nil, |
| 147 | "repo bookmarks": nil, | 147 | "notifications settings show": nil, |
| 148 | "search": {"app"}, | 148 | "repo bookmarks": nil, |
| 149 | "mr revisions": {"alice/app", "1"}, | 149 | "search": {"app"}, |
| 150 | "mr range-diff": {"alice/app", "1"}, | 150 | "mr revisions": {"alice/app", "1"}, |
| 151 | "webhook list": {"alice/app"}, | 151 | "mr range-diff": {"alice/app", "1"}, |
| 152 | "webhook deliveries": {"alice/app"}, | 152 | "webhook list": {"alice/app"}, |
| 153 | "wiki list": {"alice/app"}, | 153 | "webhook deliveries": {"alice/app"}, |
| 154 | "wiki show": {"alice/app"}, | 154 | "wiki list": {"alice/app"}, |
| 155 | "wiki show": {"alice/app"}, | ||
| 155 | } | 156 | } |
| 156 | // Reads whose subject legitimately does not exist in this fixture. | 157 | // Reads whose subject legitimately does not exist in this fixture. |
| 157 | notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true} | 158 | notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true} |
internal/control/notifications.go +41 −1
| @@ -20,6 +20,13 @@ func init() { | |||
| 20 | register(Command{Path: []string{"notifications", "read"}, | 20 | register(Command{Path: []string{"notifications", "read"}, |
| 21 | Summary: "mark notifications read", | 21 | Summary: "mark notifications read", |
| 22 | Usage: "notifications read <id>... | --all", Run: runNotificationsRead}) | 22 | 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}) | ||
| 23 | register(Command{Path: []string{"repo", "watch"}, | 30 | register(Command{Path: []string{"repo", "watch"}, |
| 24 | Summary: "hear about all activity on a repository", | 31 | Summary: "hear about all activity on a repository", |
| 25 | Usage: "repo watch <owner/name>", Run: runRepoWatch}) | 32 | Usage: "repo watch <owner/name>", Run: runRepoWatch}) |
| @@ -66,7 +73,7 @@ func notify(c *Ctx, userIDs []int64, n notice) { | |||
| 66 | if !sendMail { | 73 | if !sendMail { |
| 67 | continue | 74 | continue |
| 68 | } | 75 | } |
| 69 | email, err := c.Store.PrimaryVerifiedEmail(id) | 76 | email, err := c.Store.ActivityMailAddress(id) |
| 70 | if err != nil || email == "" { | 77 | if err != nil || email == "" { |
| 71 | continue | 78 | continue |
| 72 | } | 79 | } |
| @@ -136,6 +143,39 @@ func mrSubject(repo store.Repo, number int64, title string) string { | |||
| 136 | return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title) | 143 | return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title) |
| 137 | } | 144 | } |
| 138 | 145 | ||
| 146 | func 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 | |||
| 160 | func 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). | ||
| 169 | func 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 | |||
| 139 | // noticesDefaultLimit caps a bare list; pagination reaches further back. | 179 | // noticesDefaultLimit caps a bare list; pagination reaches further back. |
| 140 | const noticesDefaultLimit = 50 | 180 | const noticesDefaultLimit = 50 |
| 141 | 181 | ||
internal/deps/worker.go +1 −1
| @@ -251,7 +251,7 @@ func (w *Worker) notify(repo store.Repo, number int64, action, body string) { | |||
| 251 | if w.Cfg.Mail.SMTPHost == "" { | 251 | if w.Cfg.Mail.SMTPHost == "" { |
| 252 | continue | 252 | continue |
| 253 | } | 253 | } |
| 254 | email, err := w.St.PrimaryVerifiedEmail(id) | 254 | email, err := w.St.ActivityMailAddress(id) |
| 255 | if err != nil || email == "" { | 255 | if err != nil || email == "" { |
| 256 | continue | 256 | continue |
| 257 | } | 257 | } |
internal/httpd/account.go +13 −1
| @@ -52,6 +52,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | |||
| 52 | 52 | ||
| 53 | var profile control.ProfileOut | 53 | var profile control.ProfileOut |
| 54 | s.runControlInto(u, []string{"profile", "show"}, &profile) | 54 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 55 | mailOn, _ := s.st.MailEnabled(u.ID) | ||
| 55 | 56 | ||
| 56 | s.render(w, "account.html", struct { | 57 | s.render(w, "account.html", struct { |
| 57 | basePage | 58 | basePage |
| @@ -64,8 +65,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | |||
| 64 | Host string | 65 | Host string |
| 65 | Notice string | 66 | Notice string |
| 66 | Message string | 67 | Message string |
| 68 | MailOn bool | ||
| 67 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), | 69 | }{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}) |
| 69 | } | 71 | } |
| 70 | 72 | ||
| 71 | // accountExport hands the browser the same bundle `account export` | 73 | // 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 | |||
| 191 | return | 193 | return |
| 192 | } | 194 | } |
| 193 | back("", "primary address changed") | 195 | 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") | ||
| 194 | case "profile": | 206 | case "profile": |
| 195 | format := r.FormValue("format") | 207 | format := r.FormValue("format") |
| 196 | if format != "org" { | 208 | if format != "org" { |
internal/store/migrations/0048_notify_mail.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | ALTER 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). | ||
| 4 | ALTER 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) { | |||
| 494 | return addr, err | 494 | return addr, err |
| 495 | } | 495 | } |
| 496 | 496 | ||
| 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). | ||
| 500 | func (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 | |||
| 497 | // PreferredVerifiedEmail returns the primary address if it is verified, | 508 | // PreferredVerifiedEmail returns the primary address if it is verified, |
| 498 | // otherwise the account's other verified address that sorts first by | 509 | // otherwise the account's other verified address that sorts first by |
| 499 | // address; "" if none is verified. Unlike PrimaryVerifiedEmail, a verified | 510 | // 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 { | |||
| 195 | return err | 195 | return err |
| 196 | } | 196 | } |
| 197 | 197 | ||
| 198 | // MailEnabled reports whether activity notifications reach the account | ||
| 199 | // by mail as well as the inbox. | ||
| 200 | func (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 | |||
| 209 | func (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 | |||
| 198 | func (s *Store) UserByID(id int64) (User, error) { | 218 | func (s *Store) UserByID(id int64) (User, error) { |
| 199 | var u User | 219 | var u User |
| 200 | var admin, pending, disabled int | 220 | var admin, pending, disabled int |
internal/web/templates/account.html +9
| @@ -98,6 +98,15 @@ account, and where notifications go.</p> | |||
| 98 | </form> | 98 | </form> |
| 99 | </details> | 99 | </details> |
| 100 | 100 | ||
| 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 | |||
| 101 | <h2>Export</h2> | 110 | <h2>Export</h2> |
| 102 | <p class="meta">Your profile, repositories, issues and merge requests as one | 111 | <p class="meta">Your profile, repositories, issues and merge requests as one |
| 103 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are | 112 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are |