Brand notification emails, log accepted sends, and keep answer editing collapsed until explicitly opened.
This commit is contained in:
+101
-20
@@ -2,6 +2,7 @@ package mail
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,6 +11,9 @@ import (
|
|||||||
"github.com/resend/resend-go/v3"
|
"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.
|
// QuestionAnswered is the payload for notifying a question author of a reply.
|
||||||
type QuestionAnswered struct {
|
type QuestionAnswered struct {
|
||||||
ToEmail string
|
ToEmail string
|
||||||
@@ -66,7 +70,29 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
|
|||||||
if to == "" {
|
if to == "" {
|
||||||
return nil
|
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)
|
title := strings.TrimSpace(msg.QuestionTitle)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "your question"
|
title = "your question"
|
||||||
@@ -78,27 +104,82 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
|
|||||||
msg.AnswerBody,
|
msg.AnswerBody,
|
||||||
link,
|
link,
|
||||||
)
|
)
|
||||||
htmlBody := fmt.Sprintf(
|
htmlBody := strings.NewReplacer(
|
||||||
`<p>Hi%s,</p><p>Your question <strong>%s</strong> has an answer from a plumber:</p><blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #ccc;white-space:pre-wrap">%s</blockquote><p><a href="%s">View the answer</a></p>`,
|
"{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
|
||||||
html.EscapeString(greetingName(msg.ToName)),
|
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
|
||||||
html.EscapeString(title),
|
"{{TITLE}}", html.EscapeString(title),
|
||||||
html.EscapeString(msg.AnswerBody),
|
"{{ANSWER}}", html.EscapeString(msg.AnswerBody),
|
||||||
html.EscapeString(link),
|
"{{LINK}}", html.EscapeString(link),
|
||||||
)
|
"{{MARK}}", "cid:answer-notification-mark",
|
||||||
params := &resend.SendEmailRequest{
|
).Replace(questionAnsweredHTML)
|
||||||
From: r.from,
|
return text, htmlBody
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const questionAnsweredHTML = `<!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>
|
||||||
|
</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>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#161719;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 16px;">
|
||||||
|
<table role="presentation" width="560" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:560px;background:#1e2023;border:1px solid #2e3136;border-top:3px solid #e96a26;border-radius:3px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 28px 20px;border-bottom:1px solid #2e3136;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-right:12px;vertical-align:middle;">
|
||||||
|
<img src="{{MARK}}" width="32" height="32" alt="" style="display:block;width:32px;height:32px;border:0;">
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<div style="color:#ecebe7;font-size:14px;font-weight:700;line-height:1.2;letter-spacing:1px;text-transform:uppercase;">Ask a Plumber First</div>
|
||||||
|
<div style="margin-top:4px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.2;letter-spacing:1.4px;text-transform:uppercase;">Bay Area · Shop Dispatch</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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 →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</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.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
func greetingName(name string) string {
|
func greetingName(name string) string {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
@@ -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: `<Sam & Pat>`,
|
||||||
|
QuestionID: "question-123",
|
||||||
|
QuestionTitle: `<b>Leaky sink</b>`,
|
||||||
|
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
|
||||||
|
})
|
||||||
|
|
||||||
|
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{
|
||||||
|
"<Sam & Pat>",
|
||||||
|
"<b>Leaky sink</b>",
|
||||||
|
"<script>alert('x')</script>",
|
||||||
|
} {
|
||||||
|
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 <Sam & Pat>,`,
|
||||||
|
`Your question "<b>Leaky sink</b>"`,
|
||||||
|
"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:</p>") ||
|
||||||
|
!strings.Contains(htmlBody, "“your question”") {
|
||||||
|
t.Errorf("HTML missing fallback title")
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -543,7 +543,9 @@ func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adm
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
|
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
|
||||||
log.Printf("notify answer %s: %v", q.ID, err)
|
log.Printf("notify answer %s: %v", q.ID, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("notify answer %s: accepted", q.ID)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -601,10 +601,32 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
|||||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
|
||||||
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
|
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, "<summary>Edit answer</summary>") ||
|
||||||
|
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
|
||||||
|
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
|
||||||
|
!strings.Contains(body, `removeAttribute('open')`) ||
|
||||||
|
strings.Contains(body, `<details class="answer-editor" open`) {
|
||||||
|
t.Fatalf("admin answer editor is not collapsed and populated: %s", body)
|
||||||
|
}
|
||||||
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
|
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The public answer is visible to its author, but editing remains admin-only.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||||
|
for _, c := range userCookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
|
||||||
|
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), `class="answer-editor"`) {
|
||||||
|
t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
// Hide invalid id
|
// Hide invalid id
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||||
|
|||||||
@@ -610,6 +610,44 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.answer-editor {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-editor summary {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-editor summary::-webkit-details-marker { display: none; }
|
||||||
|
.answer-editor summary:hover,
|
||||||
|
.answer-editor[open] summary { color: var(--signal); }
|
||||||
|
.answer-editor summary:focus-visible {
|
||||||
|
outline: 2px solid var(--signal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-editor .answer-form { margin-top: 4px; }
|
||||||
|
|
||||||
|
.answer-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-form-actions .btn { flex: 1 1 10rem; }
|
||||||
|
|
||||||
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
|
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
|
||||||
|
|
||||||
.auth-wrap {
|
.auth-wrap {
|
||||||
|
|||||||
@@ -5,6 +5,22 @@
|
|||||||
<h2>Answer</h2>
|
<h2>Answer</h2>
|
||||||
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
|
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
|
||||||
<p class="answer-body">{{.Answer.Body}}</p>
|
<p class="answer-body">{{.Answer.Body}}</p>
|
||||||
|
{{if isAdmin .User}}
|
||||||
|
<details class="answer-editor">
|
||||||
|
<summary>Edit answer</summary>
|
||||||
|
<form class="answer-form" method="post" action="/questions/{{.Answer.QuestionID}}/answer"
|
||||||
|
hx-post="/questions/{{.Answer.QuestionID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<label for="answer-body">Edit answer</label>
|
||||||
|
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{.Answer.Body}}</textarea>
|
||||||
|
<div class="answer-form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save answer</button>
|
||||||
|
<button type="reset" class="btn btn-ghost"
|
||||||
|
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="waiting">No answer yet. Check back after the hunt.</p>
|
<p class="waiting">No answer yet. Check back after the hunt.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{{template "answer" .}}
|
{{template "answer" .}}
|
||||||
{{if isAdmin .User}}
|
{{if and (isAdmin .User) (not .Answer)}}
|
||||||
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
|
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
|
||||||
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
|
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
|
||||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
<label for="answer-body">{{if .Answer}}Edit answer{{else}}Write the answer{{end}}</label>
|
<label for="answer-body">Write the answer</label>
|
||||||
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{if .Answer}}{{.Answer.Body}}{{end}}</textarea>
|
<textarea id="answer-body" name="body" rows="8" required maxlength="12000"></textarea>
|
||||||
<button type="submit" class="btn btn-primary">Save answer</button>
|
<button type="submit" class="btn btn-primary">Save answer</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user