Commit aab3eb12e3
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Parity.org +1
| @@ -277,6 +277,7 @@ client has no use for one (krz/gitbay#57). | ||
| 277 | 277 | | email list, remove, primary | yes | yes | no | |
| 278 | 278 | | dashboard aggregate | yes | yes | yes | |
| 279 | 279 | | notification inbox | yes | yes | yes | |
| 280 | | activity mail on, off | yes | yes | no | | |
| 280 | 281 | | API token mint | yes | no | no | |
| 281 | 282 | | account export bundle | yes | yes | n/a | |
| 282 | 283 | | profile set | yes | yes | yes | |
.gitbay/wiki/Users.org +6 −4
| @@ -515,10 +515,12 @@ handled. | ||
| 515 | 515 | |
| 516 | 516 | * Notifications |
| 517 | 517 | |
| 518 | When the instance has SMTP configured, activity mails you: someone | |
| 519 | opens an issue or MR on your repository, comments where you are a | |
| 520 | participant (author, commenter, reviewer, or mentioned), reviews, | |
| 521 | closes, or merges. Writing =@name= in an issue, merge request or | |
| 518 | When the instance has SMTP configured, activity mails you as well as | |
| 519 | filing the inbox row: someone opens an issue or MR on your repository, | |
| 520 | comments where you are a participant (author, commenter, reviewer, 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 | 524 | comment files "mentioned you" in that account's inbox and makes them a |
| 523 | 525 | participant of the thread, provided they can read the repository and |
| 524 | 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 | 66 | passOpts{server: []string{"notifications", "list"}}), |
| 67 | 67 | pass("read", "mark notifications read: <id>... | --all", |
| 68 | 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 | 74 | group("wiki", "a repository's wiki pages", |
| 71 | 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 | 83 | must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created") |
| 84 | 84 | |
| 85 | 85 | 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"}, | |
| 155 | 156 | } |
| 156 | 157 | // Reads whose subject legitimately does not exist in this fixture. |
| 157 | 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 | 20 | register(Command{Path: []string{"notifications", "read"}, |
| 21 | 21 | Summary: "mark notifications read", |
| 22 | 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 | 30 | register(Command{Path: []string{"repo", "watch"}, |
| 24 | 31 | Summary: "hear about all activity on a repository", |
| 25 | 32 | Usage: "repo watch <owner/name>", Run: runRepoWatch}) |
| @@ -66,7 +73,7 @@ func notify(c *Ctx, userIDs []int64, n notice) { | ||
| 66 | 73 | if !sendMail { |
| 67 | 74 | continue |
| 68 | 75 | } |
| 69 | email, err := c.Store.PrimaryVerifiedEmail(id) | |
| 76 | email, err := c.Store.ActivityMailAddress(id) | |
| 70 | 77 | if err != nil || email == "" { |
| 71 | 78 | continue |
| 72 | 79 | } |
| @@ -136,6 +143,39 @@ func mrSubject(repo store.Repo, number int64, title string) string { | ||
| 136 | 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 | 179 | // noticesDefaultLimit caps a bare list; pagination reaches further back. |
| 140 | 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 | 251 | if w.Cfg.Mail.SMTPHost == "" { |
| 252 | 252 | continue |
| 253 | 253 | } |
| 254 | email, err := w.St.PrimaryVerifiedEmail(id) | |
| 254 | email, err := w.St.ActivityMailAddress(id) | |
| 255 | 255 | if err != nil || email == "" { |
| 256 | 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 | 53 | var profile control.ProfileOut |
| 54 | 54 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 55 | mailOn, _ := s.st.MailEnabled(u.ID) | |
| 55 | 56 | |
| 56 | 57 | s.render(w, "account.html", struct { |
| 57 | 58 | basePage |
| @@ -64,8 +65,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 64 | 65 | Host string |
| 65 | 66 | Notice string |
| 66 | 67 | Message string |
| 68 | MailOn bool | |
| 67 | 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 | 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 | 193 | return |
| 192 | 194 | } |
| 193 | 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 | 206 | case "profile": |
| 195 | 207 | format := r.FormValue("format") |
| 196 | 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 | 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 | 508 | // PreferredVerifiedEmail returns the primary address if it is verified, |
| 498 | 509 | // otherwise the account's other verified address that sorts first by |
| 499 | 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 | 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 | 218 | func (s *Store) UserByID(id int64) (User, error) { |
| 199 | 219 | var u User |
| 200 | 220 | var admin, pending, disabled int |
internal/web/templates/account.html +9
| @@ -98,6 +98,15 @@ account, and where notifications go.</p> | ||
| 98 | 98 | </form> |
| 99 | 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 | 110 | <h2>Export</h2> |
| 102 | 111 | <p class="meta">Your profile, repositories, issues and merge requests as one |
| 103 | 112 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are |