diff --git a/.env.example b/.env.example
index 71f6ffc..61c32b2 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,11 @@ SECURE_COOKIE=0
# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
# (direct peer must match). Leave unset to ignore XFF and use RemoteAddr.
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16
+# Resend (answer notifications). Leave RESEND_API_KEY unset to disable.
+# RESEND_API_KEY=re_xxxxxxxxx
+# RESEND_FROM=Ask a Plumber
+# Public site origin used in email links (required when Resend is enabled):
+# APP_BASE_URL=https://askaplumber.example
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
# SPACES_KEY=
# SPACES_SECRET=
diff --git a/cmd/server/main.go b/cmd/server/main.go
index ec20f60..28e6e17 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -17,6 +17,7 @@ import (
"plumber"
"plumber/internal/blob"
+ "plumber/internal/mail"
"plumber/internal/store"
"plumber/internal/web"
)
@@ -29,7 +30,11 @@ func main() {
defer sessions.Close()
uploader := blob.FromEnv()
- handler := newHandler(db, sessions, uploader)
+ notifier, err := mail.FromEnv()
+ if err != nil {
+ log.Fatalf("mail: %v", err)
+ }
+ handler := newHandler(db, sessions, uploader, notifier)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -53,12 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
-func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler {
+func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader,
+ Mail: notifier,
})
if err != nil {
log.Fatalf("server: %v", err)
diff --git a/db/queries/users.sql b/db/queries/users.sql
index 02e4271..74edc5f 100644
--- a/db/queries/users.sql
+++ b/db/queries/users.sql
@@ -1,24 +1,25 @@
-- name: CreateUser :exec
-INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
-VALUES ($1, $2, $3, $4, $5, '', '', $6);
+INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
+VALUES ($1, $2, $3, $4, $5, $6, '', '', $7);
-- name: GetUserByID :one
-SELECT id, username, name, role, avatar_url, state, created_at
+SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE id = $1;
-- name: GetUserByUsername :one
-SELECT id, username, name, role, avatar_url, state, created_at, password_hash
+SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
FROM users
WHERE username = $1;
-- name: ListUsers :many
-SELECT id, username, name, role, avatar_url, state, created_at
+SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE (
sqlc.arg(search) = ''
OR username ILIKE '%' || sqlc.arg(search) || '%'
OR name ILIKE '%' || sqlc.arg(search) || '%'
+ OR email ILIKE '%' || sqlc.arg(search) || '%'
)
AND (
sqlc.arg(cursor_created) = ''
@@ -43,12 +44,12 @@ UPDATE users
SET role = $1
WHERE id = $2;
--- name: UpdateUserState :exec
+-- name: UpdateUserProfile :exec
UPDATE users
-SET state = $1
-WHERE id = $2;
-
--- name: UpdateUserStateAndAvatar :exec
-UPDATE users
-SET state = $1, avatar_url = $2
+SET state = $1, email = $2
WHERE id = $3;
+
+-- name: UpdateUserProfileAndAvatar :exec
+UPDATE users
+SET state = $1, email = $2, avatar_url = $3
+WHERE id = $4;
diff --git a/go.mod b/go.mod
index ccba0df..2ff2b4b 100644
--- a/go.mod
+++ b/go.mod
@@ -28,6 +28,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/resend/resend-go/v3 v3.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
diff --git a/go.sum b/go.sum
index 0a8c3d7..479e538 100644
--- a/go.sum
+++ b/go.sum
@@ -43,6 +43,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/resend/resend-go/v3 v3.16.0 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE=
+github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
diff --git a/internal/mail/mail.go b/internal/mail/mail.go
new file mode 100644
index 0000000..86084d6
--- /dev/null
+++ b/internal/mail/mail.go
@@ -0,0 +1,189 @@
+package mail
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "html"
+ "os"
+ "strings"
+
+ "github.com/resend/resend-go/v3"
+)
+
+//go:embed mark.png
+var markPNG []byte
+
+// QuestionAnswered is the payload for notifying a question author of a reply.
+type QuestionAnswered struct {
+ ToEmail string
+ ToName string
+ QuestionID string
+ QuestionTitle string
+ AnswerBody string
+}
+
+// Notifier sends transactional email about answered questions.
+type Notifier interface {
+ NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error
+}
+
+// Nop is a no-op Notifier used when Resend is not configured.
+type Nop struct{}
+
+func (Nop) NotifyQuestionAnswered(context.Context, QuestionAnswered) error { return nil }
+
+// Resend sends via the Resend HTTP API.
+type Resend struct {
+ client *resend.Client
+ from string
+ baseURL string
+}
+
+// FromEnv builds a Notifier from RESEND_* and APP_BASE_URL.
+// Returns Nop when RESEND_API_KEY is unset.
+func FromEnv() (Notifier, error) {
+ key := strings.TrimSpace(os.Getenv("RESEND_API_KEY"))
+ if key == "" {
+ return Nop{}, nil
+ }
+ from := strings.TrimSpace(os.Getenv("RESEND_FROM"))
+ base := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
+ if from == "" {
+ return nil, fmt.Errorf("RESEND_FROM is required when RESEND_API_KEY is set")
+ }
+ if base == "" {
+ return nil, fmt.Errorf("APP_BASE_URL is required when RESEND_API_KEY is set")
+ }
+ return &Resend{
+ client: resend.NewClient(key),
+ from: from,
+ baseURL: base,
+ }, nil
+}
+
+func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
+ if r == nil || r.client == nil {
+ return nil
+ }
+ to := strings.TrimSpace(msg.ToEmail)
+ if to == "" {
+ return nil
+ }
+ text, htmlBody := questionAnsweredContent(r.baseURL, msg)
+ params := &resend.SendEmailRequest{
+ From: r.from,
+ To: []string{to},
+ Subject: "Your question was answered",
+ Text: text,
+ Html: htmlBody,
+ Attachments: []*resend.Attachment{{
+ Content: markPNG,
+ Filename: "ask-a-plumber-first.png",
+ ContentType: "image/png",
+ ContentId: "answer-notification-mark",
+ }},
+ }
+ opts := &resend.SendEmailOptions{
+ IdempotencyKey: "answer-notify:" + msg.QuestionID,
+ }
+ _, err := r.client.Emails.SendWithOptions(ctx, params, opts)
+ return err
+}
+
+func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) {
+ link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID
+ title := strings.TrimSpace(msg.QuestionTitle)
+ if title == "" {
+ title = "your question"
+ }
+ text := fmt.Sprintf(
+ "Hi%s,\n\nYour question %q has an answer from a plumber:\n\n%s\n\nView it here:\n%s\n",
+ greetingName(msg.ToName),
+ title,
+ msg.AnswerBody,
+ link,
+ )
+ htmlBody := strings.NewReplacer(
+ "{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
+ "{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
+ "{{TITLE}}", html.EscapeString(title),
+ "{{ANSWER}}", html.EscapeString(msg.AnswerBody),
+ "{{LINK}}", html.EscapeString(link),
+ "{{MARK}}", "cid:answer-notification-mark",
+ ).Replace(questionAnsweredHTML)
+ return text, htmlBody
+}
+
+const questionAnsweredHTML = `
+
+
+
+
+
+
+ Your question was answered
+
+
+ {{PREHEADER}}
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Ask a Plumber First
+ Bay Area · Shop Dispatch
+ |
+
+
+ |
+
+
+ |
+ Shop response
+ Your question has an answer.
+ Hi{{GREETING}}, a plumber replied to:
+ “{{TITLE}}”
+
+
+ |
+ The answer
+ {{ANSWER}}
+ |
+
+
+
+ |
+
+
+ |
+ You received this because you asked a question on Ask a Plumber First.
+ |
+
+
+ |
+
+
+
+`
+
+func greetingName(name string) string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return ""
+ }
+ return " " + name
+}
diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go
new file mode 100644
index 0000000..624505c
--- /dev/null
+++ b/internal/mail/mail_test.go
@@ -0,0 +1,76 @@
+package mail
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestEmbeddedMarkIsPNG(t *testing.T) {
+ t.Parallel()
+
+ if !bytes.HasPrefix(markPNG, []byte("\x89PNG\r\n\x1a\n")) {
+ t.Fatal("embedded mark is not PNG data")
+ }
+}
+
+func TestQuestionAnsweredContent(t *testing.T) {
+ t.Parallel()
+
+ text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{
+ ToName: ``,
+ QuestionID: "question-123",
+ QuestionTitle: `Leaky sink`,
+ AnswerBody: "Replace the cartridge.\nThen test the handle. ",
+ })
+
+ for _, want := range []string{
+ "Ask a Plumber First",
+ "Shop response",
+ "cid:answer-notification-mark",
+ "https://plumber.example/questions/question-123",
+ "white-space:pre-wrap",
+ "<Sam & Pat>",
+ "<b>Leaky sink</b>",
+ "<script>alert('x')</script>",
+ } {
+ if !strings.Contains(htmlBody, want) {
+ t.Errorf("HTML missing %q", want)
+ }
+ }
+ for _, unsafe := range []string{
+ "",
+ "Leaky sink",
+ "",
+ } {
+ if strings.Contains(htmlBody, unsafe) {
+ t.Errorf("HTML contains unescaped content %q", unsafe)
+ }
+ }
+ if strings.Contains(htmlBody, "{{") {
+ t.Error("HTML contains an unresolved template token")
+ }
+ for _, want := range []string{
+ `Hi ,`,
+ `Your question "Leaky sink"`,
+ "Replace the cartridge.\nThen test the handle.",
+ "https://plumber.example/questions/question-123",
+ } {
+ if !strings.Contains(text, want) {
+ t.Errorf("text missing %q", want)
+ }
+ }
+}
+
+func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) {
+ t.Parallel()
+
+ text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
+ if !strings.Contains(text, `"your question"`) {
+ t.Errorf("text missing fallback title")
+ }
+ if !strings.Contains(htmlBody, "a plumber replied to:
") ||
+ !strings.Contains(htmlBody, "“your question”") {
+ t.Errorf("HTML missing fallback title")
+ }
+}
diff --git a/internal/mail/mark.png b/internal/mail/mark.png
new file mode 100644
index 0000000..59feb94
Binary files /dev/null and b/internal/mail/mark.png differ
diff --git a/internal/mail/recording.go b/internal/mail/recording.go
new file mode 100644
index 0000000..188eac8
--- /dev/null
+++ b/internal/mail/recording.go
@@ -0,0 +1,34 @@
+package mail
+
+import (
+ "context"
+ "sync"
+)
+
+// Recording is a test Notifier that records calls.
+type Recording struct {
+ mu sync.Mutex
+ Msgs []QuestionAnswered
+}
+
+func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.Msgs = append(r.Msgs, msg)
+ return nil
+}
+
+func (r *Recording) Len() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return len(r.Msgs)
+}
+
+// Snapshot returns a copy of recorded messages.
+func (r *Recording) Snapshot() []QuestionAnswered {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := make([]QuestionAnswered, len(r.Msgs))
+ copy(out, r.Msgs)
+ return out
+}
diff --git a/internal/store/email.go b/internal/store/email.go
new file mode 100644
index 0000000..cf95199
--- /dev/null
+++ b/internal/store/email.go
@@ -0,0 +1,52 @@
+package store
+
+import (
+ "fmt"
+ "net/mail"
+ "strings"
+ "unicode/utf8"
+)
+
+const (
+ minEmailLen = 3
+ maxEmailLen = 254
+)
+
+// NormalizeEmail trims and lowercases an address for storage/comparison.
+func NormalizeEmail(s string) string {
+ return strings.ToLower(strings.TrimSpace(s))
+}
+
+// ValidateEmail returns a normalized address or an error message suitable for UI.
+func ValidateEmail(raw string) (normalized string, errMsg string) {
+ normalized = NormalizeEmail(raw)
+ if normalized == "" {
+ return "", "Email is required."
+ }
+ n := utf8.RuneCountInString(normalized)
+ if n < minEmailLen || len(normalized) > maxEmailLen {
+ return "", "Enter a valid email address."
+ }
+ addr, err := mail.ParseAddress(normalized)
+ if err != nil || addr.Address != normalized {
+ return "", "Enter a valid email address."
+ }
+ at := strings.LastIndex(normalized, "@")
+ if at < 1 || at == len(normalized)-1 {
+ return "", "Enter a valid email address."
+ }
+ domain := normalized[at+1:]
+ if !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
+ return "", "Enter a valid email address."
+ }
+ return normalized, ""
+}
+
+// MustValidateEmail is like ValidateEmail but returns a Go error.
+func MustValidateEmail(raw string) (string, error) {
+ n, msg := ValidateEmail(raw)
+ if msg != "" {
+ return "", fmt.Errorf("%s", msg)
+ }
+ return n, nil
+}
diff --git a/internal/store/email_test.go b/internal/store/email_test.go
new file mode 100644
index 0000000..83f7505
--- /dev/null
+++ b/internal/store/email_test.go
@@ -0,0 +1,21 @@
+package store
+
+import "testing"
+
+func TestValidateEmail(t *testing.T) {
+ cases := []struct {
+ in, want, err string
+ }{
+ {"", "", "Email is required."},
+ {" Alice@Example.COM ", "alice@example.com", ""},
+ {"not-an-email", "", "Enter a valid email address."},
+ {"a@b", "", "Enter a valid email address."},
+ {"ok@example.com", "ok@example.com", ""},
+ }
+ for _, tc := range cases {
+ got, msg := ValidateEmail(tc.in)
+ if got != tc.want || msg != tc.err {
+ t.Fatalf("%q: got (%q, %q) want (%q, %q)", tc.in, got, msg, tc.want, tc.err)
+ }
+ }
+}
diff --git a/internal/store/memory.go b/internal/store/memory.go
index 93e6280..60e3170 100644
--- a/internal/store/memory.go
+++ b/internal/store/memory.go
@@ -42,9 +42,17 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
+ u.Email = NormalizeEmail(u.Email)
if _, ok := m.byName[u.Username]; ok {
return ErrDuplicateUsername
}
+ if u.Email != "" {
+ for _, existing := range m.users {
+ if existing.Email == u.Email {
+ return ErrDuplicateEmail
+ }
+ }
+ }
if u.ID == "" {
u.ID = uuid.NewString()
}
@@ -179,11 +187,21 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
if !ok {
return sql.ErrNoRows
}
+ email := NormalizeEmail(u.Email)
+ if email != "" {
+ for id, existing := range m.users {
+ if id != u.ID && existing.Email == email {
+ return ErrDuplicateEmail
+ }
+ }
+ }
cur.State = strings.TrimSpace(u.State)
+ cur.Email = email
if u.AvatarURL != "" {
cur.AvatarURL = u.AvatarURL
}
u.State = cur.State
+ u.Email = cur.Email
u.AvatarURL = cur.AvatarURL
return nil
}
diff --git a/internal/store/migrate.go b/internal/store/migrate.go
index abbe1b4..ce91c2f 100644
--- a/internal/store/migrate.go
+++ b/internal/store/migrate.go
@@ -21,6 +21,20 @@ func migrateUserProfileColumns(ctx context.Context, exec execContext) error {
return nil
}
+// migrateUserEmail adds email and a partial unique index on lower(email).
+func migrateUserEmail(ctx context.Context, exec execContext) error {
+ if _, err := exec.ExecContext(ctx, `ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT ''`); err != nil {
+ return fmt.Errorf("add column email: %w", err)
+ }
+ if _, err := exec.ExecContext(ctx, `
+CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
+ ON users (lower(email))
+ WHERE email <> ''`); err != nil {
+ return fmt.Errorf("users_email_lower_uidx: %w", err)
+ }
+ return nil
+}
+
type execContext interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
@@ -66,6 +80,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
return applySchema(ctx, exec, schemaSQL)
}},
{"002_user_profile_columns", migrateUserProfileColumns},
+ {"003_user_email", migrateUserEmail},
}
for _, m := range migrations {
if applied[m.version] {
diff --git a/internal/store/postgres_store.go b/internal/store/postgres_store.go
index f0f92a4..a67d906 100644
--- a/internal/store/postgres_store.go
+++ b/internal/store/postgres_store.go
@@ -29,6 +29,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
+ u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = uuid.NewString()
}
@@ -69,6 +70,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
Name: u.Name,
PasswordHash: u.PasswordHash,
Role: string(role),
+ Email: u.Email,
CreatedAt: u.CreatedAt,
}); err != nil {
return mapUniqueViolation(err)
diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go
index 7de647f..5fdb986 100644
--- a/internal/store/sqlc/models.go
+++ b/internal/store/sqlc/models.go
@@ -39,6 +39,7 @@ type User struct {
Name string
PasswordHash string
Role string
+ Email string
AvatarUrl string
State string
CreatedAt string
diff --git a/internal/store/sqlc/users.sql.go b/internal/store/sqlc/users.sql.go
index ffa9085..f7dae6f 100644
--- a/internal/store/sqlc/users.sql.go
+++ b/internal/store/sqlc/users.sql.go
@@ -24,8 +24,8 @@ func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
}
const createUser = `-- name: CreateUser :exec
-INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
-VALUES ($1, $2, $3, $4, $5, '', '', $6)
+INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
+VALUES ($1, $2, $3, $4, $5, $6, '', '', $7)
`
type CreateUserParams struct {
@@ -34,6 +34,7 @@ type CreateUserParams struct {
Name string
PasswordHash string
Role string
+ Email string
CreatedAt string
}
@@ -44,13 +45,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
arg.Name,
arg.PasswordHash,
arg.Role,
+ arg.Email,
arg.CreatedAt,
)
return err
}
const getUserByID = `-- name: GetUserByID :one
-SELECT id, username, name, role, avatar_url, state, created_at
+SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE id = $1
`
@@ -60,6 +62,7 @@ type GetUserByIDRow struct {
Username string
Name string
Role string
+ Email string
AvatarUrl string
State string
CreatedAt string
@@ -73,6 +76,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
&i.Username,
&i.Name,
&i.Role,
+ &i.Email,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
@@ -81,7 +85,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
}
const getUserByUsername = `-- name: GetUserByUsername :one
-SELECT id, username, name, role, avatar_url, state, created_at, password_hash
+SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
FROM users
WHERE username = $1
`
@@ -91,6 +95,7 @@ type GetUserByUsernameRow struct {
Username string
Name string
Role string
+ Email string
AvatarUrl string
State string
CreatedAt string
@@ -105,6 +110,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
&i.Username,
&i.Name,
&i.Role,
+ &i.Email,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
@@ -127,12 +133,13 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
}
const listUsers = `-- name: ListUsers :many
-SELECT id, username, name, role, avatar_url, state, created_at
+SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE (
$1 = ''
OR username ILIKE '%' || $1 || '%'
OR name ILIKE '%' || $1 || '%'
+ OR email ILIKE '%' || $1 || '%'
)
AND (
$2 = ''
@@ -155,6 +162,7 @@ type ListUsersRow struct {
Username string
Name string
Role string
+ Email string
AvatarUrl string
State string
CreatedAt string
@@ -179,6 +187,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
&i.Username,
&i.Name,
&i.Role,
+ &i.Email,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
@@ -196,6 +205,46 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
return items, nil
}
+const updateUserProfile = `-- name: UpdateUserProfile :exec
+UPDATE users
+SET state = $1, email = $2
+WHERE id = $3
+`
+
+type UpdateUserProfileParams struct {
+ State string
+ Email string
+ ID string
+}
+
+func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
+ _, err := q.db.ExecContext(ctx, updateUserProfile, arg.State, arg.Email, arg.ID)
+ return err
+}
+
+const updateUserProfileAndAvatar = `-- name: UpdateUserProfileAndAvatar :exec
+UPDATE users
+SET state = $1, email = $2, avatar_url = $3
+WHERE id = $4
+`
+
+type UpdateUserProfileAndAvatarParams struct {
+ State string
+ Email string
+ AvatarUrl string
+ ID string
+}
+
+func (q *Queries) UpdateUserProfileAndAvatar(ctx context.Context, arg UpdateUserProfileAndAvatarParams) error {
+ _, err := q.db.ExecContext(ctx, updateUserProfileAndAvatar,
+ arg.State,
+ arg.Email,
+ arg.AvatarUrl,
+ arg.ID,
+ )
+ return err
+}
+
const updateUserRole = `-- name: UpdateUserRole :execresult
UPDATE users
SET role = $1
@@ -210,36 +259,3 @@ type UpdateUserRoleParams struct {
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID)
}
-
-const updateUserState = `-- name: UpdateUserState :exec
-UPDATE users
-SET state = $1
-WHERE id = $2
-`
-
-type UpdateUserStateParams struct {
- State string
- ID string
-}
-
-func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error {
- _, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID)
- return err
-}
-
-const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec
-UPDATE users
-SET state = $1, avatar_url = $2
-WHERE id = $3
-`
-
-type UpdateUserStateAndAvatarParams struct {
- State string
- AvatarUrl string
- ID string
-}
-
-func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error {
- _, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID)
- return err
-}
diff --git a/internal/store/user.go b/internal/store/user.go
index 299c616..02efe70 100644
--- a/internal/store/user.go
+++ b/internal/store/user.go
@@ -30,6 +30,7 @@ type User struct {
Username string
Name string
Role Role
+ Email string
AvatarURL string
State string
CreatedAt string
@@ -50,12 +51,13 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
-func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User {
+func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{
ID: id,
Username: username,
Name: name,
Role: Role(role),
+ Email: email,
AvatarURL: avatarURL,
State: state,
CreatedAt: createdAt,
@@ -73,6 +75,7 @@ func (u *User) Create(ctx context.Context) error {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
+ u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = uuid.NewString()
}
@@ -88,6 +91,7 @@ func (u *User) Create(ctx context.Context) error {
Name: u.Name,
PasswordHash: u.PasswordHash,
Role: string(u.Role),
+ Email: u.Email,
CreatedAt: u.CreatedAt,
}))
}
@@ -149,21 +153,27 @@ func (u *User) SetRole(ctx context.Context, role Role) error {
return nil
}
-// SaveProfile writes State and optionally AvatarURL.
+// SaveProfile writes Email, State, and optionally AvatarURL.
func (u *User) SaveProfile(ctx context.Context) error {
if u == nil || u.db == nil {
return fmt.Errorf("user: no database")
}
u.State = strings.TrimSpace(u.State)
+ u.Email = NormalizeEmail(u.Email)
q := sqlc.New(u.db)
if u.AvatarURL == "" {
- return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
+ return mapUniqueViolation(q.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{
+ State: u.State,
+ Email: u.Email,
+ ID: u.ID,
+ }))
}
- return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
+ return mapUniqueViolation(q.UpdateUserProfileAndAvatar(ctx, sqlc.UpdateUserProfileAndAvatarParams{
State: u.State,
+ Email: u.Email,
AvatarUrl: u.AvatarURL,
ID: u.ID,
- })
+ }))
}
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
@@ -187,7 +197,7 @@ func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, strin
}
out := make([]User, 0, len(rows))
for _, r := range rows {
- u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
+ u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, "")
out = append(out, *u)
}
var nextCreated, nextID string
@@ -204,7 +214,7 @@ func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
if err != nil {
return nil, err
}
- return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
+ return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
}
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
@@ -212,5 +222,5 @@ func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, er
if err != nil {
return nil, err
}
- return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
+ return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
}
diff --git a/internal/store/vote.go b/internal/store/vote.go
index 2d24a3c..5a3f831 100644
--- a/internal/store/vote.go
+++ b/internal/store/vote.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "strings"
"github.com/jackc/pgx/v5/pgconn"
@@ -14,6 +15,9 @@ import (
// ErrDuplicateUsername is returned when inserting a username that already exists.
var ErrDuplicateUsername = errors.New("username taken")
+// ErrDuplicateEmail is returned when inserting/updating an email that already exists.
+var ErrDuplicateEmail = errors.New("email taken")
+
// ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
var ErrHiddenOrMissing = errors.New("question not votable")
@@ -58,6 +62,9 @@ func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int)
func mapUniqueViolation(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
+ return ErrDuplicateEmail
+ }
return ErrDuplicateUsername
}
return err
diff --git a/internal/web/auth.go b/internal/web/auth.go
index ed1d2c1..d6df74b 100644
--- a/internal/web/auth.go
+++ b/internal/web/auth.go
@@ -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 3–20 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
diff --git a/internal/web/profile.go b/internal/web/profile.go
index d76a2db..c2e2566 100644
--- a/internal/web/profile.go
+++ b/internal/web/profile.go
@@ -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,
})
}
diff --git a/internal/web/server.go b/internal/web/server.go
index 7950bc6..bc88f5b 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -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
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 7419318..b86ffa1 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -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, "Edit answer") ||
+ !strings.Contains(body, ">Tighten the nuts.") ||
+ !strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
+ !strings.Contains(body, `removeAttribute('open')`) ||
+ strings.Contains(body, ` 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.
diff --git a/schema.sql b/schema.sql
index 67136d6..d075baa 100644
--- a/schema.sql
+++ b/schema.sql
@@ -4,11 +4,16 @@ CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
+ email TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
+CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
+ ON users (lower(email))
+ WHERE email <> '';
+
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id),
diff --git a/static/app.css b/static/app.css
index 7fec899..de617bc 100644
--- a/static/app.css
+++ b/static/app.css
@@ -610,6 +610,44 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
font-size: 0.72rem;
}
+.answer-editor {
+ margin-top: 16px;
+ border-top: 1px solid var(--line);
+}
+
+.answer-editor summary {
+ display: flex;
+ width: fit-content;
+ min-height: 44px;
+ align-items: center;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ text-decoration: underline;
+ cursor: pointer;
+ list-style: none;
+}
+
+.answer-editor summary::-webkit-details-marker { display: none; }
+.answer-editor summary:hover,
+.answer-editor[open] summary { color: var(--signal); }
+.answer-editor summary:focus-visible {
+ outline: 2px solid var(--signal);
+ outline-offset: 2px;
+}
+
+.answer-editor .answer-form { margin-top: 4px; }
+
+.answer-form-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.answer-form-actions .btn { flex: 1 1 10rem; }
+
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
.auth-wrap {
diff --git a/templates/partials/_answer.html b/templates/partials/_answer.html
index f50904a..ceca9fd 100644
--- a/templates/partials/_answer.html
+++ b/templates/partials/_answer.html
@@ -5,6 +5,22 @@
Answer
{{.Answer.AuthorName}} · 22 years, Bay Area
{{.Answer.Body}}
+ {{if isAdmin .User}}
+
+ Edit answer
+
+
+ {{end}}
{{else}}
No answer yet. Check back after the hunt.
{{end}}
diff --git a/templates/profile.html b/templates/profile.html
index 2bd4176..3878541 100644
--- a/templates/profile.html
+++ b/templates/profile.html
@@ -25,6 +25,10 @@
+
+
+ Required. We’ll email you when a plumber answers your question.
+