krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

dce6f1b3897a636e2cb1c99f5a33db9e19503395

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:25:40Z

registration: invite and open modes with SMTP verification

- internal/mail: minimal SMTP sender (STARTTLS when offered, PLAIN auth
  when configured); [mail] gains smtp_user/smtp_pass; from required
  whenever smtp_host is set
- migration 0003: email_tokens table, users.pending column
- open mode: unknown keys are admitted to run exactly one command —
  register --username --email — which creates a pending account,
  registers the presented key, and mails a 24h single-use code; pending
  accounts may only run whoami/help/email add/email verify, and git
  transport is refused until verification (verified_by = smtp)
- invite mode: gitbayd admin invite --email mails a single-use code
  (printed instead when no SMTP); register --invite redeems it into an
  immediately active account — code possession proves the mailbox;
  uninvited registration refused
- email add/verify as control commands and CLI passthroughs; gitbay
  register passthrough for first contact
- e2e: in-test SMTP server capturing mail; full open flow (denial hint,
  pending gates on control+git, wrong/reused codes, activation, second
  address) and invite flow (uninvited refusal, mailed code, active on
  redeem, single-use)
 cmd/gitbay/main.go                                 |   6 +
 cmd/gitbayd/main.go                                |  50 ++++-
 e2e/registration_test.go                           | 221 +++++++++++++++++++++
 internal/config/config.go                          |   7 +-
 internal/control/control.go                        |  10 +
 internal/control/register.go                       | 178 +++++++++++++++++
 internal/mail/mail.go                              |  64 ++++++
 internal/sshd/sshd.go                              |  41 +++-
 .../store/migrations/0003_registration.down.sql    |   2 +
 internal/store/migrations/0003_registration.up.sql |  10 +
 internal/store/registration.go                     |  73 +++++++
 internal/store/users.go                            |  15 +-
 12 files changed, 668 insertions(+), 9 deletions(-)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index f0bd75f..b96105d 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -32,6 +32,8 @@ func main() {
 		webCmd(),
 		remoteCmd(),
 		initCmd(),
+		pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
+			passOpts{server: []string{"register"}}),
 		manCmd(root),
 	)
 
@@ -176,6 +178,10 @@ func authCmd() *cobra.Command {
 			keysAdd,
 			pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
 		),
