Commit 98cd521512

98cd5215128091b1254c095aa2cd8a95658b5a7a

parent: 25772eed72

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

cmc <hello@cleberg.net> · 2026-09-19 16:31 UTC

registration: mail the admins when an account becomes active

[registration] notify_admin, off by default, queues a notice to every
instance admin when an invite is redeemed or an open-mode signup
verifies its address. Config load refuses it without [mail] smtp_host.

The unverified row an open signup creates is not reported: anyone can
post the form, so mailing on that would aim a flood at the admins.
Recipients come from AdminMailAddresses, the verified primary address
of each active admin with activity mail on, so an admin who turned mail
off or verified nothing hears nothing. The notice is enqueued rather
than sent inline, so a dead SMTP host lands in the admin page's Mail
table instead of failing the registration that caused it.

Ref #234
.gitbay/wiki/Admin.org +11
@@ -101,6 +101,17 @@ default, is right when gitbayd terminates TLS itself.
101101 self-registered account still unverified after that long is removed,
102102 hourly and at start, audited as =pending.expired=.
103103
104- =notify_admin= (false) — mail every instance admin when an account
105 becomes active: an invite redeemed, or an open-mode signup that
106 verified its address. The unverified row an open signup creates is
107 not reported, because anyone can post the form and mailing on that
108 would point a flood at the admins. Recipients are the verified
109 primary addresses of active admins who have activity mail on, the
110 same rule any other notice follows, so an admin with no verified
111 address hears nothing. The notice is queued, so a dead SMTP host
112 shows up in the admin page's Mail table instead of failing the
113 registration. Requires [mail].
114
104115** [mail]
105116- =smtp_host= (host:port, 587 assumed), =from=, optional =smtp_user= /
106117 =smtp_pass=. STARTTLS when offered. Required for invite/open
CHANGELOG.org +5
@@ -8,6 +8,11 @@ anything beyond "replace the binary and restart" is needed.
88
99The web findings from the forge comparison (#232).
1010
11- =[registration] notify_admin= mails the instance's admins when an
12 account becomes active: an invite redeemed, or an open-mode signup
13 that verified its address. Off by default, requires =[mail]=, and
14 queued like any other notice (#234).
15
1116- Links inside running text are underlined: the meta lines on issues,
1217 merge requests and releases no longer tell a link apart by colour
1318 alone.
e2e/signupnotice_test.go added +68
@@ -0,0 +1,68 @@
1package e2e
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7 "time"
8)
9
10// registration.notify_admin mails the instance's admins when an account
11// becomes active. An open-mode signup counts at verification, not when
12// the row is created, so an unverified attempt is silent (#234).
13func TestSignupNotifiesAdmins(t *testing.T) {
14 smtp := startFakeSMTP(t)
15 inst := startInstanceWith(t, fmt.Sprintf(
16 "[registration]\nmode = \"open\"\nnotify_admin = true\n"+
17 "[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
18 rootKey := inst.newKey(t, "root")
19 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub",
20 "--admin", "--email", "root@example.test", "--verified")
21 // A second admin with no verified address is skipped, not an error.
22 inst.admin(t, "admin", "user", "create", "quiet", "--key", inst.newKey(t, "quiet")+".pub", "--admin")
23
24 danaKey := inst.newKey(t, "dana")
25 if _, errOut, code := inst.ssh(t, danaKey, "", "register",
26 "--username", "dana", "--email", "dana@example.test"); code != 0 {
27 t.Fatalf("register: %s", errOut)
28 }
29 // Pending: the admin has heard nothing yet.
30 time.Sleep(5 * time.Second) // the mailer ticks every two seconds
31 if got := smtp.mailTo("root@example.test"); len(got) != 0 {
32 t.Fatalf("admin mailed before the account was verified:\n%s", got[0])
33 }
34
35 code := extractCode(t, smtp.waitFor(t, "dana@example.test", "email verify"))
36 if _, errOut, ec := inst.ssh(t, danaKey, "", "email", "verify", code); ec != 0 {
37 t.Fatalf("verify: %s", errOut)
38 }
39 msg := smtp.waitFor(t, "root@example.test", "new account")
40 if !strings.Contains(msg, "dana") || !strings.Contains(msg, "open registration") {
41 t.Fatalf("notice body:\n%s", msg)
42 }
43}
44
45// With notify_admin off, the default, the same signup mails no one but
46// the person registering.
47func TestSignupNoticeOffByDefault(t *testing.T) {
48 smtp := startFakeSMTP(t)
49 inst := startInstanceWith(t, fmt.Sprintf(
50 "[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
51 rootKey := inst.newKey(t, "root")
52 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub",
53 "--admin", "--email", "root@example.test", "--verified")
54
55 eveKey := inst.newKey(t, "eve")
56 if _, errOut, ec := inst.ssh(t, eveKey, "", "register",
57 "--username", "eve", "--email", "eve@example.test"); ec != 0 {
58 t.Fatalf("register: %s", errOut)
59 }
60 code := extractCode(t, smtp.waitFor(t, "eve@example.test", "email verify"))
61 if _, errOut, ec := inst.ssh(t, eveKey, "", "email", "verify", code); ec != 0 {
62 t.Fatalf("verify: %s", errOut)
63 }
64 time.Sleep(5 * time.Second)
65 if got := smtp.mailTo("root@example.test"); len(got) != 0 {
66 t.Fatalf("admin mailed with notify_admin off:\n%s", got[0])
67 }
68}
internal/config/config.go +10
@@ -100,6 +100,12 @@ type Registration struct {
100100 // unverified before it is removed, as a duration ("168h"). Empty
101101 // keeps such accounts forever.
102102 PendingExpiry string `toml:"pending_expiry"`
103 // NotifyAdmin mails the instance's admins when an account becomes
104 // active: an invite redeemed, or an open-mode signup that verified
105 // its address. The unverified row an open signup creates is not
106 // reported — anyone can post the form, so mailing on that would
107 // aim a flood at the admins (#234).
108 NotifyAdmin bool `toml:"notify_admin"`
103109}
104110
105111// PendingExpiryDuration parses PendingExpiry; zero means never.
@@ -327,6 +333,10 @@ func (c Config) Validate() error {
327333 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
328334 c.Registration.Mode))
329335 }
336 if c.Registration.NotifyAdmin && c.Mail.SMTPHost == "" {
337 errs = append(errs, errors.New(
338 "registration.notify_admin = true requires [mail] smtp_host: there is nowhere to send the notice"))
339 }
330340 if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
331341 errs = append(errs, fmt.Errorf(
332342 "ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
internal/config/config_test.go +5
@@ -50,6 +50,11 @@ func TestContradictions(t *testing.T) {
5050 minimal + "\n[registration]\nmode = \"open\"\n",
5151 "requires [mail] smtp_host",
5252 },
53 {
54 "notify_admin without smtp",
55 minimal + "\n[registration]\nnotify_admin = true\n",
56 "notify_admin = true requires [mail] smtp_host",
57 },
5358 {
5459 "system ssh with open registration",
5560 minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n",
internal/control/register.go +30
@@ -133,6 +133,29 @@ func sendVerification(cfg config.Config, st *store.Store, userID int64, address
133133 return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
134134}
135135
136// notifyAdminsOfSignup tells the instance's admins that an account just
137// became active, when registration.notify_admin is on. It is queued like
138// any other notice, so a dead SMTP host shows up in the admin page's
139// Mail table rather than failing the registration that caused it: the
140// person signing up is not responsible for the operator's mail (#234).
141func notifyAdminsOfSignup(cfg config.Config, st *store.Store, username, mode string) {
142 if !cfg.Registration.NotifyAdmin {
143 return
144 }
145 addrs, err := st.AdminMailAddresses()
146 if err != nil || len(addrs) == 0 {
147 return
148 }
149 host := siteHost(cfg)
150 subject := fmt.Sprintf("new account on %s: %s", host, username)
151 body := fmt.Sprintf("%s registered on %s and the account is active (%s registration).\n\n"+
152 " https://%s/%s\n\nAccounts: ssh git@%s admin user list\n",
153 username, host, mode, host, username, host)
154 for _, a := range addrs {
155 st.EnqueueMail(a, subject, body)
156 }
157}
158
136159const maxEmailAddsPerHour = 5
137160
138161func runEmailAdd(c *Ctx, args []string) int {
@@ -186,9 +209,15 @@ func runEmailVerify(c *Ctx, args []string) int {
186209 if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
187210 return c.fail(protocol.ExitFailure, "%v", err)
188211 }
212 wasPending := c.User.Pending
189213 if err := c.Store.ClearPending(c.User.ID); err != nil {
190214 return c.fail(protocol.ExitFailure, "%v", err)
191215 }
216 // The open-mode account becomes real here, not when the form was
217 // posted, so this is where the admins hear about it.
218 if wasPending {
219 notifyAdminsOfSignup(c.Cfg, c.Store, c.User.Username, "open")
220 }
192221 return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
193222 fmt.Fprintf(w, "%s verified; your account is active\n", address)
194223 })
@@ -249,6 +278,7 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user
249278 return "", err.Error(), protocol.ExitUsage
250279 }
251280 st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp})
281 notifyAdminsOfSignup(cfg, st, username, "invite")
252282 return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
253283
254284 case "open":
internal/store/adminusers.go +25
@@ -86,6 +86,31 @@ func (s *Store) AdminUserByName(name string) (AdminUser, error) {
8686 return u, err
8787}
8888
89// AdminMailAddresses returns where to reach the instance's admins: the
90// verified primary address of every active admin who has activity mail
91// on. An admin with no verified primary, or with mail off, is skipped
92// rather than reported, the same rule ActivityMailAddress applies to
93// anyone else (#234).
94func (s *Store) AdminMailAddresses() ([]string, error) {
95 rows, err := s.DB.Query(`SELECT e.address FROM users u
96 JOIN emails e ON e.user_id = u.id AND e.is_primary = 1 AND e.verified_at IS NOT NULL
97 WHERE u.is_admin = 1 AND u.pending = 0 AND u.disabled = 0 AND u.notify_mail != 0
98 ORDER BY e.address`)
99 if err != nil {
100 return nil, err
101 }
102 defer rows.Close()
103 var out []string
104 for rows.Next() {
105 var a string
106 if err := rows.Scan(&a); err != nil {
107 return nil, err
108 }
109 out = append(out, a)
110 }
111 return out, rows.Err()
112}
113
89114// OwnedRepoCount counts repositories the user owns directly, not through
90115// an org.
91116func (s *Store) OwnedRepoCount(userID int64) (int64, error) {