Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


commit a9bafeef7c3a46a4372aaa240a902144c7bcc03c
equwal <truex@equwal.com>
2026-09-22 16:05:08 -0700

Accounts confirm their email address when the server has SMTP

With SBM_SMTP_HOST set, a new account gets an email with a link. The
account syncs only after someone opens the link. Until then, /api/sync
answers 403. The account page can send the email again, 5 times an hour.

The store keeps only the SHA-256 of the code. Accounts from before have
no code, so they count as confirmed. Without SBM_SMTP_HOST, the server
sends no email and checks no address, as before.

The mailer uses net/smtp: TLS from the start on port 465, else STARTTLS
when the server offers it. The tests play an SMTP server, with and
without TLS.

 README.md      |  27 +++++---
 mail.go        | 101 ++++++++++++++++++++++++++++++
 mail_test.go   | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 main.go        | 101 ++++++++++++++++++++++++++++--
 pages.html     |  12 +++-
 server_test.go | 112 +++++++++++++++++++++++++++++++++-
 store.go       |  39 +++++++++++-
 7 files changed, 567 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index cfa7e65..91ae916 100644
--- a/README.md
+++ b/README.md
@@ -49,8 +49,8 @@ Plain HTTP, for any client with curl.
     POST /api/logout       Authorization: Bearer <token>
 
 Errors come as text: 401 (sign in again), 402 (the subscription or the
-trial has ended), 413 (the file is larger than 4 MB), 429 (too many sign-in
-attempts).
+trial has ended), 403 (confirm the email address first), 413 (the file is
+larger than 4 MB), 429 (too many sign-in attempts).
 
     curl -d email=me@example.org --data-urlencode password=... https://sbm.subread.space/api/login
     curl -H "Authorization: Bearer $token" --data-binary @bookmarks \
@@ -75,10 +75,22 @@ Settings, all through the environment:
     SBM_TRIAL_DAYS  days of sync before payment, with billing on (default 30)
     SBM_BILLING_START  date when billing starts, as 2026-10-01: accounts
                     from before it get the full trial from that date
-
-Accounts need no email check. To reset a password, the operator deletes the
-account in `accounts.json` (with the server stopped), and the user signs up
-again. The bookmark files stay on the devices.
+    SBM_SMTP_HOST   SMTP server that sends email (optional)
+    SBM_SMTP_PORT   its port (default 587, with STARTTLS; 465 uses TLS
+                    from the start)
+    SBM_SMTP_USER   user name on the SMTP server
+    SBM_SMTP_PASSWORD  password on the SMTP server
+    SBM_MAIL_FROM   sender, as "sbm Sync <sync@example.org>" (default
+                    SBM_SMTP_USER)
+
+Without `SBM_SMTP_HOST`, the server sends no email, and accounts need no
+email check. With it, each new account gets an email with a link. The
+account syncs only after someone opens that link. Accounts from a time
+without `SBM_SMTP_HOST` need no check.
+
+To reset a password, the operator deletes the account in `accounts.json`
+(with the server stopped), and the user signs up again. The bookmark files
+stay on the devices.
 
 ## Billing
 
@@ -104,7 +116,8 @@ Keep the keys out of git: put them in the environment file of the service.
 ## Data
 
     data/accounts.json            accounts: email, bcrypt hash, token hashes,
-                                  Stripe customer and subscription state
+                                  hash of the email check code, Stripe
+                                  customer and subscription state
     data/files/<account>/<sha256> the last 50 versions of each bookmark file
     data/files/<account>/HEAD     name of the current version
 
