From 3dde9f79f4d05165083d0ee0c85a053e367429f3 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 08:57:16 -0700 Subject: [PATCH] Generalize post reply notifications. Notify direct parent authors throughout threaded conversations while skipping self-replies, edits, and recipients without email. --- internal/mail/mail.go | 86 +++++++++++++---------- internal/mail/mail_test.go | 38 +++++----- internal/mail/recording.go | 8 +-- internal/web/posts.go | 58 ++++++++++++++- internal/web/posts_test.go | 136 ++++++++++++++++++++++++++++++++++++ internal/web/server.go | 1 + internal/web/server_test.go | 11 ++- 7 files changed, 279 insertions(+), 59 deletions(-) diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 86084d6..6e0d789 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "html" + "net/url" "os" "strings" @@ -14,24 +15,26 @@ import ( //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 +// PostReply is the payload for notifying a post author of a direct reply. +type PostReply struct { + ToEmail string + ToName string + RootID string + RootTitle string + ReplyID string + ReplyBody string + ReplyAuthorName string } -// Notifier sends transactional email about answered questions. +// Notifier sends transactional email about post replies. type Notifier interface { - NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error + NotifyPostReply(ctx context.Context, msg PostReply) 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 } +func (Nop) NotifyPostReply(context.Context, PostReply) error { return nil } // Resend sends via the Resend HTTP API. type Resend struct { @@ -62,7 +65,7 @@ func FromEnv() (Notifier, error) { }, nil } -func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error { +func (r *Resend) NotifyPostReply(ctx context.Context, msg PostReply) error { if r == nil || r.client == nil { return nil } @@ -70,59 +73,72 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere if to == "" { return nil } - text, htmlBody := questionAnsweredContent(r.baseURL, msg) + text, htmlBody := postReplyContent(r.baseURL, msg) params := &resend.SendEmailRequest{ From: r.from, To: []string{to}, - Subject: "Your question was answered", + Subject: "New reply to your post", Text: text, Html: htmlBody, Attachments: []*resend.Attachment{{ Content: markPNG, Filename: "ask-a-plumber-first.png", ContentType: "image/png", - ContentId: "answer-notification-mark", + ContentId: "reply-notification-mark", }}, } opts := &resend.SendEmailOptions{ - IdempotencyKey: "answer-notify:" + msg.QuestionID, + IdempotencyKey: "post-reply:" + msg.ReplyID, } _, 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" +func postReplyContent(baseURL string, msg PostReply) (string, string) { + link := strings.TrimRight(baseURL, "/") + + "/questions/" + url.PathEscape(msg.RootID) + + "#post-" + url.PathEscape(msg.ReplyID) + title := replyRootTitle(msg.RootTitle) + author := strings.TrimSpace(msg.ReplyAuthorName) + if author == "" { + author = "Someone" } 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", + "Hi%s,\n\n%s replied in %q:\n\n%s\n\nView the reply:\n%s\n", greetingName(msg.ToName), + author, title, - msg.AnswerBody, + msg.ReplyBody, link, ) htmlBody := strings.NewReplacer( - "{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."), + "{{PREHEADER}}", html.EscapeString(author+" replied in "+title+"."), "{{GREETING}}", html.EscapeString(greetingName(msg.ToName)), "{{TITLE}}", html.EscapeString(title), - "{{ANSWER}}", html.EscapeString(msg.AnswerBody), + "{{AUTHOR}}", html.EscapeString(author), + "{{REPLY}}", html.EscapeString(msg.ReplyBody), "{{LINK}}", html.EscapeString(link), - "{{MARK}}", "cid:answer-notification-mark", - ).Replace(questionAnsweredHTML) + "{{MARK}}", "cid:reply-notification-mark", + ).Replace(postReplyHTML) return text, htmlBody } -const questionAnsweredHTML = ` +func replyRootTitle(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "your conversation" + } + return title +} + +const postReplyHTML = ` - Your question was answered + New reply to your conversation
{{PREHEADER}}
@@ -147,22 +163,22 @@ const questionAnsweredHTML = ` -
Shop response
-

Your question has an answer.

-

Hi{{GREETING}}, a plumber replied to:

+
New reply
+

The conversation has a new reply.

+

Hi{{GREETING}}, {{AUTHOR}} replied in:

“{{TITLE}}”

-
The answer
-
{{ANSWER}}
+
The reply
+
{{REPLY}}
- View the answer → + View the reply →
@@ -170,7 +186,7 @@ const questionAnsweredHTML = ` - You received this because you asked a question on Ask a Plumber First. + You received this because someone replied to your post on Ask a Plumber First. diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go index 624505c..3b86d32 100644 --- a/internal/mail/mail_test.go +++ b/internal/mail/mail_test.go @@ -14,24 +14,27 @@ func TestEmbeddedMarkIsPNG(t *testing.T) { } } -func TestQuestionAnsweredContent(t *testing.T) { +func TestPostReplyContent(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. ", + text, htmlBody := postReplyContent("https://plumber.example/", PostReply{ + ToName: ``, + RootID: "question-123", + RootTitle: `Leaky sink`, + ReplyID: "reply-456", + ReplyBody: "Replace the cartridge.\nThen test the handle. ", + ReplyAuthorName: ``, }) for _, want := range []string{ "Ask a Plumber First", - "Shop response", - "cid:answer-notification-mark", - "https://plumber.example/questions/question-123", + "New reply", + "cid:reply-notification-mark", + "https://plumber.example/questions/question-123#post-reply-456", "white-space:pre-wrap", "<Sam & Pat>", "<b>Leaky sink</b>", + "<Jo & Co>", "<script>alert('x')</script>", } { if !strings.Contains(htmlBody, want) { @@ -41,6 +44,7 @@ func TestQuestionAnsweredContent(t *testing.T) { for _, unsafe := range []string{ "", "Leaky sink", + "", "", } { if strings.Contains(htmlBody, unsafe) { @@ -52,9 +56,9 @@ func TestQuestionAnsweredContent(t *testing.T) { } for _, want := range []string{ `Hi ,`, - `Your question "Leaky sink"`, + ` replied in "Leaky sink"`, "Replace the cartridge.\nThen test the handle.", - "https://plumber.example/questions/question-123", + "https://plumber.example/questions/question-123#post-reply-456", } { if !strings.Contains(text, want) { t.Errorf("text missing %q", want) @@ -62,15 +66,15 @@ func TestQuestionAnsweredContent(t *testing.T) { } } -func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) { +func TestPostReplyContentUsesFallbacks(t *testing.T) { t.Parallel() - text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{}) - if !strings.Contains(text, `"your question"`) { + text, htmlBody := postReplyContent("https://plumber.example", PostReply{}) + if !strings.Contains(text, `Someone replied in "your conversation"`) { 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") + if !strings.Contains(htmlBody, "Someone replied in:

") || + !strings.Contains(htmlBody, "“your conversation”") { + t.Errorf("HTML missing fallbacks") } } diff --git a/internal/mail/recording.go b/internal/mail/recording.go index 188eac8..f9da819 100644 --- a/internal/mail/recording.go +++ b/internal/mail/recording.go @@ -8,10 +8,10 @@ import ( // Recording is a test Notifier that records calls. type Recording struct { mu sync.Mutex - Msgs []QuestionAnswered + Msgs []PostReply } -func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error { +func (r *Recording) NotifyPostReply(_ context.Context, msg PostReply) error { r.mu.Lock() defer r.mu.Unlock() r.Msgs = append(r.Msgs, msg) @@ -25,10 +25,10 @@ func (r *Recording) Len() int { } // Snapshot returns a copy of recorded messages. -func (r *Recording) Snapshot() []QuestionAnswered { +func (r *Recording) Snapshot() []PostReply { r.mu.Lock() defer r.mu.Unlock() - out := make([]QuestionAnswered, len(r.Msgs)) + out := make([]PostReply, len(r.Msgs)) copy(out, r.Msgs) return out } diff --git a/internal/web/posts.go b/internal/web/posts.go index cecefa3..b448853 100644 --- a/internal/web/posts.go +++ b/internal/web/posts.go @@ -5,12 +5,15 @@ import ( "database/sql" "errors" "fmt" + "log" "net/http" "net/url" "strings" + "time" "github.com/go-chi/chi/v5" + "plumber/internal/mail" "plumber/internal/store" ) @@ -37,7 +40,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { AuthorID: user.ID, Body: truncateRunes(body, 12000), } - var root *store.Post + var parent, root *store.Post if parentID == "" { post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120) post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80) @@ -46,7 +49,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { return } } else { - parent, threadRoot, err := s.postAndRoot(r.Context(), parentID) + loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) @@ -63,6 +66,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { http.Error(w, "forbidden", http.StatusForbidden) return } + parent = loadedParent post.ParentID = &parent.ID root = threadRoot } @@ -78,6 +82,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { if root == nil { root = post } + if parent != nil { + s.notifyPostReply(parent, root, post, user) + } http.Redirect( w, r, @@ -86,6 +93,53 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { ) } +// notifyPostReply asynchronously emails the direct parent post's author. +func (s *Server) notifyPostReply( + parent *store.Post, + root *store.Post, + reply *store.Post, + replyAuthor *store.User, +) { + if parent == nil || + root == nil || + reply == nil || + replyAuthor == nil || + s.cfg.Mail == nil || + parent.AuthorID == replyAuthor.ID { + return + } + if _, disabled := s.cfg.Mail.(mail.Nop); disabled { + return + } + msg := mail.PostReply{ + RootID: root.ID, + RootTitle: root.Title, + ReplyID: reply.ID, + ReplyBody: reply.Body, + ReplyAuthorName: replyAuthor.Name, + } + recipientID := parent.AuthorID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + recipient, err := s.store.UserByID(ctx, recipientID) + if err != nil { + log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err) + return + } + if recipient == nil || strings.TrimSpace(recipient.Email) == "" { + return + } + msg.ToEmail = recipient.Email + msg.ToName = recipient.Name + if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil { + log.Printf("notify reply %s: %v", msg.ReplyID, err) + return + } + log.Printf("notify reply %s: accepted", msg.ReplyID) + }() +} + // handleEditPost updates only a post's body after verifying that the current // homeowner owns it or that an admin is editing an admin-authored post. func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/posts_test.go b/internal/web/posts_test.go index 5587d07..0ea4829 100644 --- a/internal/web/posts_test.go +++ b/internal/web/posts_test.go @@ -7,7 +7,9 @@ import ( "net/url" "strings" "testing" + "time" + "plumber/internal/mail" "plumber/internal/pacific" "plumber/internal/store" ) @@ -258,6 +260,127 @@ func TestEditPostRoutePermissions(t *testing.T) { } } +func TestPostReplyNotifications(t *testing.T) { + t.Parallel() + + recording := &mail.Recording{} + srv, mem := newTestServer(t, Config{Mail: recording}) + handler := srv.Handler() + homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser) + admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin) + homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22") + adminCookies := loginUser(t, handler, admin.Username, "hunter22") + homeownerCSRF := csrfForCookies(t, handler, homeownerCookies) + adminCSRF := csrfForCookies(t, handler, adminCookies) + + rec := postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "title": {"Leaky sink"}, + "body": {"Water under the cabinet."}, + }, homeownerCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String()) + } + if recording.Len() != 0 { + t.Fatalf("root create sent %d notifications", recording.Len()) + } + roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID) + if err != nil || len(roots) != 1 { + t.Fatalf("created roots = %+v, %v", roots, err) + } + root := roots[0] + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {adminCSRF}, + "parent_id": {root.ID}, + "body": {"Replace the cartridge."}, + }, adminCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String()) + } + thread, err := mem.GetPostThread(context.Background(), root.ID) + if err != nil || len(thread.Replies) != 1 { + t.Fatalf("admin reply thread = %+v, %v", thread, err) + } + adminReply := thread.Replies[0] + msgs := waitForMail(t, recording, 1) + if msg := msgs[0]; msg.ToEmail != homeowner.Email || + msg.RootID != root.ID || + msg.RootTitle != root.Title || + msg.ReplyID != adminReply.ID || + msg.ReplyBody != adminReply.Body || + msg.ReplyAuthorName != admin.Name { + t.Fatalf("admin reply notification = %+v", msg) + } + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "parent_id": {adminReply.ID}, + "body": {"That fixed the drip."}, + }, homeownerCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String()) + } + thread, err = mem.GetPostThread(context.Background(), root.ID) + if err != nil || len(thread.Replies[0].Replies) != 1 { + t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err) + } + homeownerReply := thread.Replies[0].Replies[0] + msgs = waitForMail(t, recording, 2) + if msg := msgs[1]; msg.ToEmail != admin.Email || + msg.RootID != root.ID || + msg.ReplyID != homeownerReply.ID || + msg.ReplyAuthorName != homeowner.Name { + t.Fatalf("homeowner reply notification = %+v", msg) + } + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "parent_id": {root.ID}, + "body": {"A note to myself."}, + }, homeownerCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String()) + } + rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{ + "_csrf": {adminCSRF}, + "body": {"Replace the ceramic cartridge."}, + }, adminCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String()) + } + + noEmail := &store.User{ + Username: uniq("no-email"), + PasswordHash: homeowner.PasswordHash, + Role: store.RoleUser, + } + if err := mem.CreateUser(context.Background(), noEmail); err != nil { + t.Fatal(err) + } + noEmailRoot := &store.Post{ + AuthorID: noEmail.ID, + Title: "Quiet thread", + Body: "No email configured.", + PostDate: pacific.Today(), + } + if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil { + t.Fatal(err) + } + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {adminCSRF}, + "parent_id": {noEmailRoot.ID}, + "body": {"This should not send."}, + }, adminCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String()) + } + time.Sleep(50 * time.Millisecond) + if recording.Len() != 2 { + t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot()) + } +} + func TestQuestionPageRendersNestedPostControls(t *testing.T) { t.Parallel() @@ -345,6 +468,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { } } +func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if recording.Len() >= want { + return recording.Snapshot() + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("recorded %d notifications, want %d", recording.Len(), want) + return nil +} + func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string { t.Helper() req := httptest.NewRequest(http.MethodGet, "/submit", nil) diff --git a/internal/web/server.go b/internal/web/server.go index 60c6274..30a7612 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -510,6 +510,7 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not save answer", http.StatusInternalServerError) return } + s.notifyPostReply(root, root, reply, u) location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID) if isHTMX(r) { w.Header().Set("HX-Redirect", location) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index ee74077..c9d65d2 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" ) @@ -483,7 +484,8 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { } func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { - srv, mem := newTestServer(t, Config{}) + recording := &mail.Recording{} + srv, mem := newTestServer(t, Config{Mail: recording}) h := srv.Handler() adminName := uniq("admin") userName := uniq("user") @@ -611,6 +613,13 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID { t.Fatalf("admin answer redirect = %q", got) } + msgs := waitForMail(t, recording, 1) + if msg := msgs[0]; msg.ToEmail != user.Email || + msg.RootID != q.ID || + msg.ReplyID != adminReply.ID || + msg.ReplyBody != adminReply.Body { + t.Fatalf("compatibility reply notification = %+v", msg) + } rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)