Generalize post reply notifications (#6)

## Summary
- Notify the direct parent post author for replies throughout nested conversations
- Skip root creation, self-replies, edits, disabled mail, and recipients without email
- Link directly to each reply and use per-reply Resend idempotency

Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
2026-08-27 16:03:33 +00:00
committed by codegirl007
parent 7412069ca6
commit f0591ccea3
8 changed files with 280 additions and 60 deletions
+51 -35
View File
@@ -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 = `<!doctype html>
func replyRootTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "your conversation"
}
return title
}
const postReplyHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>Your question was answered</title>
<title>New reply to your conversation</title>
</head>
<body style="margin:0;padding:0;background:#161719;color:#ecebe7;font-family:Arial,'Helvetica Neue',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{PREHEADER}}</div>
@@ -147,22 +163,22 @@ const questionAnsweredHTML = `<!doctype html>
</tr>
<tr>
<td style="padding:30px 28px 32px;">
<div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">Shop response</div>
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">Your question has an answer.</h1>
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, a plumber replied to:</p>
<div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">New reply</div>
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">The conversation has a new reply.</h1>
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, {{AUTHOR}} replied in:</p>
<p style="margin:8px 0 0;color:#ecebe7;font-size:17px;font-weight:600;line-height:1.45;">“{{TITLE}}”</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin-top:24px;background:#161719;border:1px solid #2e3136;border-radius:3px;">
<tr>
<td style="padding:20px 18px;">
<div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The answer</div>
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{ANSWER}}</div>
<div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The reply</div>
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{REPLY}}</div>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
<tr>
<td bgcolor="#e96a26" style="border-radius:3px;">
<a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the answer&nbsp;&rarr;</a>
<a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the reply&nbsp;&rarr;</a>
</td>
</tr>
</table>
@@ -170,7 +186,7 @@ const questionAnsweredHTML = `<!doctype html>
</tr>
<tr>
<td style="padding:18px 28px;border-top:1px solid #2e3136;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.6;letter-spacing:0.4px;">
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.
</td>
</tr>
</table>
+21 -17
View File
@@ -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: `<Sam & Pat>`,
QuestionID: "question-123",
QuestionTitle: `<b>Leaky sink</b>`,
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com/", PostReply{
ToName: `<Sam & Pat>`,
RootID: "question-123",
RootTitle: `<b>Leaky sink</b>`,
ReplyID: "reply-456",
ReplyBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
ReplyAuthorName: `<Jo & Co>`,
})
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://www.askaplumberfirst.com/questions/question-123#post-reply-456",
"white-space:pre-wrap",
"&lt;Sam &amp; Pat&gt;",
"&lt;b&gt;Leaky sink&lt;/b&gt;",
"&lt;Jo &amp; Co&gt;",
"&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;",
} {
if !strings.Contains(htmlBody, want) {
@@ -41,6 +44,7 @@ func TestQuestionAnsweredContent(t *testing.T) {
for _, unsafe := range []string{
"<Sam & Pat>",
"<b>Leaky sink</b>",
"<Jo & Co>",
"<script>alert('x')</script>",
} {
if strings.Contains(htmlBody, unsafe) {
@@ -52,9 +56,9 @@ func TestQuestionAnsweredContent(t *testing.T) {
}
for _, want := range []string{
`Hi <Sam & Pat>,`,
`Your question "<b>Leaky sink</b>"`,
`<Jo & Co> replied in "<b>Leaky sink</b>"`,
"Replace the cartridge.\nThen test the handle.",
"https://plumber.example/questions/question-123",
"https://www.askaplumberfirst.com/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://www.askaplumberfirst.com", PostReply{})
if !strings.Contains(text, `Someone replied in "your conversation"`) {
t.Errorf("text missing fallback title")
}
if !strings.Contains(htmlBody, "a plumber replied to:</p>") ||
!strings.Contains(htmlBody, "“your question”") {
t.Errorf("HTML missing fallback title")
if !strings.Contains(htmlBody, "Someone replied in:</p>") ||
!strings.Contains(htmlBody, "“your conversation”") {
t.Errorf("HTML missing fallbacks")
}
}
+4 -4
View File
@@ -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
}