diff --git a/mail.go b/mail.go
new file mode 100644
index 0000000..699ad17
--- /dev/null
+++ b/mail.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"mime"
+	"net"
+	"net/mail"
+	"net/smtp"
+	"strings"
+	"time"
+)
+
+// Mailer sends email through an SMTP server. With TLS set (port 465), the
+// connection uses TLS from the start. Else the mailer starts TLS when the
+// server offers STARTTLS. net/smtp never sends the password over a
+// connection without TLS, except to localhost.
+type Mailer struct {
+	Addr     string // host:port of the SMTP server
+	TLS      bool
+	User     string // empty for a server that needs no sign-in
+	Password string
+	From     *mail.Address
+	Hello    string         // name of this host in EHLO
+	Roots    *x509.CertPool // nil for the roots of the system; tests replace it
+}
+
+// send sends a plain text email. The lines of body end in "\n".
+func (m *Mailer) send(to, subject, body string) error {
+	host, _, err := net.SplitHostPort(m.Addr)
+	if err != nil {
+		return err
+	}
+	tc := &tls.Config{ServerName: host, RootCAs: m.Roots}
+	d := &net.Dialer{Timeout: 20 * time.Second}
+	var conn net.Conn
+	if m.TLS {
+		conn, err = tls.DialWithDialer(d, "tcp", m.Addr, tc)
+	} else {
+		conn, err = d.Dial("tcp", m.Addr)
+	}
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
+		return err
+	}
+	c, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return err
+	}
+	if err := c.Hello(m.Hello); err != nil {
+		return err
+	}
+	if ok, _ := c.Extension("STARTTLS"); ok && !m.TLS {
+		if err := c.StartTLS(tc); err != nil {
+			return err
+		}
+	}
+	if m.User != "" {
+		if err := c.Auth(smtp.PlainAuth("", m.User, m.Password, host)); err != nil {
+			return err
+		}
+	}
+	if err := c.Mail(m.From.Address); 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(m.message(to, subject, body)); err != nil {
+		return err
+	}
+	if err := w.Close(); err != nil {
+		return err
+	}
+	return c.Quit()
+}
+
+// message gives the email with its header. The lines end in CRLF, as SMTP
+// needs.
+func (m *Mailer) message(to, subject, body string) []byte {
+	_, domain, _ := strings.Cut(m.From.Address, "@")
+	var b strings.Builder
+	fmt.Fprintf(&b, "From: %s\r\n", m.From)
+	fmt.Fprintf(&b, "To: %s\r\n", to)
+	fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
+	fmt.Fprintf(&b, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
+	fmt.Fprintf(&b, "Message-ID: <%s@%s>\r\n", random(16), domain)
+	b.WriteString("MIME-Version: 1.0\r\n")
+	b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
+	b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
+	b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
+	return []byte(b.String())
+}
diff --git a/mail_test.go b/mail_test.go
new file mode 100644
index 0000000..457647c
--- /dev/null
+++ b/mail_test.go
@@ -0,0 +1,190 @@
+package main
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/base64"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"net/mail"
+	"net/textproto"
+	"strings"
+	"sync"
+	"testing"
+)
+
+// fakeSMTP plays an SMTP server and records what the client sends. With
+// tls set, it offers STARTTLS, or it uses TLS from the start when implicit
+// is set.
+type fakeSMTP struct {
+	ln       net.Listener
+	tls      *tls.Config
+	implicit bool
+
+	mu       sync.Mutex
+	auth     string // "user password" of AUTH PLAIN
+	authTLS  bool   // the connection had TLS at AUTH
+	from, to string // arguments of MAIL and RCPT
+	data     string
+}
+
+func newFakeSMTP(t *testing.T, tc *tls.Config, implicit bool) *fakeSMTP {
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { ln.Close() })
+	f := &fakeSMTP{ln: ln, tls: tc, implicit: implicit}
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			go f.session(conn)
+		}
+	}()
+	return f
+}
+
+func (f *fakeSMTP) session(conn net.Conn) {
+	defer func() { conn.Close() }()
+	secure := f.implicit
+	if secure {
+		conn = tls.Server(conn, f.tls)
+	}
+	tp := textproto.NewConn(conn)
+	tp.PrintfLine("220 fake ESMTP")
+	for {
+		line, err := tp.ReadLine()
+		if err != nil {
+			return
+		}
+		verb, arg, _ := strings.Cut(line, " ")
+		f.mu.Lock()
+		switch strings.ToUpper(verb) {
+		case "EHLO":
+			tp.PrintfLine("250-fake")
+			if f.tls != nil && !secure {
+				tp.PrintfLine("250-STARTTLS")
+			}
+			tp.PrintfLine("250 AUTH PLAIN")
+		case "STARTTLS":
+			tp.PrintfLine("220 go ahead")
+			conn = tls.Server(conn, f.tls)
+			tp = textproto.NewConn(conn)
+			secure = true
+		case "AUTH":
+			_, resp, _ := strings.Cut(arg, " ")
+			b, _ := base64.StdEncoding.DecodeString(resp)
+			f.auth = strings.TrimPrefix(strings.ReplaceAll(string(b), "\x00", " "), " ")
+			f.authTLS = secure
+			tp.PrintfLine("235 ok")
+		case "MAIL":
+			f.from = arg
+			tp.PrintfLine("250 ok")
+		case "RCPT":
+			f.to = arg
+			tp.PrintfLine("250 ok")
+		case "DATA":
+			tp.PrintfLine("354 go on")
+			b, _ := tp.ReadDotBytes()
+			f.data = string(b)
+			tp.PrintfLine("250 ok")
+		case "QUIT":
+			tp.PrintfLine("221 bye")
+			f.mu.Unlock()
+			return
+		default:
+			tp.PrintfLine("502 no such command")
+		}
+		f.mu.Unlock()
+	}
+}
+
+// testTLS gives a certificate for 127.0.0.1 and a pool that trusts it.
+func testTLS(t *testing.T) (*tls.Config, *x509.CertPool) {
+	ts := httptest.NewTLSServer(http.NotFoundHandler())
+	t.Cleanup(ts.Close)
+	pool := x509.NewCertPool()
+	pool.AddCert(ts.Certificate())
+	return &tls.Config{Certificates: ts.TLS.Certificates}, pool
+}
+
+func testMailer(f *fakeSMTP) *Mailer {
+	return &Mailer{Addr: f.ln.Addr().String(), Hello: "localhost",
+		From: &mail.Address{Name: "sbm Sync", Address: "sync@example.org"}}
+}
+
+func TestMailerSends(t *testing.T) {
+	f := newFakeSMTP(t, nil, false)
+	if err := testMailer(f).send("you@example.org", "Hello", "line 1\n.line 2\n"); err != nil {
+		t.Fatal(err)
+	}
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	if f.from != "FROM:<sync@example.org>" || f.to != "TO:<you@example.org>" {
+		t.Errorf("envelope: %q %q", f.from, f.to)
+	}
+	for _, want := range []string{
+		"From: \"sbm Sync\" <sync@example.org>\n",
+		"To: you@example.org\n",
+		"Subject: Hello\n",
+		"Message-ID: <",
+		"@example.org>\n",
+		"Content-Type: text/plain; charset=utf-8\n",
+		"\n\nline 1\n.line 2\n",
+	} {
+		if !strings.Contains(f.data, want) {
+			t.Errorf("the email lacks %q:\n%s", want, f.data)
+		}
+	}
+}
+
+func TestMailerStartTLS(t *testing.T) {
+	tc, pool := testTLS(t)
+	f := newFakeSMTP(t, tc, false)
+	m := testMailer(f)
+	m.User, m.Password, m.Roots = "me", "secret", pool
+	if err := m.send("you@example.org", "Hello", "hi\n"); err != nil {
+		t.Fatal(err)
+	}
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	if f.auth != "me secret" || !f.authTLS {
+		t.Errorf("auth %q, with TLS %v", f.auth, f.authTLS)
+	}
+}
+
+func TestMailerTLSFromTheStart(t *testing.T) {
+	tc, pool := testTLS(t)
+	f := newFakeSMTP(t, tc, true)
+	m := testMailer(f)
+	m.TLS, m.User, m.Password, m.Roots = true, "me", "secret", pool
+	if err := m.send("you@example.org", "Hello", "hi\n"); err != nil {
+		t.Fatal(err)
+	}
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	if f.auth != "me secret" || !f.authTLS || !strings.Contains(f.data, "hi\n") {
+		t.Errorf("auth %q, with TLS %v, data %q", f.auth, f.authTLS, f.data)
+	}
+}
+
+// A certificate that the system does not trust stops the email before the
+// password goes out.
+func TestMailerChecksTheCertificate(t *testing.T) {
+	tc, _ := testTLS(t)
+	f := newFakeSMTP(t, tc, false)
+	m := testMailer(f)
+	m.User, m.Password = "me", "secret"
+	if err := m.send("you@example.org", "Hello", "hi\n"); err == nil {
+		t.Error("the mailer trusts any certificate")
+	}
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	if f.auth != "" {
+		t.Errorf("the password went out: %q", f.auth)
+	}
+}
diff --git a/main.go b/main.go
index 44f4e7f..05f47b5 100644
--- a/main.go
+++ b/main.go
@@ -4,7 +4,7 @@
 // got last time. The server merges the changes of the device into its own
 // copy, keeps the result and sends it back. Accounts are an email address
 // and a password. Billing through Stripe is off unless STRIPE_SECRET_KEY is
