package mail
import (
"context"
_ "embed"
"fmt"
"html"
"net/url"
"os"
"strings"
"github.com/resend/resend-go/v3"
)
//go:embed mark.png
var markPNG []byte
// PostReply is the payload for notifying a post author of a direct reply.
type PostReply struct {
ToEmail string
ToName string
RootID string
RootTitle string
ReplyID string
ReplyBody string
ReplyAuthorName string
}
// Notifier sends transactional email about post replies.
type Notifier interface {
NotifyPostReply(ctx context.Context, msg PostReply) error
}
// Nop is a no-op Notifier used when Resend is not configured.
type Nop struct{}
func (Nop) NotifyPostReply(context.Context, PostReply) 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) NotifyPostReply(ctx context.Context, msg PostReply) error {
if r == nil || r.client == nil {
return nil
}
to := strings.TrimSpace(msg.ToEmail)
if to == "" {
return nil
}
text, htmlBody := postReplyContent(r.baseURL, msg)
params := &resend.SendEmailRequest{
From: r.from,
To: []string{to},
Subject: "New reply to your post",
Text: text,
Html: htmlBody,
Attachments: []*resend.Attachment{{
Content: markPNG,
Filename: "ask-a-plumber-first.png",
ContentType: "image/png",
ContentId: "reply-notification-mark",
}},
}
opts := &resend.SendEmailOptions{
IdempotencyKey: "post-reply:" + msg.ReplyID,
}
_, err := r.client.Emails.SendWithOptions(ctx, params, opts)
return err
}
func postReplyContent(baseURL string, msg PostReply) (string, string) {
link := strings.TrimRight(baseURL, "/") +
"/questions/" + url.PathEscape(msg.RootID) +
"#post-" + url.PathEscape(msg.ReplyID)
title := replyRootTitle(msg.RootTitle)
author := strings.TrimSpace(msg.ReplyAuthorName)
if author == "" {
author = "Someone"
}
text := fmt.Sprintf(
"Hi%s,\n\n%s replied in %q:\n\n%s\n\nView the reply:\n%s\n",
greetingName(msg.ToName),
author,
title,
msg.ReplyBody,
link,
)
htmlBody := strings.NewReplacer(
"{{PREHEADER}}", html.EscapeString(author+" replied in "+title+"."),
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
"{{TITLE}}", html.EscapeString(title),
"{{AUTHOR}}", html.EscapeString(author),
"{{REPLY}}", html.EscapeString(msg.ReplyBody),
"{{LINK}}", html.EscapeString(link),
"{{MARK}}", "cid:reply-notification-mark",
).Replace(postReplyHTML)
return text, htmlBody
}
func replyRootTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "your conversation"
}
return title
}
const postReplyHTML = `
New reply to your conversation
{{PREHEADER}}
|
Ask a Plumber First
Bay Area · Shop Dispatch
|
|
|
New reply
The conversation has a new reply.
Hi{{GREETING}}, {{AUTHOR}} replied in:
“{{TITLE}}”
|
|
You received this because someone replied to your post on Ask a Plumber First.
|
|
`
func greetingName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
return " " + name
}