+		group("email", "manage email addresses",
+			pass("add", "add an address and get a verification code by mail", passOpts{server: []string{"email", "add"}}),
+			pass("verify", "confirm a verification code", passOpts{server: []string{"email", "verify"}}),
+		),
 		group("pgp", "manage OpenPGP keys",
 			pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
 			pgpAdd,
diff --git a/cmd/gitbayd/main.go b/cmd/gitbayd/main.go
index a4bdf85..1dbcf3d 100644
--- a/cmd/gitbayd/main.go
+++ b/cmd/gitbayd/main.go
@@ -8,6 +8,7 @@ import (
 	"net"
 	"net/http"
 	"os"
+	"strings"
 	"path/filepath"
 	"strconv"
 
@@ -16,6 +17,7 @@ import (
 
 	"gitbay.org/gitbay/internal/config"
 	"gitbay.org/gitbay/internal/control"
+	"gitbay.org/gitbay/internal/mail"
 	"gitbay.org/gitbay/internal/gitd"
 	"gitbay.org/gitbay/internal/hookd"
 	"gitbay.org/gitbay/internal/httpd"
@@ -214,7 +216,7 @@ func adminCmd() *cobra.Command {
 	admin.AddCommand(
 		userCmd,
 		emailCmd,
-		notImplemented("invite", "issue registration invites"),
+		adminInviteCmd(),
 		backupCmd(),
 		notImplemented("gc", "run git gc across repositories"),
 		notImplemented("stats", "instance statistics"),
@@ -222,6 +224,52 @@ func adminCmd() *cobra.Command {
 	return admin
 }
 
+func adminInviteCmd() *cobra.Command {
+	var email string
+	cmd := &cobra.Command{
+		Use:   "invite",
+		Short: "issue a registration invite and email its code",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if email == "" {
+				return fmt.Errorf("--email is required")
+			}
+			cfg, err := config.Load(configPath)
+			if err != nil {
+				return err
+			}
+			st, err := openStore(cfg)
+			if err != nil {
+				return err
+			}
+			defer st.Close()
+
+			code, hash, err := store.NewToken()
+			if err != nil {
+				return err
+			}
+			if err := st.CreateInvite(hash, email); err != nil {
+				return err
+			}
+			host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/")
+			body := fmt.Sprintf(
+				"You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+
+					"    ssh git@%s register --username <name> --invite %s\n\n"+
+					"The invite is single-use and tied to this address.\n", host, host, code)
+			if cfg.Mail.SMTPHost != "" {
+				if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil {
+					return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code)
+				}
+				fmt.Printf("invite emailed to %s\n", email)
+			} else {
+				fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
+			}
+			return nil
+		},
+	}
+	cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)")
+	return cmd
+}
+
 func adminUserCreateCmd() *cobra.Command {
 	var keyPath, email string
 	var verified, isAdmin bool
diff --git a/e2e/registration_test.go b/e2e/registration_test.go
new file mode 100644
index 0000000..42c4fab
--- /dev/null
+++ b/e2e/registration_test.go
@@ -0,0 +1,221 @@
+package e2e
+
+import (
+	"bufio"
+	"fmt"
+	"net"
+	"regexp"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+// fakeSMTP is a minimal SMTP server capturing delivered messages.
+type fakeSMTP struct {
+	addr string
+	mu   sync.Mutex
+	mail []string // raw DATA payloads
+}
+
+func startFakeSMTP(t *testing.T) *fakeSMTP {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { ln.Close() })
+	f := &fakeSMTP{addr: ln.Addr().String()}
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			go f.handle(conn)
+		}
+	}()
+	return f
+}
+
+func (f *fakeSMTP) handle(conn net.Conn) {
+	defer conn.Close()
+	r := bufio.NewReader(conn)
+	say := func(s string) { fmt.Fprintf(conn, "%s\r\n", s) }
+	say("220 fake ESMTP")
+	var data strings.Builder
+	inData := false
+	for {
+		line, err := r.ReadString('\n')
+		if err != nil {
+			return
+		}
+		line = strings.TrimRight(line, "\r\n")
+		if inData {
+			if line == "." {
+				f.mu.Lock()
+				f.mail = append(f.mail, data.String())
+				f.mu.Unlock()
+				data.Reset()
+				inData = false
+				say("250 ok")
+				continue
+			}
+			data.WriteString(line + "\n")
+			continue
+		}
+		switch {
+		case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
+			fmt.Fprintf(conn, "250-fake\r\n250 SIZE 1000000\r\n")
+		case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
+			say("250 ok")
+		case line == "DATA":
+			inData = true
+			say("354 go")
+		case line == "QUIT":
+			say("221 bye")
+			return
+		default:
+			say("250 ok")
+		}
+	}
+}
+
+// waitMail returns the nth captured message.
+func (f *fakeSMTP) waitMail(t *testing.T, n int) string {
+	t.Helper()
+	deadline := time.Now().Add(5 * time.Second)
+	for time.Now().Before(deadline) {
+		f.mu.Lock()
+		if len(f.mail) > n {
+			m := f.mail[n]
+			f.mu.Unlock()
+			return m
+		}
+		f.mu.Unlock()
+		time.Sleep(50 * time.Millisecond)
+	}
+	t.Fatalf("mail %d never arrived", n)
+	return ""
+}
+
+var codePat = regexp.MustCompile(`(?:verify|--invite) ([0-9a-f]{64})`)
+
+func extractCode(t *testing.T, mail string) string {
+	t.Helper()
+	m := codePat.FindStringSubmatch(mail)
+	if m == nil {
+		t.Fatalf("no code in mail:\n%s", mail)
+	}
+	return m[1]
+}
+
+func TestOpenRegistration(t *testing.T) {
+	smtp := startFakeSMTP(t)
+	inst := startInstanceWith(t, fmt.Sprintf(
+		"[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
+
+	// A stranger's key cannot run normal commands, and the denial explains
+	// how to register.
+	newKey := inst.newKey(t, "newcomer")
+	_, errOut, code := inst.ssh(t, newKey, "", "whoami")
+	if code != 4 || !strings.Contains(errOut, "register --username") {
+		t.Fatalf("stranger whoami: exit %d, %s", code, errOut)
+	}
+
+	// Register: account created pending, verification mail sent.
+	out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test")
+	if code != 0 {
+		t.Fatalf("register: exit %d, %s", code, errOut)
+	}
+	if !strings.Contains(out, "verification code was sent") {
+		t.Fatalf("register output: %s", out)
+	}
+	msg := smtp.waitMail(t, 0)
+	if !strings.Contains(msg, "To: dana@example.test") || !strings.Contains(msg, "From: noreply@gitbay.test") {
+		t.Fatalf("mail headers:\n%s", msg)
+	}
+
+	// Pending: the key authenticates, whoami works, but everything else is
+	// gated — control commands and git alike.
+	if out, _, code = inst.ssh(t, newKey, "", "whoami"); code != 0 || strings.TrimSpace(out) != "dana" {
+		t.Fatalf("pending whoami: %d %q", code, out)
+	}
+	_, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj")
+	if code != 4 || !strings.Contains(errOut, "not active yet") {
+		t.Fatalf("pending repo create: exit %d, %s", code, errOut)
+	}
+	cloneOut, cloneCode := gitRun(t, t.TempDir(), inst.gitEnv(newKey), "clone", inst.sshURL("dana/anything"))
+	if cloneCode == 0 || !strings.Contains(cloneOut, "not active yet") {
+		t.Fatalf("pending git: %d\n%s", cloneCode, cloneOut)
+	}
+
+	// A wrong code fails; the mailed code activates the account.
+	if _, _, code = inst.ssh(t, newKey, "", "email", "verify", strings.Repeat("0", 64)); code != 2 {
+		t.Fatalf("bad code: exit %d, want 2", code)
+	}
+	verifyCode := extractCode(t, msg)
+	out, errOut, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode)
+	if code != 0 || !strings.Contains(out, "account is active") {
+		t.Fatalf("verify: exit %d, %s%s", code, out, errOut)
+	}
+	// Single use.
+	if _, _, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode); code != 2 {
+		t.Fatalf("code reuse: exit %d, want 2", code)
+	}
+
+	// Fully active: repo create works, and the verified email makes
+	// signature verification meaningful (verified_by = smtp).
+	if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj"); code != 0 {
+		t.Fatalf("post-verify repo create: %s", errOut)
+	}
+
+	// Self-service email add on an existing account sends a second mail.
+	if _, errOut, code = inst.ssh(t, newKey, "", "email", "add", "dana2@example.test"); code != 0 {
+		t.Fatalf("email add: %s", errOut)
+	}
+	msg2 := smtp.waitMail(t, 1)
+	if !strings.Contains(msg2, "To: dana2@example.test") {
+		t.Fatalf("second mail:\n%s", msg2)
+	}
+	if _, _, code = inst.ssh(t, newKey, "", "email", "verify", extractCode(t, msg2)); code != 0 {
+		t.Fatal("second verify failed")
+	}
+}
+
+func TestInviteRegistration(t *testing.T) {
+	smtp := startFakeSMTP(t)
+	inst := startInstanceWith(t, fmt.Sprintf(
+		"[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
+
+	// Registering without an invite is refused.
+	newKey := inst.newKey(t, "guest")
+	_, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "erin", "--email", "erin@example.test")
+	if code != 4 || !strings.Contains(errOut, "invite-only") {
+		t.Fatalf("uninvited register: exit %d, %s", code, errOut)
+	}
+
+	// Admin issues an invite; the code arrives by mail.
+	out := inst.admin(t, "admin", "invite", "--email", "erin@example.test")
+	if !strings.Contains(out, "invite emailed") {
+		t.Fatalf("invite output: %s", out)
+	}
+	inviteCode := extractCode(t, smtp.waitMail(t, 0))
+
+	// Redeeming it creates an ACTIVE account: code possession proves the
+	// mailbox, so the email is verified (by smtp) and nothing is pending.
+	out, errOut, code = inst.ssh(t, newKey, "", "register", "--username", "erin", "--invite", inviteCode)
+	if code != 0 || !strings.Contains(out, "account is active") {
+		t.Fatalf("invite register: exit %d, %s%s", code, out, errOut)
+	}
+	if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "erin/proj"); code != 0 {
+		t.Fatalf("invited user repo create: %s", errOut)
+	}
+
+	// Invites are single-use.
+	otherKey := inst.newKey(t, "other")
+	_, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "fake", "--invite", inviteCode)
+	if code != 4 || !strings.Contains(errOut, "already used") {
+		t.Fatalf("invite reuse: exit %d, %s", code, errOut)
+	}
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 293fa9a..46dd369 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -62,8 +62,10 @@ type Limits struct {
 }
 
 type Mail struct {
-	SMTPHost string `toml:"smtp_host"`
+	SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587)
 	From     string `toml:"from"`
