Require email at registration (editable on profile), add a Resend mailer with idempotent first-answer sends, and keep answer saves independent of delivery.
109 lines
2.8 KiB
Go
109 lines
2.8 KiB
Go
package mail
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/resend/resend-go/v3"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Notifier sends transactional email about answered questions.
|
|
type Notifier interface {
|
|
NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) 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 }
|
|
|
|
// Resend sends via the Resend HTTP API.
|
|
type Resend struct {
|
|
client *resend.Client
|
|
from string
|
|
baseURL string
|
|
}
|
|
|
|
// FromEnv builds a Notifier from RESEND_* and APP_BASE_URL.
|
|
// Returns Nop when RESEND_API_KEY is unset.
|
|
func FromEnv() (Notifier, error) {
|
|
key := strings.TrimSpace(os.Getenv("RESEND_API_KEY"))
|
|
if key == "" {
|
|
return Nop{}, nil
|
|
}
|
|
from := strings.TrimSpace(os.Getenv("RESEND_FROM"))
|
|
base := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
|
|
if from == "" {
|
|
return nil, fmt.Errorf("RESEND_FROM is required when RESEND_API_KEY is set")
|
|
}
|
|
if base == "" {
|
|
return nil, fmt.Errorf("APP_BASE_URL is required when RESEND_API_KEY is set")
|
|
}
|
|
return &Resend{
|
|
client: resend.NewClient(key),
|
|
from: from,
|
|
baseURL: base,
|
|
}, nil
|
|
}
|
|
|
|
func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
|
|
if r == nil || r.client == nil {
|
|
return nil
|
|
}
|
|
to := strings.TrimSpace(msg.ToEmail)
|
|
if to == "" {
|
|
return nil
|
|
}
|
|
link := r.baseURL + "/questions/" + msg.QuestionID
|
|
title := strings.TrimSpace(msg.QuestionTitle)
|
|
if title == "" {
|
|
title = "your question"
|
|
}
|
|
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",
|
|
greetingName(msg.ToName),
|
|
title,
|
|
msg.AnswerBody,
|
|
link,
|
|
)
|
|
htmlBody := fmt.Sprintf(
|
|
`<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>`,
|
|
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
|
|
}
|
|
|
|
func greetingName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
return " " + name
|
|
}
|