Add polished answer notifications (#1)

Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-27 06:55:24 +00:00
committed by codegirl007
parent 35c8c9f391
commit 418ef93da5
28 changed files with 827 additions and 74 deletions
+14 -1
View File
@@ -129,14 +129,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
emailRaw := r.PostFormValue("email")
password := r.PostFormValue("password")
setupSecret := r.PostFormValue("setup_secret")
p := authPage{page: s.basePage(r, "Create account"), Username: username}
p := authPage{page: s.basePage(r, "Create account"), Username: username, Email: strings.TrimSpace(emailRaw)}
if !usernameRe.MatchString(username) {
p.Error = "Username must be 320 letters, numbers, or underscores."
s.exec(w, "register", p)
return
}
email, emailErr := store.ValidateEmail(emailRaw)
if emailErr != "" {
p.Error = emailErr
s.exec(w, "register", p)
return
}
if ok, msg := passwordValid(password); !ok {
p.Error = msg
s.exec(w, "register", p)
@@ -153,6 +160,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
u := &store.User{
Username: username,
Email: email,
PasswordHash: string(hash),
Role: role,
}
@@ -162,6 +170,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
s.exec(w, "register", p)
return
}
if errors.Is(err, store.ErrDuplicateEmail) {
p.Error = "That email is already registered."
s.exec(w, "register", p)
return
}
log.Printf("register create: %v", err)
http.Error(w, "could not create account", http.StatusInternalServerError)
return
+27 -9
View File
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
@@ -27,6 +28,7 @@ type profilePage struct {
UploadsEnabled bool
Error string
StateVal string
EmailVal string
}
func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
@@ -35,7 +37,7 @@ func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
return
}
s.renderProfile(w, r, u, "", u.State)
s.renderProfile(w, r, u, "", u.State, u.Email)
}
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
@@ -45,7 +47,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
return
}
if err := r.ParseMultipartForm(3 << 20); err != nil {
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State)
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State, u.Email)
return
}
want := s.sessions.GetString(r.Context(), "csrf")
@@ -57,7 +59,12 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
state := geo.NormalizeState(r.FormValue("state"))
if !geo.ValidState(state) {
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state)
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state, r.FormValue("email"))
return
}
email, emailErr := store.ValidateEmail(r.FormValue("email"))
if emailErr != "" {
s.renderProfile(w, r, u, emailErr, state, r.FormValue("email"))
return
}
@@ -67,16 +74,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
if err == nil {
defer file.Close()
if !s.cfg.Blob.Enabled() {
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state)
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state, email)
return
}
if hdr.Size > 2<<20 {
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state)
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state, email)
return
}
body, ext, contentType, prepErr := prepareAvatar(file, 2<<20)
if prepErr != nil {
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state, email)
return
}
prevURL := u.AvatarURL
@@ -88,14 +95,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
Size: int64(len(body)),
})
if upErr != nil {
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state)
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state, email)
return
}
avatarURL = url
u.State = state
u.Email = email
u.AvatarURL = avatarURL
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
_ = s.cfg.Blob.Delete(r.Context(), avatarKey)
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
}
@@ -106,12 +118,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
} else if err != http.ErrMissingFile {
s.renderProfile(w, r, u, "Could not read avatar file.", state)
s.renderProfile(w, r, u, "Could not read avatar file.", state, email)
return
}
u.State = state
u.Email = email
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
}
@@ -236,7 +253,7 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
return dst
}
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) {
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var (
questions []store.RankedQuestion
label string
@@ -263,5 +280,6 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg,
StateVal: stateVal,
EmailVal: emailVal,
})
}
+46
View File
@@ -22,6 +22,7 @@ import (
"plumber/internal/blob"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -34,6 +35,7 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet
Blob blob.Uploader
Mail mail.Notifier
}
type Server struct {
@@ -82,6 +84,7 @@ type submitPage struct {
type authPage struct {
page
Username string
Email string
Error string
Next string
}
@@ -98,6 +101,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Blob == nil {
cfg.Blob = blob.Disabled{}
}
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
@@ -482,6 +488,17 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if len(body) > 12000 {
body = truncateRunes(body, 12000)
}
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
http.NotFound(w, r)
return
}
_, priorErr := s.store.GetAnswer(r.Context(), id)
wasNew := errors.Is(priorErr, sql.ErrNoRows)
if priorErr != nil && !wasNew {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
ans := &store.Answer{
QuestionID: id,
AuthorID: u.ID,
@@ -491,6 +508,9 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save answer", http.StatusInternalServerError)
return
}
if wasNew {
s.notifyQuestionAnswered(q, body, u.ID)
}
saved, err := s.store.GetAnswer(r.Context(), id)
if err != nil {
http.Error(w, "could not load answer", http.StatusInternalServerError)
@@ -503,6 +523,32 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
}
func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) {
if q == nil || s.cfg.Mail == nil {
return
}
author, err := s.store.UserByID(context.Background(), q.AuthorID)
if err != nil || author == nil || author.Email == "" || author.ID == adminID {
return
}
msg := mail.QuestionAnswered{
ToEmail: author.Email,
ToName: author.Name,
QuestionID: q.ID,
QuestionTitle: q.Title,
AnswerBody: answerBody,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
log.Printf("notify answer %s: %v", q.ID, err)
return
}
log.Printf("notify answer %s: accepted", q.ID)
}()
}
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
+155 -1
View File
@@ -19,6 +19,7 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -53,6 +54,7 @@ func seedUser(t *testing.T, st store.Store, username, password string, role stor
}
u := &store.User{
Username: username,
Email: username + "@example.com",
PasswordHash: string(hash),
Role: role,
}
@@ -114,7 +116,7 @@ func registerUser(t *testing.T, h http.Handler, username, password string, setup
pre := rec.Result().Cookies()
preToken := sessionValue(pre)
csrf := csrfFrom(rec.Body.String())
form := "_csrf=" + csrf + "&username=" + username + "&password=" + password
form := "_csrf=" + csrf + "&username=" + username + "&email=" + username + "%40example.com&password=" + password
if len(setupSecret) > 0 && setupSecret[0] != "" {
form += "&setup_secret=" + setupSecret[0]
}
@@ -362,6 +364,7 @@ func TestProfilePageAndState(t *testing.T) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", name+"@example.com")
_ = w.WriteField("state", "CA")
_ = w.Close()
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
@@ -389,6 +392,7 @@ func TestProfilePageAndState(t *testing.T) {
buf.Reset()
w = multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", name+"@example.com")
_ = w.WriteField("state", "ZZ")
_ = w.Close()
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
@@ -449,6 +453,7 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", hubName+"@example.com")
_ = w.WriteField("state", "OR")
part, err := w.CreateFormFile("avatar", "pic.png")
if err != nil {
@@ -596,10 +601,32 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) ||
!strings.Contains(body, "<summary>Edit answer</summary>") ||
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
!strings.Contains(body, `removeAttribute('open')`) ||
strings.Contains(body, `<details class="answer-editor" open`) {
t.Fatalf("admin answer editor is not collapsed and populated: %s", body)
}
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
t.Fatal(err)
}
// The public answer is visible to its author, but editing remains admin-only.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), `class="answer-editor"`) {
t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
}
// Hide invalid id
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
@@ -666,6 +693,133 @@ func csrfFrom(html string) string {
return html[:j]
}
func TestRegisterRequiresEmail(t *testing.T) {
srv, _ := newTestServer(t, Config{})
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
csrf := csrfFrom(rec.Body.String())
cookies := rec.Result().Cookies()
form := strings.NewReader("_csrf=" + csrf + "&username=" + uniq("noem") + "&email=bad&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid email") {
t.Fatalf("want email validation error, got %d %s", rec.Code, rec.Body.String())
}
}
func TestAnswerNotifyFirstOnly(t *testing.T) {
recMail := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recMail})
h := srv.Handler()
adminName := uniq("adm")
askName := uniq("ask")
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
asker := seedUser(t, mem, askName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22")
q := &store.RankedQuestion{
AuthorID: asker.ID,
Title: "Leaky sink",
Body: "Drip",
City: "Oakland",
HuntDate: pacific.Today(),
}
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err)
}
postAnswer := func(body string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(w, req)
csrf := csrfFrom(w.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&body=" + body)
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range adminCookies {
req.AddCookie(c)
}
w = httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("answer %d %s", w.Code, w.Body.String())
}
}
postAnswer("First+reply")
deadline := time.Now().Add(2 * time.Second)
var msgs []mail.QuestionAnswered
for time.Now().Before(deadline) {
msgs = recMail.Snapshot()
if len(msgs) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(msgs) != 1 {
t.Fatalf("first answer notifies once, got %d", len(msgs))
}
if msgs[0].ToEmail != asker.Email || msgs[0].QuestionID != q.ID {
t.Fatalf("unexpected notify: %+v", msgs[0])
}
if msgs[0].AnswerBody != "First reply" {
t.Fatalf("answer body %q", msgs[0].AnswerBody)
}
postAnswer("Edited+reply")
time.Sleep(50 * time.Millisecond)
if recMail.Len() != 1 {
t.Fatalf("edit must not notify again, got %d", recMail.Len())
}
// Author without email is skipped
recMail2 := &mail.Recording{}
srv2, mem2 := newTestServer(t, Config{Mail: recMail2})
h2 := srv2.Handler()
admin2 := seedUser(t, mem2, uniq("adm2"), "hunter22", store.RoleAdmin)
noMail := &store.User{Username: uniq("silent"), PasswordHash: admin.PasswordHash, Role: store.RoleUser, Email: ""}
hash, _ := bcrypt.GenerateFromPassword([]byte("hunter22"), bcrypt.MinCost)
noMail.PasswordHash = string(hash)
if err := mem2.CreateUser(context.Background(), noMail); err != nil {
t.Fatal(err)
}
q2 := &store.RankedQuestion{AuthorID: noMail.ID, Title: "Quiet", Body: "x", HuntDate: pacific.Today()}
if err := mem2.CreateQuestion(context.Background(), q2); err != nil {
t.Fatal(err)
}
cookies := loginUser(t, h2, admin2.Username, "hunter22")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+q2.ID, nil)
for _, c := range cookies {
req.AddCookie(c)
}
h2.ServeHTTP(w, req)
csrf := csrfFrom(w.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&body=Hello")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q2.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
w = httptest.NewRecorder()
h2.ServeHTTP(w, req)
time.Sleep(50 * time.Millisecond)
if recMail2.Len() != 0 {
t.Fatalf("empty email must skip notify, got %d", recMail2.Len())
}
}
// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
// This must go through Handler() so middleware ordering bugs are caught.