diff --git a/go.mod b/go.mod
index 3bc0bb1..70996dc 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 68a74c1..8f81fc0 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/mail/announcement.go b/mail/announcement.go
new file mode 100644
index 0000000..afc4316
--- /dev/null
+++ b/mail/announcement.go
@@ -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 \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, "on a schedule") {
+ t.Error("markdown not rendered to html")
+ }
+ if strings.Contains(html, "