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..f716ae9 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,108 @@ +package mail + +import ( + "context" + "fmt" + "html" + "os" + "strings" + + "github.com/resend/resend-go/v3" +) + +// 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 + } + link := r.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 := fmt.Sprintf( + `

Hi%s,

Your question %s has an answer from a plumber:

%s

View the answer

`, + html.EscapeString(greetingName(msg.ToName)), + html.EscapeString(title), + html.EscapeString(msg.AnswerBody), + html.EscapeString(link), + ) + params := &resend.SendEmailRequest{ + From: r.from, + To: []string{to}, + Subject: "Your question was answered", + Text: text, + Html: htmlBody, + } + opts := &resend.SendEmailOptions{ + IdempotencyKey: "answer-notify:" + msg.QuestionID, + } + _, err := r.client.Emails.SendWithOptions(ctx, params, opts) + return err +} + +func greetingName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return " " + name +} 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..6d4a7b3 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,30 @@ 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) + } + }() +} + 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..15945a2 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 { @@ -666,6 +671,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. 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/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.

+

3–20 letters, numbers, or underscores.

+ + +

We’ll email you when a plumber answers your question.

At least 8 characters (max 72 bytes).