diff --git a/internal/mail/mail.go b/internal/mail/mail.go
index f716ae9..86084d6 100644
--- a/internal/mail/mail.go
+++ b/internal/mail/mail.go
@@ -2,6 +2,7 @@ package mail
import (
"context"
+ _ "embed"
"fmt"
"html"
"os"
@@ -10,6 +11,9 @@ import (
"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
@@ -66,7 +70,29 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
if to == "" {
return nil
}
- link := r.baseURL + "/questions/" + msg.QuestionID
+ 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"
@@ -78,27 +104,82 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
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
+ 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 == "" {
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/web/server.go b/internal/web/server.go
index 6d4a7b3..bc88f5b 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -543,7 +543,9 @@ func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adm
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)
}()
}
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 15945a2..b86ffa1 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -601,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, `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/question.html b/templates/question.html
index 4f83855..3e34841 100644
--- a/templates/question.html
+++ b/templates/question.html
@@ -23,12 +23,12 @@
{{template "answer" .}}
- {{if isAdmin .User}}
+ {{if and (isAdmin .User) (not .Answer)}}
{{end}}