Files
plumber/internal/mail/mail.go
T
codegirl007 f61b4fc7ab Notify question authors via Resend when an admin posts the first answer.
Require email at registration (editable on profile), add a Resend mailer with idempotent first-answer sends, and keep answer saves independent of delivery.
2026-08-22 12:39:43 -07:00

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
}