tenseleyflow/shithub / 134f212

Browse files

S10: Emails settings — list, add, resend verify, set primary, remove

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
134f21239e90436a059c64a4f4adc798b200286e
Parents
fa5aa61
Tree
c433908

6 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 5 0
M internal/web/handlers/auth/auth_test.go 8 0
A internal/web/handlers/auth/emails.go 277 0
A internal/web/handlers/auth/emails_test.go 217 0
M internal/web/static/css/shithub.css 35 0
A internal/web/templates/settings/emails.html 64 0
internal/web/handlers/auth/auth.gomodified
@@ -140,6 +140,11 @@ func (h *Handlers) Mount(r chi.Router) {
140140
 			r.Post("/settings/password", h.settingsPasswordSubmit)
141141
 			r.Get("/settings/appearance", h.settingsAppearanceForm)
142142
 			r.Post("/settings/appearance", h.settingsAppearanceSubmit)
143
+			r.Get("/settings/emails", h.settingsEmailsList)
144
+			r.Post("/settings/emails", h.settingsEmailsAdd)
145
+			r.Post("/settings/emails/{id}/resend", h.settingsEmailsResend)
146
+			r.Post("/settings/emails/{id}/primary", h.settingsEmailsSetPrimary)
147
+			r.Post("/settings/emails/{id}/remove", h.settingsEmailsRemove)
143148
 			r.Get("/settings/keys", h.sshKeysList)
144149
 			r.Post("/settings/keys", h.sshKeysAdd)
145150
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
internal/web/handlers/auth/auth_test.gomodified
@@ -56,6 +56,12 @@ func (c *captureSender) all() []email.Message {
5656
 	return out
5757
 }
5858
 
59
+func (c *captureSender) reset() {
60
+	c.mu.Lock()
61
+	defer c.mu.Unlock()
62
+	c.out = nil
63
+}
64
+
5965
 // fastArgon keeps tests under a few seconds. The full-cost defaults are
6066
 // exercised by the unit test in internal/auth/password.
6167
 var fastArgon = password.Params{Memory: 16 * 1024, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
@@ -167,6 +173,7 @@ func authTemplatesFS() fs.FS {
167173
 	//nolint:gosec // G101 false positive: HTML fixture, not a credential.
168174
 	pwTpl := `{{ define "page" }}<h1>Password</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/password" method=POST><input name=csrf_token value="{{.CSRFToken}}">RECENT={{.RecentAuthOK}};</form>{{ end }}`
169175
 	apprTpl := `{{ define "page" }}<h1>Appearance</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/appearance" method=POST><input name=csrf_token value="{{.CSRFToken}}">THEME={{.CurrentTheme}};</form>{{ end }}`
176
+	emailsTpl := `{{ define "page" }}<h1>Emails</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/emails" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>EMAILS={{ range .Emails }}{{.ID}}:{{.Email}}:p={{.IsPrimary}}:v={{.Verified}};{{ end }}{{ end }}`
170177
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
171178
 	return fstest.MapFS{
172179
 		"_layout.html":               {Data: []byte(layout)},
@@ -186,6 +193,7 @@ func authTemplatesFS() fs.FS {
186193
 		"settings/account.html":      {Data: []byte(accountTpl)},
187194
 		"settings/password.html":     {Data: []byte(pwTpl)},
188195
 		"settings/appearance.html":   {Data: []byte(apprTpl)},
196
+		"settings/emails.html":       {Data: []byte(emailsTpl)},
189197
 		"errors/404.html":            {Data: []byte(errorPage)},
190198
 		"errors/403.html":            {Data: []byte(errorPage)},
191199
 		"errors/429.html":            {Data: []byte(errorPage)},
internal/web/handlers/auth/emails.goadded
@@ -0,0 +1,277 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+	"strconv"
8
+	"strings"
9
+	"time"
10
+
11
+	"github.com/go-chi/chi/v5"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/token"
16
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+)
19
+
20
+// maxEmailsPerUser caps how many addresses a user can register. Picked
21
+// to match GitHub's posted ceiling — high enough that nobody hits it
22
+// legitimately, low enough that an abuser can't use the table as scratch
23
+// space.
24
+const maxEmailsPerUser = 10
25
+
26
+// settingsEmailsList renders GET /settings/emails.
27
+func (h *Handlers) settingsEmailsList(w http.ResponseWriter, r *http.Request) {
28
+	h.renderEmailsList(w, r, "", "")
29
+}
30
+
31
+// settingsEmailsAdd handles POST /settings/emails.
32
+func (h *Handlers) settingsEmailsAdd(w http.ResponseWriter, r *http.Request) {
33
+	if err := r.ParseForm(); err != nil {
34
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
35
+		return
36
+	}
37
+	user := middleware.CurrentUserFromContext(r.Context())
38
+	addr := strings.ToLower(strings.TrimSpace(r.PostFormValue("email")))
39
+
40
+	if !looksLikeEmail(addr) {
41
+		h.renderEmailsList(w, r, "Please enter a valid email address.", "")
42
+		return
43
+	}
44
+
45
+	count, err := h.countUserEmails(r, user.ID)
46
+	if err != nil {
47
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
48
+		return
49
+	}
50
+	if count >= maxEmailsPerUser {
51
+		h.renderEmailsList(w, r, "You've reached the per-account email cap. Remove one first.", "")
52
+		return
53
+	}
54
+
55
+	tokEnc, tokHash, err := token.New()
56
+	if err != nil {
57
+		h.d.Logger.ErrorContext(r.Context(), "emails: token", "error", err)
58
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
59
+		return
60
+	}
61
+
62
+	tx, err := h.d.Pool.Begin(r.Context())
63
+	if err != nil {
64
+		h.d.Logger.ErrorContext(r.Context(), "emails: begin", "error", err)
65
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
66
+		return
67
+	}
68
+	defer func() { _ = tx.Rollback(r.Context()) }()
69
+
70
+	em, err := h.q.CreateUserEmail(r.Context(), tx, usersdb.CreateUserEmailParams{
71
+		UserID:                user.ID,
72
+		Email:                 addr,
73
+		IsPrimary:             false,
74
+		Verified:              false,
75
+		VerificationTokenHash: tokHash,
76
+	})
77
+	if err != nil {
78
+		if isUniqueViolation(err) {
79
+			h.renderEmailsList(w, r, "That email is already registered (here or to another account).", "")
80
+			return
81
+		}
82
+		h.d.Logger.ErrorContext(r.Context(), "emails: insert", "error", err)
83
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
84
+		return
85
+	}
86
+	expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
87
+	if _, err := h.q.CreateEmailVerification(r.Context(), tx, usersdb.CreateEmailVerificationParams{
88
+		UserEmailID: em.ID, TokenHash: tokHash, ExpiresAt: expires,
89
+	}); err != nil {
90
+		h.d.Logger.ErrorContext(r.Context(), "emails: verification row", "error", err)
91
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
92
+		return
93
+	}
94
+	if err := tx.Commit(r.Context()); err != nil {
95
+		h.d.Logger.ErrorContext(r.Context(), "emails: commit", "error", err)
96
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
97
+		return
98
+	}
99
+
100
+	h.sendVerifyMessage(r, addr, user.Username, tokEnc)
101
+	h.renderEmailsList(w, r, "", "Verification link sent to "+addr+".")
102
+}
103
+
104
+// settingsEmailsResend handles POST /settings/emails/{id}/resend.
105
+func (h *Handlers) settingsEmailsResend(w http.ResponseWriter, r *http.Request) {
106
+	id, ok := h.parseEmailID(w, r)
107
+	if !ok {
108
+		return
109
+	}
110
+	user := middleware.CurrentUserFromContext(r.Context())
111
+	em, err := h.q.GetUserEmailByID(r.Context(), h.d.Pool, id)
112
+	if err != nil || em.UserID != user.ID {
113
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
114
+		return
115
+	}
116
+	if em.Verified {
117
+		h.renderEmailsList(w, r, "That address is already verified.", "")
118
+		return
119
+	}
120
+
121
+	tokEnc, tokHash, err := token.New()
122
+	if err != nil {
123
+		h.d.Logger.ErrorContext(r.Context(), "emails: resend token", "error", err)
124
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
125
+		return
126
+	}
127
+	tx, err := h.d.Pool.Begin(r.Context())
128
+	if err != nil {
129
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
130
+		return
131
+	}
132
+	defer func() { _ = tx.Rollback(r.Context()) }()
133
+	if err := h.q.SetVerificationToken(r.Context(), tx, usersdb.SetVerificationTokenParams{
134
+		ID: em.ID, VerificationTokenHash: tokHash,
135
+	}); err != nil {
136
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
137
+		return
138
+	}
139
+	if _, err := h.q.CreateEmailVerification(r.Context(), tx, usersdb.CreateEmailVerificationParams{
140
+		UserEmailID: em.ID,
141
+		TokenHash:   tokHash,
142
+		ExpiresAt:   pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true},
143
+	}); err != nil {
144
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
145
+		return
146
+	}
147
+	if err := tx.Commit(r.Context()); err != nil {
148
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
149
+		return
150
+	}
151
+
152
+	h.sendVerifyMessage(r, string(em.Email), user.Username, tokEnc)
153
+	h.renderEmailsList(w, r, "", "Verification link resent to "+string(em.Email)+".")
154
+}
155
+
156
+// settingsEmailsSetPrimary handles POST /settings/emails/{id}/primary.
157
+func (h *Handlers) settingsEmailsSetPrimary(w http.ResponseWriter, r *http.Request) {
158
+	id, ok := h.parseEmailID(w, r)
159
+	if !ok {
160
+		return
161
+	}
162
+	user := middleware.CurrentUserFromContext(r.Context())
163
+	em, err := h.q.GetUserEmailByID(r.Context(), h.d.Pool, id)
164
+	if err != nil || em.UserID != user.ID {
165
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
166
+		return
167
+	}
168
+	if !em.Verified {
169
+		h.renderEmailsList(w, r, "Verify the address before promoting it to primary.", "")
170
+		return
171
+	}
172
+	if em.IsPrimary {
173
+		h.renderEmailsList(w, r, "That address is already primary.", "")
174
+		return
175
+	}
176
+
177
+	tx, err := h.d.Pool.Begin(r.Context())
178
+	if err != nil {
179
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
180
+		return
181
+	}
182
+	defer func() { _ = tx.Rollback(r.Context()) }()
183
+	if err := h.q.SetUserEmailPrimary(r.Context(), tx, usersdb.SetUserEmailPrimaryParams{
184
+		UserID: user.ID, ID: em.ID,
185
+	}); err != nil {
186
+		h.d.Logger.ErrorContext(r.Context(), "emails: set primary", "error", err)
187
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
188
+		return
189
+	}
190
+	if err := h.q.LinkUserPrimaryEmail(r.Context(), tx, usersdb.LinkUserPrimaryEmailParams{
191
+		ID:             user.ID,
192
+		PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
193
+	}); err != nil {
194
+		h.d.Logger.ErrorContext(r.Context(), "emails: link primary", "error", err)
195
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
196
+		return
197
+	}
198
+	if err := tx.Commit(r.Context()); err != nil {
199
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
200
+		return
201
+	}
202
+	h.renderEmailsList(w, r, "", "Primary email is now "+string(em.Email)+".")
203
+}
204
+
205
+// settingsEmailsRemove handles POST /settings/emails/{id}/remove.
206
+func (h *Handlers) settingsEmailsRemove(w http.ResponseWriter, r *http.Request) {
207
+	id, ok := h.parseEmailID(w, r)
208
+	if !ok {
209
+		return
210
+	}
211
+	user := middleware.CurrentUserFromContext(r.Context())
212
+	rows, err := h.q.DeleteUserEmail(r.Context(), h.d.Pool, usersdb.DeleteUserEmailParams{
213
+		ID: id, UserID: user.ID,
214
+	})
215
+	if err != nil {
216
+		h.d.Logger.ErrorContext(r.Context(), "emails: delete", "error", err)
217
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
218
+		return
219
+	}
220
+	if rows == 0 {
221
+		h.renderEmailsList(w, r, "Couldn't remove that email — set a different primary first.", "")
222
+		return
223
+	}
224
+	h.renderEmailsList(w, r, "", "Email removed.")
225
+}
226
+
227
+// renderEmailsList is the shared render path.
228
+func (h *Handlers) renderEmailsList(w http.ResponseWriter, r *http.Request, errMsg, successMsg string) {
229
+	user := middleware.CurrentUserFromContext(r.Context())
230
+	rows, err := h.q.ListUserEmailsForUser(r.Context(), h.d.Pool, user.ID)
231
+	if err != nil {
232
+		h.d.Logger.ErrorContext(r.Context(), "emails: list", "error", err)
233
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
234
+		return
235
+	}
236
+	h.renderPage(w, r, "settings/emails", map[string]any{
237
+		"Title":          "Emails",
238
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
239
+		"SettingsActive": "emails",
240
+		"Emails":         rows,
241
+		"Error":          errMsg,
242
+		"Success":        successMsg,
243
+	})
244
+}
245
+
246
+// parseEmailID extracts and validates the {id} route param. On failure
247
+// writes a 404 and returns ok=false.
248
+func (h *Handlers) parseEmailID(w http.ResponseWriter, r *http.Request) (int64, bool) {
249
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
250
+	if err != nil {
251
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
252
+		return 0, false
253
+	}
254
+	return id, true
255
+}
256
+
257
+func (h *Handlers) countUserEmails(r *http.Request, userID int64) (int, error) {
258
+	rows, err := h.q.ListUserEmailsForUser(r.Context(), h.d.Pool, userID)
259
+	if err != nil {
260
+		h.d.Logger.ErrorContext(r.Context(), "emails: count", "error", err)
261
+		return 0, err
262
+	}
263
+	return len(rows), nil
264
+}
265
+
266
+// sendVerifyMessage is best-effort: failures don't break the flow but
267
+// are logged so the operator can chase delivery issues.
268
+func (h *Handlers) sendVerifyMessage(r *http.Request, addr, username, tokEnc string) {
269
+	msg, err := email.VerifyMessage(h.d.Branding, addr, username, tokEnc)
270
+	if err != nil {
271
+		h.d.Logger.WarnContext(r.Context(), "emails: build verify msg", "error", err)
272
+		return
273
+	}
274
+	if err := h.d.Email.Send(r.Context(), msg); err != nil {
275
+		h.d.Logger.WarnContext(r.Context(), "emails: send verify msg", "error", err)
276
+	}
277
+}
internal/web/handlers/auth/emails_test.goadded
@@ -0,0 +1,217 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"io"
7
+	"net/http"
8
+	"net/http/httptest"
9
+	"net/url"
10
+	"regexp"
11
+	"strings"
12
+	"testing"
13
+)
14
+
15
+// loginEmailsUser returns an authenticated client AND the captureSender
16
+// so tests can pull verification tokens out of newly-sent emails.
17
+func loginEmailsUser(t *testing.T, name string) (*client, *httptest.Server, *captureSender) {
18
+	t.Helper()
19
+	httpsrv, captor := newTestServer(t, false)
20
+	cli := newClient(t, httpsrv)
21
+	mustSignup(t, cli, name, name+"@example.com", "correct horse battery staple")
22
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
23
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
24
+
25
+	csrf := cli.extractCSRF(t, "/login")
26
+	resp := cli.post(t, "/login", url.Values{
27
+		"csrf_token": {csrf},
28
+		"username":   {name},
29
+		"password":   {"correct horse battery staple"},
30
+	})
31
+	if resp.StatusCode != http.StatusSeeOther {
32
+		t.Fatalf("login: %d", resp.StatusCode)
33
+	}
34
+	_ = resp.Body.Close()
35
+	return cli, httpsrv, captor
36
+}
37
+
38
+// extractEmailIDFromList scans the test fixture's "EMAILS=…" payload for
39
+// the row whose address matches addr and returns its ID.
40
+//
41
+// Fixture format:  ID:address:p=<bool>:v=<bool>;…
42
+var emailRowRE = regexp.MustCompile(`(\d+):([^:]+):p=(true|false):v=(true|false);`)
43
+
44
+func extractEmailID(t *testing.T, body, addr string) int64 {
45
+	t.Helper()
46
+	for _, m := range emailRowRE.FindAllStringSubmatch(body, -1) {
47
+		if m[2] == addr {
48
+			var id int64
49
+			for _, c := range m[1] {
50
+				id = id*10 + int64(c-'0')
51
+			}
52
+			return id
53
+		}
54
+	}
55
+	t.Fatalf("no email row for %q in body: %s", addr, body)
56
+	return 0
57
+}
58
+
59
+func TestEmails_ListShowsPrimary(t *testing.T) {
60
+	t.Parallel()
61
+	cli, _, _ := loginEmailsUser(t, "ema")
62
+	resp := cli.get(t, "/settings/emails")
63
+	defer func() { _ = resp.Body.Close() }()
64
+	body, _ := io.ReadAll(resp.Body)
65
+	if !strings.Contains(string(body), "ema@example.com:p=true:v=true") {
66
+		t.Fatalf("expected primary verified row, got: %s", body)
67
+	}
68
+}
69
+
70
+func TestEmails_AddSendsVerify(t *testing.T) {
71
+	t.Parallel()
72
+	cli, _, captor := loginEmailsUser(t, "emb")
73
+	captor.reset()
74
+
75
+	csrf := cli.extractCSRF(t, "/settings/emails")
76
+	resp := cli.post(t, "/settings/emails", url.Values{
77
+		"csrf_token": {csrf},
78
+		"email":      {"emb-second@example.com"},
79
+	})
80
+	defer func() { _ = resp.Body.Close() }()
81
+	body, _ := io.ReadAll(resp.Body)
82
+	if !strings.Contains(string(body), "Verification link sent") {
83
+		t.Fatalf("expected success, got: %s", body)
84
+	}
85
+	if !strings.Contains(string(body), "emb-second@example.com:p=false:v=false") {
86
+		t.Fatalf("new email row missing or wrong shape: %s", body)
87
+	}
88
+	if len(captor.all()) == 0 {
89
+		t.Fatalf("expected captor to have a verify email")
90
+	}
91
+}
92
+
93
+func TestEmails_AddRejectsBad(t *testing.T) {
94
+	t.Parallel()
95
+	cli, _, _ := loginEmailsUser(t, "emc")
96
+	csrf := cli.extractCSRF(t, "/settings/emails")
97
+	resp := cli.post(t, "/settings/emails", url.Values{
98
+		"csrf_token": {csrf},
99
+		"email":      {"not-an-email"},
100
+	})
101
+	defer func() { _ = resp.Body.Close() }()
102
+	body, _ := io.ReadAll(resp.Body)
103
+	if !strings.Contains(string(body), "valid email") {
104
+		t.Fatalf("expected validation, got: %s", body)
105
+	}
106
+}
107
+
108
+func TestEmails_SetPrimaryRequiresVerified(t *testing.T) {
109
+	t.Parallel()
110
+	cli, _, _ := loginEmailsUser(t, "emd")
111
+	csrf := cli.extractCSRF(t, "/settings/emails")
112
+	resp := cli.post(t, "/settings/emails", url.Values{
113
+		"csrf_token": {csrf},
114
+		"email":      {"emd-second@example.com"},
115
+	})
116
+	body, _ := io.ReadAll(resp.Body)
117
+	_ = resp.Body.Close()
118
+
119
+	id := extractEmailID(t, string(body), "emd-second@example.com")
120
+	csrf = cli.extractCSRF(t, "/settings/emails")
121
+	resp = cli.post(t, "/settings/emails/"+itoa(id)+"/primary", url.Values{
122
+		"csrf_token": {csrf},
123
+	})
124
+	defer func() { _ = resp.Body.Close() }()
125
+	body, _ = io.ReadAll(resp.Body)
126
+	if !strings.Contains(string(body), "Verify the address") {
127
+		t.Fatalf("expected verify-required error, got: %s", body)
128
+	}
129
+}
130
+
131
+func TestEmails_RoundtripVerifySetPrimaryRemove(t *testing.T) {
132
+	t.Parallel()
133
+	cli, _, captor := loginEmailsUser(t, "eme")
134
+	captor.reset()
135
+
136
+	// 1. Add a second address.
137
+	csrf := cli.extractCSRF(t, "/settings/emails")
138
+	resp := cli.post(t, "/settings/emails", url.Values{
139
+		"csrf_token": {csrf},
140
+		"email":      {"eme-second@example.com"},
141
+	})
142
+	body, _ := io.ReadAll(resp.Body)
143
+	_ = resp.Body.Close()
144
+	id := extractEmailID(t, string(body), "eme-second@example.com")
145
+
146
+	// 2. Verify via the link in the captured email.
147
+	if len(captor.all()) == 0 {
148
+		t.Fatalf("expected verify email; none captured")
149
+	}
150
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
151
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
152
+
153
+	// 3. Promote it to primary.
154
+	csrf = cli.extractCSRF(t, "/settings/emails")
155
+	resp = cli.post(t, "/settings/emails/"+itoa(id)+"/primary", url.Values{
156
+		"csrf_token": {csrf},
157
+	})
158
+	body, _ = io.ReadAll(resp.Body)
159
+	_ = resp.Body.Close()
160
+	if !strings.Contains(string(body), "Primary email is now eme-second@example.com") {
161
+		t.Fatalf("expected primary-set success, got: %s", body)
162
+	}
163
+	if !strings.Contains(string(body), "eme-second@example.com:p=true:v=true") {
164
+		t.Fatalf("expected new row to be primary+verified: %s", body)
165
+	}
166
+
167
+	// 4. Now the original address can be removed (no longer primary).
168
+	origID := extractEmailID(t, string(body), "eme@example.com")
169
+	csrf = cli.extractCSRF(t, "/settings/emails")
170
+	resp = cli.post(t, "/settings/emails/"+itoa(origID)+"/remove", url.Values{
171
+		"csrf_token": {csrf},
172
+	})
173
+	defer func() { _ = resp.Body.Close() }()
174
+	body, _ = io.ReadAll(resp.Body)
175
+	if !strings.Contains(string(body), "Email removed") {
176
+		t.Fatalf("expected remove success, got: %s", body)
177
+	}
178
+	if strings.Contains(string(body), "eme@example.com:") {
179
+		t.Fatalf("removed email still listed: %s", body)
180
+	}
181
+}
182
+
183
+func TestEmails_CannotRemovePrimary(t *testing.T) {
184
+	t.Parallel()
185
+	cli, _, _ := loginEmailsUser(t, "emf")
186
+
187
+	// Find the (only) primary row's id.
188
+	resp := cli.get(t, "/settings/emails")
189
+	body, _ := io.ReadAll(resp.Body)
190
+	_ = resp.Body.Close()
191
+	id := extractEmailID(t, string(body), "emf@example.com")
192
+
193
+	csrf := cli.extractCSRF(t, "/settings/emails")
194
+	resp = cli.post(t, "/settings/emails/"+itoa(id)+"/remove", url.Values{
195
+		"csrf_token": {csrf},
196
+	})
197
+	defer func() { _ = resp.Body.Close() }()
198
+	body, _ = io.ReadAll(resp.Body)
199
+	if !strings.Contains(string(body), "set a different primary first") {
200
+		t.Fatalf("expected primary-protected error, got: %s", body)
201
+	}
202
+}
203
+
204
+// itoa avoids strconv to keep imports tight.
205
+func itoa(n int64) string {
206
+	if n == 0 {
207
+		return "0"
208
+	}
209
+	var buf [20]byte
210
+	i := len(buf)
211
+	for n > 0 {
212
+		i--
213
+		buf[i] = byte('0' + n%10)
214
+		n /= 10
215
+	}
216
+	return string(buf[i:])
217
+}
internal/web/static/css/shithub.cssmodified
@@ -580,3 +580,38 @@ code {
580580
   font-size: 0.8rem;
581581
   color: var(--fg-muted);
582582
 }
583
+
584
+/* ----- emails (S10) ----- */
585
+.shithub-email-list {
586
+  list-style: none;
587
+  padding: 0;
588
+  margin: 1rem 0 0;
589
+  display: grid;
590
+  gap: 0.5rem;
591
+}
592
+.shithub-email-row {
593
+  display: flex;
594
+  justify-content: space-between;
595
+  align-items: center;
596
+  gap: 1rem;
597
+  padding: 0.75rem 1rem;
598
+  border: 1px solid var(--border-default);
599
+  border-radius: 6px;
600
+  background: var(--canvas-subtle);
601
+}
602
+.shithub-email-meta { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
603
+.shithub-email-addr { background: transparent; padding: 0; font-weight: 500; }
604
+.shithub-email-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
605
+.shithub-email-actions form { display: inline; }
606
+.shithub-pill {
607
+  font-size: 0.7rem;
608
+  padding: 0.1rem 0.5rem;
609
+  border-radius: 999px;
610
+  border: 1px solid var(--border-default);
611
+  text-transform: uppercase;
612
+  letter-spacing: 0.04em;
613
+  font-weight: 600;
614
+}
615
+.shithub-pill-primary { background: rgba(56, 139, 253, 0.15); border-color: rgba(56, 139, 253, 0.4); }
616
+.shithub-pill-verified { background: rgba(63, 185, 80, 0.15); border-color: rgba(63, 185, 80, 0.4); }
617
+.shithub-pill-unverified { background: rgba(187, 128, 9, 0.15); border-color: rgba(187, 128, 9, 0.4); }
internal/web/templates/settings/emails.htmladded
@@ -0,0 +1,64 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Emails</h1>
6
+
7
+    {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
8
+    {{ with .Success }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
9
+
10
+    <section class="shithub-settings-section">
11
+      <h2>Your email addresses</h2>
12
+      <p>Your <strong>primary</strong> email is where account-related and security notifications go. You must verify each address before it can become primary.</p>
13
+
14
+      {{ if .Emails }}
15
+      <ul class="shithub-email-list">
16
+        {{ range .Emails }}
17
+        <li class="shithub-email-row">
18
+          <div class="shithub-email-meta">
19
+            <code class="shithub-email-addr">{{ .Email }}</code>
20
+            {{ if .IsPrimary }}<span class="shithub-pill shithub-pill-primary">primary</span>{{ end }}
21
+            {{ if .Verified }}<span class="shithub-pill shithub-pill-verified">verified</span>
22
+            {{ else }}<span class="shithub-pill shithub-pill-unverified">unverified</span>{{ end }}
23
+          </div>
24
+          <div class="shithub-email-actions">
25
+            {{ if and (not .Verified) (not .IsPrimary) }}
26
+            <form method="POST" action="/settings/emails/{{ .ID }}/resend" novalidate>
27
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
28
+              <button type="submit" class="shithub-button">Resend verification</button>
29
+            </form>
30
+            {{ end }}
31
+            {{ if and .Verified (not .IsPrimary) }}
32
+            <form method="POST" action="/settings/emails/{{ .ID }}/primary" novalidate>
33
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
34
+              <button type="submit" class="shithub-button">Make primary</button>
35
+            </form>
36
+            {{ end }}
37
+            {{ if not .IsPrimary }}
38
+            <form method="POST" action="/settings/emails/{{ .ID }}/remove" novalidate>
39
+              <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
40
+              <button type="submit" class="shithub-button shithub-button-danger">Remove</button>
41
+            </form>
42
+            {{ end }}
43
+          </div>
44
+        </li>
45
+        {{ end }}
46
+      </ul>
47
+      {{ end }}
48
+    </section>
49
+
50
+    <section class="shithub-settings-section">
51
+      <h2>Add an email address</h2>
52
+      <form method="POST" action="/settings/emails" novalidate>
53
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
54
+        <label>
55
+          <span>Email address</span>
56
+          <input type="email" name="email" required autocomplete="email">
57
+          <small>We'll send a verification link to this address. It won't appear publicly until you also opt in (post-MVP).</small>
58
+        </label>
59
+        <button type="submit" class="shithub-button shithub-button-primary">Add</button>
60
+      </form>
61
+    </section>
62
+  </div>
63
+</div>
64
+{{- end }}