CI / test (pull_request) Successful in 6m23s
Notify direct parent authors throughout threaded conversations while skipping self-replies, edits, and recipients without email.
206 lines
7.5 KiB
Go
206 lines
7.5 KiB
Go
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 = `<!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>New reply to your conversation</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;">New reply</div>
|
|
<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}}, {{AUTHOR}} replied in:</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 reply</div>
|
|
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{REPLY}}</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 reply →</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 someone replied to your post on Ask a Plumber First.
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`
|
|
|
|
func greetingName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
return " " + name
|
|
}
|