feat: announcement email with one-click unsubscribe headers
This commit is contained in:
@@ -16,6 +16,7 @@ require (
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
|
||||
@@ -21,6 +21,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
htmltmpl "html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
// Announcement is one product email to one person: a feature launch, a guide,
|
||||
// an offer. vantage-admin's announce package builds it per recipient, because
|
||||
// the unsubscribe links carry that person's token.
|
||||
type Announcement struct {
|
||||
CategoryLabel string // "New features"
|
||||
Subject string
|
||||
BodyMarkdown string
|
||||
UnsubscribeURL string // RFC 8058 one-click target for this category
|
||||
PreferencesURL string // the no-login preferences page
|
||||
PostalAddress string // optional footer line
|
||||
}
|
||||
|
||||
type announcementData struct {
|
||||
Announcement
|
||||
BodyHTML htmltmpl.HTML
|
||||
}
|
||||
|
||||
// md renders with goldmark's defaults, which omit raw HTML: a pasted <script>
|
||||
// never reaches an inbox.
|
||||
var md = goldmark.New()
|
||||
|
||||
func announcementMessage(a Announcement, publicURL string) (message, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(a.BodyMarkdown), &buf); err != nil {
|
||||
return message{}, fmt.Errorf("mail: markdown: %w", err)
|
||||
}
|
||||
return render("announcement", announcementData{Announcement: a, BodyHTML: htmltmpl.HTML(buf.String())}, publicURL)
|
||||
}
|
||||
|
||||
// RenderAnnouncement is the staff preview: exactly what a recipient gets.
|
||||
func RenderAnnouncement(a Announcement, publicURL string) (subject, html, text string, err error) {
|
||||
m, err := announcementMessage(a, publicURL)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return m.Subject, m.HTML, m.Text, nil
|
||||
}
|
||||
|
||||
// SendAnnouncement sends one announcement to one address, with the
|
||||
// List-Unsubscribe pair Gmail and Yahoo require of bulk senders.
|
||||
func (s Sender) SendAnnouncement(to string, a Announcement) error {
|
||||
if strings.Contains(to, ",") {
|
||||
return fmt.Errorf("mail: an announcement goes to exactly one address")
|
||||
}
|
||||
if a.UnsubscribeURL == "" {
|
||||
return fmt.Errorf("mail: announcement without an unsubscribe URL")
|
||||
}
|
||||
m, err := announcementMessage(a, s.PublicURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.To = to
|
||||
m.Headers = map[string]string{
|
||||
"List-Unsubscribe": "<" + a.UnsubscribeURL + ">",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
return s.send(m)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sampleAnnouncement() Announcement {
|
||||
return Announcement{
|
||||
CategoryLabel: "New features",
|
||||
Subject: "New in Vantage: scheduled workflows",
|
||||
BodyMarkdown: "Workflows can now run **on a schedule**.\n\n<script>alert(1)</script>\n\n- cron syntax\n- time zones",
|
||||
UnsubscribeURL: "https://api.example/email/unsubscribe?c=features&t=abc",
|
||||
PreferencesURL: "https://hq.example/email-preferences?t=abc",
|
||||
PostalAddress: "Hostxtra Ltd, 1 High Street, Leeds",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAnnouncement(t *testing.T) {
|
||||
subject, html, text, err := RenderAnnouncement(sampleAnnouncement(), "https://hq.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if subject != "New in Vantage: scheduled workflows" {
|
||||
t.Errorf("subject = %q", subject)
|
||||
}
|
||||
if !strings.Contains(html, "<strong>on a schedule</strong>") {
|
||||
t.Error("markdown not rendered to html")
|
||||
}
|
||||
if strings.Contains(html, "<script>") {
|
||||
t.Error("raw html from markdown reached the email")
|
||||
}
|
||||
for label, body := range map[string]string{"html": html, "text": text} {
|
||||
for _, want := range []string{"email/unsubscribe?c=features", "email-preferences?t=abc", "New features", "Hostxtra Ltd"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q", label, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "—") {
|
||||
t.Errorf("%s body contains an em dash", label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "**on a schedule**") {
|
||||
t.Error("text part should carry the markdown source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAnnouncementWithoutPostalAddress(t *testing.T) {
|
||||
a := sampleAnnouncement()
|
||||
a.PostalAddress = ""
|
||||
_, html, _, err := RenderAnnouncement(a, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(html, "<no value>") {
|
||||
t.Error("empty postal address rendered as <no value>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAnnouncementRefusesListsAndMissingLink(t *testing.T) {
|
||||
s := Sender{Host: "smtp.invalid", From: "updates@example.com"}
|
||||
if err := s.SendAnnouncement("a@example.com, b@example.com", sampleAnnouncement()); err == nil {
|
||||
t.Error("a comma-separated To must be refused")
|
||||
}
|
||||
a := sampleAnnouncement()
|
||||
a.UnsubscribeURL = ""
|
||||
if err := s.SendAnnouncement("a@example.com", a); err == nil {
|
||||
t.Error("an announcement without an unsubscribe URL must be refused")
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -20,18 +20,18 @@ func cases() map[string]any {
|
||||
"invite": s.inviteData("sam@example.com", "Northwind Ops", "8a1b2c3d4e"),
|
||||
"license": licenseData("northwind-prod",
|
||||
"eyJ2IjoxLCJpbnN0YW5jZSI6Im5vcnRod2luZC1wcm9kIiwidGllciI6ImZyZWUiLCJleHAiOjE3OTk5OTk5OTl9.MEUCIQDk3v1rX0sX7kQ2m9WQ1o0q8pWm6lq3vRjz8yN0yX4bGQIgYk2Fh7s"),
|
||||
"instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)),
|
||||
"instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)),
|
||||
"renewed": renewedData("northwind-prod", now.Add(30*day)),
|
||||
"expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)),
|
||||
"expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)),
|
||||
"expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}),
|
||||
"deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7),
|
||||
"deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1),
|
||||
"cancelled": s.billingData("northwind-prod"),
|
||||
"pastdue": s.billingData("northwind-prod"),
|
||||
"monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true},
|
||||
"monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now},
|
||||
"instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)),
|
||||
"instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)),
|
||||
"renewed": renewedData("northwind-prod", now.Add(30*day)),
|
||||
"expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)),
|
||||
"expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)),
|
||||
"expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}),
|
||||
"deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7),
|
||||
"deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1),
|
||||
"cancelled": s.billingData("northwind-prod"),
|
||||
"pastdue": s.billingData("northwind-prod"),
|
||||
"monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true},
|
||||
"monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now},
|
||||
"contact": struct {
|
||||
Enquiry
|
||||
Received string
|
||||
@@ -47,6 +47,10 @@ func cases() map[string]any {
|
||||
{CVEID: "CVE-2025-48112", Severity: "low", PackageName: "less", ServerName: "worker-03"},
|
||||
},
|
||||
More: 9, DBAge: "2 days"},
|
||||
"announcement": announcementData{
|
||||
Announcement: sampleAnnouncement(),
|
||||
BodyHTML: "<p>Workflows can now run <strong>on a schedule</strong>.</p>",
|
||||
},
|
||||
"accountlocked": sampleNotice(),
|
||||
"disputereminder": sampleNotice(),
|
||||
"accountterminated": sampleNotice(),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}{{.CategoryLabel}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" .CategoryLabel "tone" "accent")}}{{end}}
|
||||
{{define "title"}}{{.Subject}}{{end}}
|
||||
{{define "body"}}
|
||||
<div style="color:#e4ecf6;font-size:15px;line-height:1.6;">{{.BodyHTML}}</div>
|
||||
<p style="margin:24px 0 0;color:#71879f;font-size:12px;line-height:1.6;"><a href="{{.UnsubscribeURL}}" style="color:#9fb3ca;">Unsubscribe from {{.CategoryLabel}}</a> · <a href="{{.PreferencesURL}}" style="color:#9fb3ca;">Email preferences</a>{{if .PostalAddress}}<br>{{.PostalAddress}}{{end}}</p>
|
||||
{{end}}
|
||||
{{define "why"}}you have a Vantage account and are subscribed to {{.CategoryLabel}}. Account emails such as sign-in, licences and billing are sent whatever you choose here.{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "subject"}}{{.Subject}}{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}{{.CategoryLabel}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" .CategoryLabel "tone" "accent")}}{{end}}
|
||||
{{define "title"}}{{.Subject}}{{end}}
|
||||
{{define "body"}}
|
||||
{{.BodyMarkdown}}
|
||||
|
||||
Unsubscribe from {{.CategoryLabel}}: {{.UnsubscribeURL}}
|
||||
Email preferences: {{.PreferencesURL}}
|
||||
{{if .PostalAddress}}{{.PostalAddress}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}you have a Vantage account and are subscribed to {{.CategoryLabel}}. Account emails such as sign-in, licences and billing are sent whatever you choose here.{{end}}
|
||||
Reference in New Issue
Block a user