+	SMTPUser string `toml:"smtp_user,omitempty"`
+	SMTPPass string `toml:"smtp_pass,omitempty"`
 }
 
 // Default returns the configuration used when a key is absent from the file.
@@ -139,6 +141,9 @@ func (c Config) Validate() error {
 	}
 
 	// Contradictions.
+	if c.Mail.SMTPHost != "" && c.Mail.From == "" {
+		errs = append(errs, errors.New("[mail] from is required when smtp_host is set"))
+	}
 	if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
 		errs = append(errs, fmt.Errorf(
 			"registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
diff --git a/internal/control/control.go b/internal/control/control.go
index e6fc544..197fcd9 100644
--- a/internal/control/control.go
+++ b/internal/control/control.go
@@ -70,6 +70,10 @@ func Dispatch(c *Ctx, argv []string) int {
 	if c.Scope != "full" {
 		return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
 	}
+	if c.User.Pending && !pendingAllowed(cmd.Path) {
+		return c.fail(protocol.ExitDenied,
+			"your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
+	}
 	// Strip the global --json flag wherever it appears.
 	args := rest[:0:0]
 	for _, a := range rest {
@@ -85,6 +89,12 @@ func Dispatch(c *Ctx, argv []string) int {
 	return cmd.Run(c, args)
 }
 
+// pendingAllowed lists what an unverified self-registered account may do.
+func pendingAllowed(path []string) bool {
+	key := joinPath(path)
+	return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
+}
+
 type emptyReader struct{}
 
 func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
diff --git a/internal/control/register.go b/internal/control/register.go
new file mode 100644
index 0000000..e40ad71
--- /dev/null
+++ b/internal/control/register.go
@@ -0,0 +1,178 @@
+package control
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+
+	"gitbay.org/gitbay/internal/config"
+	"gitbay.org/gitbay/internal/mail"
+	"gitbay.org/gitbay/internal/policy"
+	"gitbay.org/gitbay/internal/protocol"
+	"gitbay.org/gitbay/internal/store"
+)
+
+func init() {
+	register(Command{Path: []string{"email", "add"},
+		Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd})
+	register(Command{Path: []string{"email", "verify"},
+		Summary: "confirm a verification code: email verify <code>", Run: runEmailVerify})
+}
+
+func siteHost(cfg config.Config) string {
+	h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
+	return strings.TrimSuffix(h, "/")
+}
+
+func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error {
+	code, hash, err := store.NewToken()
+	if err != nil {
+		return err
+	}
+	if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil {
+		return err
+	}
+	body := fmt.Sprintf(
+		"Someone (hopefully you) added this address to an account on %s.\n\n"+
+			"To verify it, run:\n\n    ssh git@%s email verify %s\n\n"+
+			"The code expires in 24 hours. If this wasn't you, ignore this mail.\n",
+		siteHost(cfg), siteHost(cfg), code)
+	return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
+}
+
+func runEmailAdd(c *Ctx, args []string) int {
+	if len(args) != 1 || !strings.Contains(args[0], "@") {
+		return c.fail(protocol.ExitUsage, "usage: email add <address>")
+	}
+	if c.Cfg.Mail.SMTPHost == "" {
+		return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)")
+	}
+	if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil {
+		return c.fail(protocol.ExitFailure, "sending verification mail: %v", err)
+	}
+	return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) {
+		fmt.Fprintf(w, "verification code sent to %s\n", args[0])
+	})
+}
+
+func runEmailVerify(c *Ctx, args []string) int {
+	if len(args) != 1 {
+		return c.fail(protocol.ExitUsage, "usage: email verify <code>")
+	}
+	address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0]))
+	if err != nil {
+		if errors.Is(err, store.ErrNotFound) {
+			return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
+		}
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := c.Store.ClearPending(c.User.ID); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
+		fmt.Fprintf(w, "%s verified; your account is active\n", address)
+	})
+}
+
+// RunRegister handles the one command an UNAUTHENTICATED key may run. It is
+// dispatched outside the normal registry: the caller has already checked
+// that registration is enabled and that argv[0] == "register".
+func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
+	stdout, stderr io.Writer) int {
+	var username, email, invite string
+	args := argv[1:]
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--username", "--email", "--invite":
+			if i+1 >= len(args) {
+				fmt.Fprintf(stderr, "%s requires a value\n", args[i])
+				return protocol.ExitUsage
+			}
+			switch args[i] {
+			case "--username":
+				username = args[i+1]
+			case "--email":
+				email = args[i+1]
+			case "--invite":
+				invite = args[i+1]
+			}
+			i++
+		default:
+			fmt.Fprintf(stderr, "unexpected argument %q\n", args[i])
+			return protocol.ExitUsage
+		}
+	}
+	fail := func(code int, format string, a ...any) int {
+		fmt.Fprintf(stderr, format+"\n", a...)
+		return code
+	}
+	if username == "" {
+		return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>")
+	}
+	if err := policy.ValidateOwnerName(username); err != nil {
+		return fail(protocol.ExitUsage, "%v", err)
+	}
+
+	switch cfg.Registration.Mode {
+	case "invite":
+		if invite == "" {
+			return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>")
+		}
+		addr, err := st.ConsumeInvite(store.HashToken(invite))
+		if err != nil {
+			return fail(protocol.ExitDenied, "that invite is invalid or already used")
+		}
+		uid, err := st.CreateRegisteredUser(username, false)
+		if err != nil {
+			return fail(protocol.ExitFailure, "%v", err)
+		}
+		// Possession of the emailed invite code proves the mailbox.
+		if err := st.AddEmail(uid, addr, "smtp", true); err != nil {
+			return fail(protocol.ExitFailure, "%v", err)
+		}
+		if err := addRegisteredKey(st, uid, pub); err != nil {
+			return fail(protocol.ExitFailure, "%v", err)
+		}
+		fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username)
+		return protocol.ExitOK
+
+	case "open":
+		if email == "" || !strings.Contains(email, "@") {
+			return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>")
+		}
+		uid, err := st.CreateRegisteredUser(username, true)
+		if err != nil {
+			return fail(protocol.ExitFailure, "%v", err)
+		}
+		if err := st.AddEmail(uid, email, "", true); err != nil {
+			return fail(protocol.ExitFailure, "%v", err)
+		}
+		if err := addRegisteredKey(st, uid, pub); err != nil {
+			return fail(protocol.ExitFailure, "%v", err)
+		}
+		if err := sendVerification(cfg, st, uid, email); err != nil {
+			return fail(protocol.ExitFailure, "sending verification mail: %v", err)
+		}
+		fmt.Fprintf(stdout,
+			"account %s created. A verification code was sent to %s.\nActivate with:\n\n    ssh git@%s email verify <code>\n",
+			username, email, siteHost(cfg))
+		return protocol.ExitOK
+
+	default:
+		return fail(protocol.ExitDenied, "registration is closed on this instance")
+	}
+}
+
+func addRegisteredKey(st *store.Store, uid int64, pub ssh.PublicKey) error {
+	return st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full")
+}
diff --git a/internal/mail/mail.go b/internal/mail/mail.go
new file mode 100644
index 0000000..76e8912
--- /dev/null
+++ b/internal/mail/mail.go
@@ -0,0 +1,64 @@
+// Package mail sends transactional email over SMTP: verification codes and
+// invites. STARTTLS is used when the server offers it; PLAIN auth when
+// credentials are configured.
+package mail
+
+import (
+	"fmt"
+	"net"
+	"net/smtp"
+	"strings"
+	"time"
+
+	"gitbay.org/gitbay/internal/config"
+)
+
+// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port.
+func Send(cfg config.Config, to, subject, body string) error {
+	m := cfg.Mail
+	if m.SMTPHost == "" || m.From == "" {
+		return fmt.Errorf("[mail] smtp_host and from must be configured")
+	}
+	host := m.SMTPHost
+	if !strings.Contains(host, ":") {
+		host += ":587"
+	}
+	hostname, _, _ := net.SplitHostPort(host)
+
+	msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf(
+		"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n",
+		m.From, to, subject, time.Now().Format(time.RFC1123Z), body))
+
+	c, err := smtp.Dial(host)
+	if err != nil {
+		return fmt.Errorf("smtp dial %s: %w", host, err)
+	}
+	defer c.Close()
+	if ok, _ := c.Extension("STARTTLS"); ok {
+		if err := c.StartTLS(nil); err != nil {
+			return fmt.Errorf("starttls: %w", err)
+		}
+	}
+	if m.SMTPUser != "" {
+		if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil {
+			return fmt.Errorf("smtp auth: %w", err)
+		}
+	}
+	if err := c.Mail(m.From); err != nil {
+		return err
+	}
+	if err := c.Rcpt(to); err != nil {
+		return err
+	}
+	w, err := c.Data()
+	if err != nil {
+		return err
+	}
+	if _, err := w.Write([]byte(msg)); err != nil {
+		return err
+	}
+	if err := w.Close(); err != nil {
+		return err
+	}
+	return c.Quit()
+}
diff --git a/internal/sshd/sshd.go b/internal/sshd/sshd.go
index be33e1c..cca74a6 100644
--- a/internal/sshd/sshd.go
+++ b/internal/sshd/sshd.go
@@ -5,6 +5,7 @@ package sshd
 import (
 	"crypto/ed25519"
 	"crypto/rand"
+	"encoding/base64"
 	"encoding/pem"
 	"errors"
 	"fmt"
@@ -95,11 +96,18 @@ func generateHostKey(path string) error {
 }
 
 // authenticate resolves the presented key to a registered account. The SSH
-// username is ignored; identity comes from the key alone.
+// username is ignored; identity comes from the key alone. When registration
+// is open or invite-based, unknown keys are admitted to run exactly one
+// command: register.
 func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
 	fp := ssh.FingerprintSHA256(pub)
 	key, err := s.st.SSHKeyByFingerprint(fp)
 	if err != nil {
+		if s.cfg.Registration.Mode != "closed" {
+			return &ssh.Permissions{Extensions: map[string]string{
+				"anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()),
+			}}, nil
+		}
 		return nil, fmt.Errorf("unknown key %s", fp)
 	}
 	return &ssh.Permissions{Extensions: map[string]string{
@@ -177,6 +185,9 @@ func sendExit(ch ssh.Channel, code int) {
 
 func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
 	ext := sconn.Permissions.Extensions
+	if blob := ext["anon-key"]; blob != "" {
+		return s.runAnonymous(ch, blob, cmdline)
+	}
 	userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
 	keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
 	user, err := s.st.UserByID(userID)
@@ -188,6 +199,30 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
 	return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr())
 }
 
+// runAnonymous handles a session from an unregistered key: the register
+// command and nothing else.
+func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
+	raw, err := base64.StdEncoding.DecodeString(keyB64)
+	if err != nil {
+		return protocol.ExitFailure
+	}
+	pub, err := ssh.ParsePublicKey(raw)
+	if err != nil {
+		return protocol.ExitFailure
+	}
+	argv, err := protocol.Tokenize(cmdline)
+	if err != nil {
+		fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
+		return protocol.ExitUsage
+	}
+	if len(argv) == 0 || argv[0] != "register" {
+		fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n  ssh <host> register --username <name> %s\n",
+			map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode])
+		return protocol.ExitDenied
+	}
+	return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr())
+}
+
 // Exec runs one SSH exec command line for an authenticated key. It is the
 // single dispatch path shared by the embedded listener and the system-sshd
 // forced command (gitbayd shell).
