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}}
+
+ + + + +
+ View the 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.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}}