Generalize post reply notifications.
CI / test (pull_request) Successful in 6m23s

Notify direct parent authors throughout threaded conversations while skipping self-replies, edits, and recipients without email.
This commit is contained in:
2026-08-27 08:57:16 -07:00
parent 7412069ca6
commit 3dde9f79f4
7 changed files with 279 additions and 59 deletions
+56 -2
View File
@@ -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) {