-// set.
+// set. Accounts confirm their email address only when SBM_SMTP_HOST is set.
 package main
 
 import (
@@ -16,6 +16,7 @@ import (
 	"log"
 	"net"
 	"net/http"
+	"net/mail"
 	"net/url"
 	"os"
 	"strconv"
@@ -43,6 +44,9 @@ type server struct {
 	prices  map[string]string // plan ("month", "year"): Stripe price ID
 	labels  map[string]string // plan: price as text
 	limit   limiter
+	// mail sends an email. It is nil when the server has no SMTP server:
+	// then accounts need no email check.
+	mail func(to, subject, body string) error
 }
 
 func main() {
@@ -92,8 +96,19 @@ func main() {
 			}
 		}
 	}
+	if host := os.Getenv("SBM_SMTP_HOST"); host != "" {
+		from, err := mail.ParseAddress(env("SBM_MAIL_FROM", os.Getenv("SBM_SMTP_USER")))
+		if err != nil {
+			log.Fatalf("SBM_MAIL_FROM: %v", err)
+		}
+		port := env("SBM_SMTP_PORT", "587")
+		m := &Mailer{Addr: net.JoinHostPort(host, port), TLS: port == "465",
+			User: os.Getenv("SBM_SMTP_USER"), Password: os.Getenv("SBM_SMTP_PASSWORD"),
+			From: from, Hello: u.Hostname()}
+		s.mail = m.send
+	}
 	addr := env("SBM_ADDR", "127.0.0.1:8750")
-	log.Printf("sbm-sync on %s for %s, billing %v", addr, site, s.stripe != nil)
+	log.Printf("sbm-sync on %s for %s, billing %v, email check %v", addr, site, s.stripe != nil, s.mail != nil)
 	hs := &http.Server{
 		Addr:              addr,
 		Handler:           s.routes(),
@@ -123,6 +138,8 @@ func (s *server) routes() http.Handler {
 	m.HandleFunc("POST /login", s.login)
 	m.HandleFunc("POST /logout", s.logout)
 	m.HandleFunc("GET /account", s.account)
+	m.HandleFunc("POST /account/verify", s.resend)
+	m.HandleFunc("GET /verify", s.confirm)
 	m.HandleFunc("POST /account/checkout", s.checkout)
 	m.HandleFunc("POST /account/portal", s.portal)
 	m.HandleFunc("POST /account/delete", s.remove)
@@ -150,7 +167,7 @@ type page struct {
 	Title, Error, Email, State, URL, Contact string
 	Month, Year                              string
 	TrialDays                                int
-	Billing, CanSubscribe                    bool
+	Billing, CanSubscribe, Unconfirmed       bool
 	Customer                                 string
 }
 
@@ -222,7 +239,11 @@ func (s *server) signup(w http.ResponseWriter, r *http.Request) {
 		s.render(w, http.StatusTooManyRequests, "signup", p)
 		return
 	}
-	a, err := s.store.signup(email, r.PostFormValue("password"))
+	code := ""
+	if s.mail != nil {
+		code = random(16)
+	}
+	a, err := s.store.signup(email, r.PostFormValue("password"), code)
 	if errors.Is(err, errTaken) || errors.Is(err, errEmail) || errors.Is(err, errPassword) {
 		p.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
 		s.render(w, http.StatusBadRequest, "signup", p)
@@ -232,9 +253,76 @@ func (s *server) signup(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	log.Printf("new account %s", a.ID)
+	if code != "" {
+		// The account page tells how to send the email again.
+		if err := s.sendCheck(a.Email, code); err != nil {
+			log.Printf("email check for %s: %v", a.ID, err)
+		}
+	}
 	s.startSession(w, r, a)
 }
 
+// verified tells if the account can sync: its email is confirmed, or the
+// server sends no email and so checks no address.
+func (s *server) verified(a Account) bool {
+	return s.mail == nil || a.Verify == ""
+}
+
+// sendCheck sends the link that confirms the email address.
+func (s *server) sendCheck(email, code string) error {
+	return s.mail(email, "Confirm your email address for sbm Sync",
+		"Open this link to confirm your email address for sbm Sync:\n\n"+
+			s.site+"/verify?code="+code+"\n\n"+
+			"Sync starts after you confirm. If you did not make an account on\n"+
+			s.site+", ignore this email.\n")
+}
+
+// resend sends the email check again, with a new code.
+func (s *server) resend(w http.ResponseWriter, r *http.Request) {
+	u := s.user(r)
+	if !s.sameSite(w, r) {
+		return
+	}
+	if u == nil || s.verified(s.store.view(u)) {
+		http.Redirect(w, r, "/account", http.StatusSeeOther)
+		return
+	}
+	a := s.store.view(u)
+	if !s.limit.allow("mail "+a.ID, 5, time.Hour) {
+		s.showAccount(w, http.StatusTooManyRequests, a, "Too many emails in one hour. Try again later.")
+		return
+	}
+	code, err := s.store.newCode(u)
+	if err != nil {
+		s.fail(w, err)
+		return
+	}
+	if err := s.sendCheck(a.Email, code); err != nil {
+		log.Printf("email check for %s: %v", a.ID, err)
+		s.showAccount(w, http.StatusBadGateway, a, "The email did not go out. Try again later.")
+		return
+	}
+	http.Redirect(w, r, "/account", http.StatusSeeOther)
+}
+
+// confirm takes the link of the email check.
+func (s *server) confirm(w http.ResponseWriter, r *http.Request) {
+	a, err := s.store.confirm(r.URL.Query().Get("code"))
+	if errors.Is(err, errCode) {
+		p := s.page("Confirm your email address")
+		p.Error = "This link does not work: it is wrong, or a newer email replaced it. Sign in to send a new link."
+		s.render(w, http.StatusBadRequest, "confirmed", p)
+		return
+	} else if err != nil {
+		s.fail(w, err)
+		return
+	}
+	log.Printf("account %s: email confirmed", a.ID)
+	p := s.page("Email address confirmed")
+	p.Email = s.store.view(a).Email
+	s.render(w, http.StatusOK, "confirmed", p)
+}
+
 func (s *server) login(w http.ResponseWriter, r *http.Request) {
 	if !s.sameSite(w, r) {
 		return
@@ -313,6 +401,7 @@ func (s *server) account(w http.ResponseWriter, r *http.Request) {
 func (s *server) showAccount(w http.ResponseWriter, status int, a Account, problem string) {
 	p := s.page("Your account")
 	p.Email, p.Error, p.Customer = a.Email, problem, a.Customer
+	p.Unconfirmed = !s.verified(a)
 	left := s.trialLeft(a)
 	switch {
 	case s.stripe == nil:
@@ -521,6 +610,10 @@ func (s *server) sync(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
 		return
 	}
+	if !s.verified(s.store.view(u)) {
+		http.Error(w, "confirm your email address first: open the link in the email from sbm Sync, or see "+s.site+"/account", http.StatusForbidden)
+		return
+	}
 	if !s.active(s.store.view(u)) {
 		http.Error(w, "sbm Sync is paused for this account: see "+s.site+"/account", http.StatusPaymentRequired)
 		return
diff --git a/pages.html b/pages.html
index 31da8f4..efe47ce 100644
--- a/pages.html
+++ b/pages.html
@@ -76,6 +76,9 @@ Chrome.</p>
 <h1>Your account</h1>
 <p>Signed in as {{.Email}}.</p>
 {{with .Error}}<p class="error">{{.}}</p>{{end}}
+{{if .Unconfirmed}}<p><b>Confirm your email address.</b> Open the link in the
+email that this server sent to {{.Email}}. Sync starts after that.</p>
+<form method="post" action="/account/verify"><button>Send the email again</button></form>{{end}}
 <p><b>{{.State}}</b></p>
 {{if .CanSubscribe}}<form method="post" action="/account/checkout">
 <button name="plan" value="year">Subscribe: {{.Year}}</button>
@@ -103,13 +106,20 @@ their bookmark files.</p>
 </form>
 {{template "foot" .}}{{end}}
 
+{{define "confirmed"}}{{template "head" .}}
+<h1>{{.Title}}</h1>
+{{with .Error}}<p class="error">{{.}}</p>{{else}}<p>Sync is on for {{.Email}}.</p>{{end}}
+<p><a href="/account">Your account</a></p>
+{{template "foot" .}}{{end}}
+
 {{define "privacy"}}{{template "head" .}}
 <h1>Privacy</h1>
 <p>This policy is for this sbm Sync server, the sbm app for Android and the
 sbm add-on for Firefox and Chrome.</p>
 <h2>What the server keeps, and why</h2>
 <ul>
-<li>Your email address and a bcrypt hash of your password, never the password itself: to sign you in.</li>
+<li>Your email address and a bcrypt hash of your password, never the password itself: to sign you in.
+When this server checks email addresses, it also sends you an email with a link that confirms your address.</li>
 <li>Your bookmark file, the last 50 versions that your devices sent: to sync your devices.</li>
 <li>A hash of the sign-in token of each device: to know your devices.</li>
 <li>When you subscribe: your Stripe customer ID and the state of your subscription: to bill you.</li>
diff --git a/server_test.go b/server_test.go
index 08b35f9..197a714 100644
--- a/server_test.go
+++ b/server_test.go
@@ -4,6 +4,7 @@ import (
 	"crypto/hmac"
 	"crypto/sha256"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -12,6 +13,7 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"sync"
 	"testing"
@@ -58,6 +60,8 @@ type harness struct {
 	s      *server
 	web    *httptest.Server
 	stripe *fakeStripe
+	mu     sync.Mutex
+	mails  []string // "to\nsubject\nbody" of each email
 }
 
 func newEnv(t *testing.T, billing bool) *harness {
@@ -141,6 +145,44 @@ func (e *harness) body(c *http.Client, path string) string {
 	return string(b)
 }
 
+// withMail turns on the email check. The emails of the server go to
+// e.mails.
+func (e *harness) withMail() {
+	e.s.mail = func(to, subject, body string) error {
+		e.mu.Lock()
+		defer e.mu.Unlock()
+		e.mails = append(e.mails, to+"\n"+subject+"\n"+body)
+		return nil
+	}
+}
+
+func (e *harness) sent() int {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	return len(e.mails)
+}
+
+var checkLink = regexp.MustCompile(`http\S+/verify\?code=[0-9a-f]+`)
+
+// link gives the link in the last email.
+func (e *harness) link() string {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	if len(e.mails) == 0 {
+		e.t.Fatal("no email went out")
+	}
+	return checkLink.FindString(e.mails[len(e.mails)-1])
+}
+
+func (e *harness) open(link string) int {
+	resp, err := http.Get(link)
+	if err != nil {
+		e.t.Fatal(err)
+	}
+	resp.Body.Close()
+	return resp.StatusCode
+}
+
 func TestTwoDevicesSync(t *testing.T) {
 	e := newEnv(t, false)
 	e.signup("Me@Example.org")
@@ -413,7 +455,7 @@ func TestMoney(t *testing.T) {
 func TestStoreSurvivesRestart(t *testing.T) {
 	dir := t.TempDir()
 	s, _ := openStore(dir)
-	a, err := s.signup("me@example.org", "password1")
+	a, err := s.signup("me@example.org", "password1", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -434,7 +476,7 @@ func TestStoreSurvivesRestart(t *testing.T) {
 
 func TestOldVersionsGo(t *testing.T) {
 	s, _ := openStore(t.TempDir())
-	a, _ := s.signup("me@example.org", "password1")
+	a, _ := s.signup("me@example.org", "password1", "")
 	base := ""
 	for i := range maxVersions + 10 {
 		_, v, err := s.sync(a, base, fmt.Sprintf("line %d\n", i))
@@ -455,3 +497,69 @@ func TestOldVersionsGo(t *testing.T) {
 		t.Errorf("%d versions kept, want %d", n, maxVersions)
 	}
 }
+
+func TestEmailCheck(t *testing.T) {
+	e := newEnv(t, false)
+	e.withMail()
+	c := e.signup("Me@Example.org")
+	tok := e.token("me@example.org")
+	if e.sent() != 1 || !strings.HasPrefix(e.mails[0], "me@example.org\nConfirm your email address") {
+		t.Fatalf("emails: %q", e.mails)
+	}
+	first := e.link()
+	if code, text, _ := e.sync(tok, "", "a\n"); code != http.StatusForbidden || !strings.Contains(text, "confirm your email address") {
+		t.Fatalf("sync before the check: %d %q", code, text)
+	}
+	if !strings.Contains(e.body(c, "/account"), "Confirm your email address.") {
+		t.Error("the account page does not ask for the check")
+	}
+	// A new email replaces the link of the first one.
+	if resp := e.post(c, "/account/verify", nil); resp.StatusCode != http.StatusSeeOther || e.sent() != 2 {
+		t.Fatalf("send again: %s, %d emails", resp.Status, e.sent())
+	}
+	if code := e.open(first); code != http.StatusBadRequest {
+		t.Errorf("the replaced link: %d", code)
+	}
+	if code := e.open(e.link()); code != http.StatusOK {
+		t.Fatalf("the new link: %d", code)
+	}
+	if code, _, _ := e.sync(tok, "", "a\n"); code != http.StatusOK {
+		t.Errorf("sync after the check: %d", code)
+	}
+	if strings.Contains(e.body(c, "/account"), "Confirm your email address.") {
+		t.Error("the account page still asks for the check")
+	}
+	if code := e.open(e.link()); code != http.StatusBadRequest {
+		t.Errorf("the link works twice: %d", code)
+	}
+	e.post(c, "/account/verify", nil)
+	if e.sent() != 2 {
+		t.Error("an email went out for a confirmed address")
+	}
+}
+
+func TestEmailCheckLimit(t *testing.T) {
+	e := newEnv(t, false)
+	e.withMail()
+	c := e.signup("me@example.org")
+	for range 5 {
+		e.post(c, "/account/verify", nil)
+	}
+	if resp := e.post(c, "/account/verify", nil); resp.StatusCode != http.StatusTooManyRequests {
+		t.Errorf("the seventh email in an hour: %s", resp.Status)
+	}
+	if e.sent() != 6 {
+		t.Errorf("%d emails went out, want 6", e.sent())
+	}
+}
+
+func TestEmailThatDoesNotGoOut(t *testing.T) {
+	e := newEnv(t, false)
+	e.s.mail = func(string, string, string) error { return errors.New("no SMTP server") }
+	c := e.signup("me@example.org")
+	resp := e.post(c, "/account/verify", nil)
+	b, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(b), "The email did not go out.") {
+		t.Errorf("send again: %s", resp.Status)
+	}
+}
diff --git a/store.go b/store.go
index 04fcd11..d391200 100644
--- a/store.go
+++ b/store.go
@@ -30,6 +30,7 @@ var (
 	errPassword = errors.New("the password must have 8 to 72 characters")
 	errWrong    = errors.New("wrong email or password")
 	errGone     = errors.New("the account does not exist")
+	errCode     = errors.New("no account has this code")
 	hashName    = regexp.MustCompile(`^[0-9a-f]{64}$`)
 )
 
@@ -43,6 +44,7 @@ type Account struct {
 	Customer string    `json:"customer,omitempty"`
 	Status   string    `json:"status,omitempty"`    // of the Stripe subscription
 	StatusAt int64     `json:"status_at,omitempty"` // time of the Stripe event that set Status
+	Verify   string    `json:"verify,omitempty"`    // SHA-256 of the code that confirms the email; empty when confirmed
 }
 
 // Store keeps the accounts in one JSON file, accounts.json, and the versions
@@ -151,7 +153,9 @@ func cleanEmail(email string) (string, error) {
 	return email, nil
 }
 
-func (s *Store) signup(email, password string) (*Account, error) {
+// signup makes an account. With a code, the email of the account is not
+// confirmed until someone opens the link with that code.
+func (s *Store) signup(email, password, code string) (*Account, error) {
 	email, err := cleanEmail(email)
 	if err != nil {
 		return nil, err
@@ -169,6 +173,9 @@ func (s *Store) signup(email, password string) (*Account, error) {
 		return nil, errTaken
 	}
 	a := &Account{ID: random(16), Email: email, Hash: string(hash), Created: time.Now().UTC()}
+	if code != "" {
+		a.Verify = digest(code)
+	}
 	s.index(a)
 	if err := s.save(); err != nil {
 		delete(s.accounts, a.ID)
@@ -270,6 +277,36 @@ func (s *Store) view(a *Account) Account {
 	return c
 }
 
+// newCode gives a new code that confirms the email of the account. It
+// replaces the old code. Only its hash is kept.
+func (s *Store) newCode(a *Account) (string, error) {
+	code := random(16)
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.accounts[a.ID] == nil {
+		return "", errGone
+	}
+	a.Verify = digest(code)
+	return code, s.save()
+}
+
+// confirm marks the email of the account with the code as confirmed.
+func (s *Store) confirm(code string) (*Account, error) {
+	if code == "" {
+		return nil, errCode
+	}
+	h := digest(code)
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for _, a := range s.accounts {
+		if a.Verify == h {
+			a.Verify = ""
+			return a, s.save()
+		}
+	}
+	return nil, errCode
+}
+
 func (s *Store) setCustomer(a *Account, customer string) error {
 	s.mu.Lock()
 	defer s.mu.Unlock()