@@ -201,6 +236,10 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st
 	if len(argv) > 0 {
 		switch argv[0] {
 		case "git-upload-pack", "git-receive-pack", "git-upload-archive":
+			if user.Pending {
+				fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
+				return protocol.ExitDenied
+			}
 			return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
 		}
 	}
diff --git a/internal/store/migrations/0003_registration.down.sql b/internal/store/migrations/0003_registration.down.sql
new file mode 100644
index 0000000..7b71e11
--- /dev/null
+++ b/internal/store/migrations/0003_registration.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users DROP COLUMN pending;
+DROP TABLE email_tokens;
diff --git a/internal/store/migrations/0003_registration.up.sql b/internal/store/migrations/0003_registration.up.sql
new file mode 100644
index 0000000..15e9b56
--- /dev/null
+++ b/internal/store/migrations/0003_registration.up.sql
@@ -0,0 +1,10 @@
+CREATE TABLE email_tokens (
+    token_hash TEXT PRIMARY KEY,
+    user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    address    TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    expires_at TEXT NOT NULL,
+    used_at    TEXT
+);
+
+ALTER TABLE users ADD COLUMN pending INTEGER NOT NULL DEFAULT 0;
diff --git a/internal/store/registration.go b/internal/store/registration.go
new file mode 100644
index 0000000..8871c7b
--- /dev/null
+++ b/internal/store/registration.go
@@ -0,0 +1,73 @@
+package store
+
+import (
+	"errors"
+	"time"
+)
+
+// CreateInvite stores an invite code hash bound to an email address.
+func (s *Store) CreateInvite(codeHash, email string) error {
+	_, err := s.DB.Exec("INSERT INTO invites (code_hash, email) VALUES (?, ?)", codeHash, email)
+	return err
+}
+
+// ConsumeInvite redeems an invite exactly once, returning the address it was
+// issued for. Used and unknown codes fail identically.
+func (s *Store) ConsumeInvite(codeHash string) (string, error) {
+	res, err := s.DB.Exec(
+		"UPDATE invites SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE code_hash = ? AND used_at IS NULL",
+		codeHash)
+	if err != nil {
+		return "", err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return "", ErrNotFound
+	}
+	var email string
+	err = s.DB.QueryRow("SELECT email FROM invites WHERE code_hash = ?", codeHash).Scan(&email)
+	return email, err
+}
+
+// CreateEmailToken stores a verification code hash for one address.
+func (s *Store) CreateEmailToken(userID int64, address, tokenHash string, ttl time.Duration) error {
+	_, err := s.DB.Exec(
+		"INSERT INTO email_tokens (token_hash, user_id, address, expires_at) VALUES (?, ?, ?, ?)",
+		tokenHash, userID, address, fmtTime(time.Now().Add(ttl)))
+	return err
+}
+
+// ConsumeEmailToken redeems a verification code for the given user.
+func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error) {
+	res, err := s.DB.Exec(`
+		UPDATE email_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
+		WHERE token_hash = ? AND user_id = ? AND used_at IS NULL AND expires_at > ?`,
+		tokenHash, userID, fmtTime(time.Now()))
+	if err != nil {
+		return "", err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return "", ErrNotFound
+	}
+	var address string
+	err = s.DB.QueryRow("SELECT address FROM email_tokens WHERE token_hash = ?", tokenHash).Scan(&address)
+	return address, err
+}
+
+// CreateRegisteredUser makes a self-registered account, pending until its
+// email is verified.
+func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) {
+	res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending))
+	if err != nil {
+		if isUniqueErr(err) {
+			return 0, errors.New("that username is taken")
+		}
+		return 0, err
+	}
+	return res.LastInsertId()
+}
+
+// ClearPending activates a pending account.
+func (s *Store) ClearPending(userID int64) error {
+	_, err := s.DB.Exec("UPDATE users SET pending = 0 WHERE id = ?", userID)
+	return err
+}
diff --git a/internal/store/users.go b/internal/store/users.go
index b671f50..0ae36a3 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -11,6 +11,7 @@ type User struct {
 	ID       int64
 	Username string
 	IsAdmin  bool
+	Pending  bool // self-registered, email not yet verified
 }
 
 type SSHKey struct {
@@ -41,25 +42,27 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
 
 func (s *Store) UserByUsername(name string) (User, error) {
 	var u User
-	var admin int
-	err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name).
-		Scan(&u.ID, &u.Username, &admin)
+	var admin, pending int
+	err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name).
+		Scan(&u.ID, &u.Username, &admin, &pending)
 	if errors.Is(err, sql.ErrNoRows) {
 		return u, ErrNotFound
 	}
 	u.IsAdmin = admin != 0
+	u.Pending = pending != 0
 	return u, err
 }
 
 func (s *Store) UserByID(id int64) (User, error) {
 	var u User
-	var admin int
-	err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id).
-		Scan(&u.ID, &u.Username, &admin)
+	var admin, pending int
+	err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id).
+		Scan(&u.ID, &u.Username, &admin, &pending)
 	if errors.Is(err, sql.ErrNoRows) {
 		return u, ErrNotFound
 	}
 	u.IsAdmin = admin != 0
+	u.Pending = pending != 0
 	return u, err
 }