Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c328c498 | ||
|
|
3dde9f79f4 |
+1
-1
@@ -18,7 +18,7 @@ SECURE_COOKIE=0
|
|||||||
# RESEND_API_KEY=re_xxxxxxxxx
|
# RESEND_API_KEY=re_xxxxxxxxx
|
||||||
# RESEND_FROM=Ask a Plumber <notify@yourdomain.com>
|
# RESEND_FROM=Ask a Plumber <notify@yourdomain.com>
|
||||||
# Public site origin used in email links (required when Resend is enabled):
|
# Public site origin used in email links (required when Resend is enabled):
|
||||||
# APP_BASE_URL=https://askaplumber.example
|
# APP_BASE_URL=https://www.askaplumberfirst.com
|
||||||
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
|
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
|
||||||
# SPACES_KEY=
|
# SPACES_KEY=
|
||||||
# SPACES_SECRET=
|
# SPACES_SECRET=
|
||||||
|
|||||||
+49
-33
@@ -5,6 +5,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -14,24 +15,26 @@ import (
|
|||||||
//go:embed mark.png
|
//go:embed mark.png
|
||||||
var markPNG []byte
|
var markPNG []byte
|
||||||
|
|
||||||
// QuestionAnswered is the payload for notifying a question author of a reply.
|
// PostReply is the payload for notifying a post author of a direct reply.
|
||||||
type QuestionAnswered struct {
|
type PostReply struct {
|
||||||
ToEmail string
|
ToEmail string
|
||||||
ToName string
|
ToName string
|
||||||
QuestionID string
|
RootID string
|
||||||
QuestionTitle string
|
RootTitle string
|
||||||
AnswerBody string
|
ReplyID string
|
||||||
|
ReplyBody string
|
||||||
|
ReplyAuthorName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier sends transactional email about answered questions.
|
// Notifier sends transactional email about post replies.
|
||||||
type Notifier interface {
|
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.
|
// Nop is a no-op Notifier used when Resend is not configured.
|
||||||
type Nop struct{}
|
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.
|
// Resend sends via the Resend HTTP API.
|
||||||
type Resend struct {
|
type Resend struct {
|
||||||
@@ -62,7 +65,7 @@ func FromEnv() (Notifier, error) {
|
|||||||
}, nil
|
}, 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 {
|
if r == nil || r.client == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -70,59 +73,72 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
|
|||||||
if to == "" {
|
if to == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
text, htmlBody := questionAnsweredContent(r.baseURL, msg)
|
text, htmlBody := postReplyContent(r.baseURL, msg)
|
||||||
params := &resend.SendEmailRequest{
|
params := &resend.SendEmailRequest{
|
||||||
From: r.from,
|
From: r.from,
|
||||||
To: []string{to},
|
To: []string{to},
|
||||||
Subject: "Your question was answered",
|
Subject: "New reply to your post",
|
||||||
Text: text,
|
Text: text,
|
||||||
Html: htmlBody,
|
Html: htmlBody,
|
||||||
Attachments: []*resend.Attachment{{
|
Attachments: []*resend.Attachment{{
|
||||||
Content: markPNG,
|
Content: markPNG,
|
||||||
Filename: "ask-a-plumber-first.png",
|
Filename: "ask-a-plumber-first.png",
|
||||||
ContentType: "image/png",
|
ContentType: "image/png",
|
||||||
ContentId: "answer-notification-mark",
|
ContentId: "reply-notification-mark",
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
opts := &resend.SendEmailOptions{
|
opts := &resend.SendEmailOptions{
|
||||||
IdempotencyKey: "answer-notify:" + msg.QuestionID,
|
IdempotencyKey: "post-reply:" + msg.ReplyID,
|
||||||
}
|
}
|
||||||
_, err := r.client.Emails.SendWithOptions(ctx, params, opts)
|
_, err := r.client.Emails.SendWithOptions(ctx, params, opts)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) {
|
func postReplyContent(baseURL string, msg PostReply) (string, string) {
|
||||||
link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID
|
link := strings.TrimRight(baseURL, "/") +
|
||||||
title := strings.TrimSpace(msg.QuestionTitle)
|
"/questions/" + url.PathEscape(msg.RootID) +
|
||||||
if title == "" {
|
"#post-" + url.PathEscape(msg.ReplyID)
|
||||||
title = "your question"
|
title := replyRootTitle(msg.RootTitle)
|
||||||
|
author := strings.TrimSpace(msg.ReplyAuthorName)
|
||||||
|
if author == "" {
|
||||||
|
author = "Someone"
|
||||||
}
|
}
|
||||||
text := fmt.Sprintf(
|
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),
|
greetingName(msg.ToName),
|
||||||
|
author,
|
||||||
title,
|
title,
|
||||||
msg.AnswerBody,
|
msg.ReplyBody,
|
||||||
link,
|
link,
|
||||||
)
|
)
|
||||||
htmlBody := strings.NewReplacer(
|
htmlBody := strings.NewReplacer(
|
||||||
"{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
|
"{{PREHEADER}}", html.EscapeString(author+" replied in "+title+"."),
|
||||||
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
|
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
|
||||||
"{{TITLE}}", html.EscapeString(title),
|
"{{TITLE}}", html.EscapeString(title),
|
||||||
"{{ANSWER}}", html.EscapeString(msg.AnswerBody),
|
"{{AUTHOR}}", html.EscapeString(author),
|
||||||
|
"{{REPLY}}", html.EscapeString(msg.ReplyBody),
|
||||||
"{{LINK}}", html.EscapeString(link),
|
"{{LINK}}", html.EscapeString(link),
|
||||||
"{{MARK}}", "cid:answer-notification-mark",
|
"{{MARK}}", "cid:reply-notification-mark",
|
||||||
).Replace(questionAnsweredHTML)
|
).Replace(postReplyHTML)
|
||||||
return text, htmlBody
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="supported-color-schemes" content="dark">
|
<meta name="supported-color-schemes" content="dark">
|
||||||
<title>Your question was answered</title>
|
<title>New reply to your conversation</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background:#161719;color:#ecebe7;font-family:Arial,'Helvetica Neue',sans-serif;">
|
<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>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:30px 28px 32px;">
|
<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>
|
<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;">Your question has an answer.</h1>
|
<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}}, a plumber replied to:</p>
|
<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>
|
<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;">
|
<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>
|
<tr>
|
||||||
<td style="padding:20px 18px;">
|
<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 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;">{{ANSWER}}</div>
|
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{REPLY}}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td bgcolor="#e96a26" style="border-radius:3px;">
|
<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>
|
<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 →</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -170,7 +186,7 @@ const questionAnsweredHTML = `<!doctype html>
|
|||||||
</tr>
|
</tr>
|
||||||
<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;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+20
-16
@@ -14,24 +14,27 @@ func TestEmbeddedMarkIsPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuestionAnsweredContent(t *testing.T) {
|
func TestPostReplyContent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{
|
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com/", PostReply{
|
||||||
ToName: `<Sam & Pat>`,
|
ToName: `<Sam & Pat>`,
|
||||||
QuestionID: "question-123",
|
RootID: "question-123",
|
||||||
QuestionTitle: `<b>Leaky sink</b>`,
|
RootTitle: `<b>Leaky sink</b>`,
|
||||||
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
|
ReplyID: "reply-456",
|
||||||
|
ReplyBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
|
||||||
|
ReplyAuthorName: `<Jo & Co>`,
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Ask a Plumber First",
|
"Ask a Plumber First",
|
||||||
"Shop response",
|
"New reply",
|
||||||
"cid:answer-notification-mark",
|
"cid:reply-notification-mark",
|
||||||
"https://plumber.example/questions/question-123",
|
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456",
|
||||||
"white-space:pre-wrap",
|
"white-space:pre-wrap",
|
||||||
"<Sam & Pat>",
|
"<Sam & Pat>",
|
||||||
"<b>Leaky sink</b>",
|
"<b>Leaky sink</b>",
|
||||||
|
"<Jo & Co>",
|
||||||
"<script>alert('x')</script>",
|
"<script>alert('x')</script>",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(htmlBody, want) {
|
if !strings.Contains(htmlBody, want) {
|
||||||
@@ -41,6 +44,7 @@ func TestQuestionAnsweredContent(t *testing.T) {
|
|||||||
for _, unsafe := range []string{
|
for _, unsafe := range []string{
|
||||||
"<Sam & Pat>",
|
"<Sam & Pat>",
|
||||||
"<b>Leaky sink</b>",
|
"<b>Leaky sink</b>",
|
||||||
|
"<Jo & Co>",
|
||||||
"<script>alert('x')</script>",
|
"<script>alert('x')</script>",
|
||||||
} {
|
} {
|
||||||
if strings.Contains(htmlBody, unsafe) {
|
if strings.Contains(htmlBody, unsafe) {
|
||||||
@@ -52,9 +56,9 @@ func TestQuestionAnsweredContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`Hi <Sam & Pat>,`,
|
`Hi <Sam & Pat>,`,
|
||||||
`Your question "<b>Leaky sink</b>"`,
|
`<Jo & Co> replied in "<b>Leaky sink</b>"`,
|
||||||
"Replace the cartridge.\nThen test the handle.",
|
"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) {
|
if !strings.Contains(text, want) {
|
||||||
t.Errorf("text missing %q", 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()
|
t.Parallel()
|
||||||
|
|
||||||
text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
|
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com", PostReply{})
|
||||||
if !strings.Contains(text, `"your question"`) {
|
if !strings.Contains(text, `Someone replied in "your conversation"`) {
|
||||||
t.Errorf("text missing fallback title")
|
t.Errorf("text missing fallback title")
|
||||||
}
|
}
|
||||||
if !strings.Contains(htmlBody, "a plumber replied to:</p>") ||
|
if !strings.Contains(htmlBody, "Someone replied in:</p>") ||
|
||||||
!strings.Contains(htmlBody, "“your question”") {
|
!strings.Contains(htmlBody, "“your conversation”") {
|
||||||
t.Errorf("HTML missing fallback title")
|
t.Errorf("HTML missing fallbacks")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
// Recording is a test Notifier that records calls.
|
// Recording is a test Notifier that records calls.
|
||||||
type Recording struct {
|
type Recording struct {
|
||||||
mu sync.Mutex
|
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()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.Msgs = append(r.Msgs, msg)
|
r.Msgs = append(r.Msgs, msg)
|
||||||
@@ -25,10 +25,10 @@ func (r *Recording) Len() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns a copy of recorded messages.
|
// Snapshot returns a copy of recorded messages.
|
||||||
func (r *Recording) Snapshot() []QuestionAnswered {
|
func (r *Recording) Snapshot() []PostReply {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
out := make([]QuestionAnswered, len(r.Msgs))
|
out := make([]PostReply, len(r.Msgs))
|
||||||
copy(out, r.Msgs)
|
copy(out, r.Msgs)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-2
@@ -5,12 +5,15 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"plumber/internal/mail"
|
||||||
"plumber/internal/store"
|
"plumber/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
AuthorID: user.ID,
|
AuthorID: user.ID,
|
||||||
Body: truncateRunes(body, 12000),
|
Body: truncateRunes(body, 12000),
|
||||||
}
|
}
|
||||||
var root *store.Post
|
var parent, root *store.Post
|
||||||
if parentID == "" {
|
if parentID == "" {
|
||||||
post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120)
|
post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120)
|
||||||
post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80)
|
post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80)
|
||||||
@@ -46,7 +49,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
parent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -63,6 +66,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
parent = loadedParent
|
||||||
post.ParentID = &parent.ID
|
post.ParentID = &parent.ID
|
||||||
root = threadRoot
|
root = threadRoot
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
if root == nil {
|
if root == nil {
|
||||||
root = post
|
root = post
|
||||||
}
|
}
|
||||||
|
if parent != nil {
|
||||||
|
s.notifyPostReply(parent, root, post, user)
|
||||||
|
}
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w,
|
w,
|
||||||
r,
|
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
|
// 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.
|
// homeowner owns it or that an admin is editing an admin-authored post.
|
||||||
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"plumber/internal/mail"
|
||||||
"plumber/internal/pacific"
|
"plumber/internal/pacific"
|
||||||
"plumber/internal/store"
|
"plumber/internal/store"
|
||||||
)
|
)
|
||||||
@@ -258,6 +260,127 @@ func TestEditPostRoutePermissions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostReplyNotifications(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
recording := &mail.Recording{}
|
||||||
|
srv, mem := newTestServer(t, Config{Mail: recording})
|
||||||
|
handler := srv.Handler()
|
||||||
|
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
|
||||||
|
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
|
||||||
|
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
|
||||||
|
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
|
||||||
|
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
|
||||||
|
adminCSRF := csrfForCookies(t, handler, adminCookies)
|
||||||
|
|
||||||
|
rec := postForm(handler, "/posts", url.Values{
|
||||||
|
"_csrf": {homeownerCSRF},
|
||||||
|
"title": {"Leaky sink"},
|
||||||
|
"body": {"Water under the cabinet."},
|
||||||
|
}, homeownerCookies)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if recording.Len() != 0 {
|
||||||
|
t.Fatalf("root create sent %d notifications", recording.Len())
|
||||||
|
}
|
||||||
|
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
|
||||||
|
if err != nil || len(roots) != 1 {
|
||||||
|
t.Fatalf("created roots = %+v, %v", roots, err)
|
||||||
|
}
|
||||||
|
root := roots[0]
|
||||||
|
|
||||||
|
rec = postForm(handler, "/posts", url.Values{
|
||||||
|
"_csrf": {adminCSRF},
|
||||||
|
"parent_id": {root.ID},
|
||||||
|
"body": {"Replace the cartridge."},
|
||||||
|
}, adminCookies)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
thread, err := mem.GetPostThread(context.Background(), root.ID)
|
||||||
|
if err != nil || len(thread.Replies) != 1 {
|
||||||
|
t.Fatalf("admin reply thread = %+v, %v", thread, err)
|
||||||
|
}
|
||||||
|
adminReply := thread.Replies[0]
|
||||||
|
msgs := waitForMail(t, recording, 1)
|
||||||
|
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
|
||||||
|
msg.RootID != root.ID ||
|
||||||
|
msg.RootTitle != root.Title ||
|
||||||
|
msg.ReplyID != adminReply.ID ||
|
||||||
|
msg.ReplyBody != adminReply.Body ||
|
||||||
|
msg.ReplyAuthorName != admin.Name {
|
||||||
|
t.Fatalf("admin reply notification = %+v", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = postForm(handler, "/posts", url.Values{
|
||||||
|
"_csrf": {homeownerCSRF},
|
||||||
|
"parent_id": {adminReply.ID},
|
||||||
|
"body": {"That fixed the drip."},
|
||||||
|
}, homeownerCookies)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
thread, err = mem.GetPostThread(context.Background(), root.ID)
|
||||||
|
if err != nil || len(thread.Replies[0].Replies) != 1 {
|
||||||
|
t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err)
|
||||||
|
}
|
||||||
|
homeownerReply := thread.Replies[0].Replies[0]
|
||||||
|
msgs = waitForMail(t, recording, 2)
|
||||||
|
if msg := msgs[1]; msg.ToEmail != admin.Email ||
|
||||||
|
msg.RootID != root.ID ||
|
||||||
|
msg.ReplyID != homeownerReply.ID ||
|
||||||
|
msg.ReplyAuthorName != homeowner.Name {
|
||||||
|
t.Fatalf("homeowner reply notification = %+v", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = postForm(handler, "/posts", url.Values{
|
||||||
|
"_csrf": {homeownerCSRF},
|
||||||
|
"parent_id": {root.ID},
|
||||||
|
"body": {"A note to myself."},
|
||||||
|
}, homeownerCookies)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
|
||||||
|
"_csrf": {adminCSRF},
|
||||||
|
"body": {"Replace the ceramic cartridge."},
|
||||||
|
}, adminCookies)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
noEmail := &store.User{
|
||||||
|
Username: uniq("no-email"),
|
||||||
|
PasswordHash: homeowner.PasswordHash,
|
||||||
|
Role: store.RoleUser,
|
||||||
|
}
|
||||||
|
if err := mem.CreateUser(context.Background(), noEmail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
noEmailRoot := &store.Post{
|
||||||
|
AuthorID: noEmail.ID,
|
||||||
|
Title: "Quiet thread",
|
||||||
|
Body: "No email configured.",
|
||||||
|
PostDate: pacific.Today(),
|
||||||
|
}
|
||||||
|
if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = postForm(handler, "/posts", url.Values{
|
||||||
|
"_csrf": {adminCSRF},
|
||||||
|
"parent_id": {noEmailRoot.ID},
|
||||||
|
"body": {"This should not send."},
|
||||||
|
}, adminCookies)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if recording.Len() != 2 {
|
||||||
|
t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -345,6 +468,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if recording.Len() >= want {
|
||||||
|
return recording.Snapshot()
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
|
func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.notifyPostReply(root, root, reply, u)
|
||||||
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
|
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
|
||||||
if isHTMX(r) {
|
if isHTMX(r) {
|
||||||
w.Header().Set("HX-Redirect", location)
|
w.Header().Set("HX-Redirect", location)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"plumber"
|
"plumber"
|
||||||
"plumber/internal/blob"
|
"plumber/internal/blob"
|
||||||
|
"plumber/internal/mail"
|
||||||
"plumber/internal/pacific"
|
"plumber/internal/pacific"
|
||||||
"plumber/internal/store"
|
"plumber/internal/store"
|
||||||
)
|
)
|
||||||
@@ -483,7 +484,8 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||||
srv, mem := newTestServer(t, Config{})
|
recording := &mail.Recording{}
|
||||||
|
srv, mem := newTestServer(t, Config{Mail: recording})
|
||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
adminName := uniq("admin")
|
adminName := uniq("admin")
|
||||||
userName := uniq("user")
|
userName := uniq("user")
|
||||||
@@ -611,6 +613,13 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
|||||||
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
|
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
|
||||||
t.Fatalf("admin answer redirect = %q", got)
|
t.Fatalf("admin answer redirect = %q", got)
|
||||||
}
|
}
|
||||||
|
msgs := waitForMail(t, recording, 1)
|
||||||
|
if msg := msgs[0]; msg.ToEmail != user.Email ||
|
||||||
|
msg.RootID != q.ID ||
|
||||||
|
msg.ReplyID != adminReply.ID ||
|
||||||
|
msg.ReplyBody != adminReply.Body {
|
||||||
|
t.Fatalf("compatibility reply notification = %+v", msg)
|
||||||
|
}
|
||||||
|
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user