diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go
index a34b9d7..c05eec7 100644
--- a/internal/service/admin_service.go
+++ b/internal/service/admin_service.go
@@ -11,6 +11,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
+ "github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap"
)
@@ -335,8 +336,9 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
var buf bytes.Buffer
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
"RecipientName": name,
- "Reply": message,
- "URL": s.frontendURL,
+ // Rendered markers; escaped by richtext, newlines become
.
+ "Reply": richtext.ToHTML(message),
+ "URL": s.frontendURL,
}); err != nil {
s.logger.Warn("render feedback reply email failed", zap.Error(err))
return true, false, nil
@@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<
Thank you for writing to us. Here's our reply to your feedback:
-{{.Reply}}+
{{.Reply}}
Open Prabhupāda Says
diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go
index 3ea4fc4..37b60ea 100644
--- a/internal/service/notification_service.go
+++ b/internal/service/notification_service.go
@@ -26,6 +26,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
+ "github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap"
)
@@ -300,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
- Body: body,
+ Body: richtext.Plain(body),
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
@@ -642,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
if s.rt != nil {
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
"kind": "admin_message",
- "body": body,
+ "body": richtext.Plain(body),
}); err != nil {
s.logger.Warn("realtime admin message publish failed",
zap.String("recipient", recipientID), zap.Error(err))
@@ -670,8 +671,10 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Title": strings.TrimSpace(title),
- "Body": body,
- "URL": url,
+ // Rendered markers (bold, links…). Already escaped by richtext,
+ // and its
s replace the old white-space:pre-line.
+ "Body": richtext.ToHTML(body),
+ "URL": url,
}); err != nil {
s.logger.Warn("render admin message email failed", zap.Error(err))
} else {
@@ -882,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`
{{.Body}}
+{{.Body}}
Open Prabhupāda Says
diff --git a/pkg/richtext/richtext.go b/pkg/richtext/richtext.go
new file mode 100644
index 0000000..06a47ce
--- /dev/null
+++ b/pkg/richtext/richtext.go
@@ -0,0 +1,121 @@
+// Package richtext renders the light formatting the admin panel's message
+// fields allow, for the two places that text leaves the app:
+//
+// - ToHTML, for email bodies.
+// - Plain, for push notifications and toasts, which can't render markup.
+//
+// The format is a handful of markers typed into a plain textarea:
+//
+// *bold* _italic_ __underline__ ~strike~ [label](https://…)
+//
+// plus bare http(s) URLs, which become links on their own. *bold* and ~strike~
+// match what chat already uses, so the two read the same.
+//
+// Safety comes from the order of operations: the input is HTML-escaped FIRST,
+// and only then are the markers turned into tags. Nothing an author types can
+// therefore produce markup of its own — the only tags in the output are the
+// ones this package writes. Links are limited to http and https, so a
+// `javascript:` URL is never made clickable.
+package richtext
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "regexp"
+ "strings"
+)
+
+var (
+ // A labelled link, or a bare URL. One alternation so a URL is never
+ // linked twice — once as the label's target and once on its own.
+ linkRe = regexp.MustCompile(`\[([^\]\n]+)\]\((https?://[^\s)]+)\)|(https?://[^\s<]+)`)
+
+ underlineRe = regexp.MustCompile(`__([^_\n]+?)__`)
+ boldRe = regexp.MustCompile(`\*([^*\n]+?)\*`)
+ strikeRe = regexp.MustCompile(`~([^~\n]+?)~`)
+ // Italic needs a non-word character (or an edge) on each side, so the
+ // underscores inside snake_case or a handle aren't read as markers. RE2
+ // has no lookbehind, so the neighbours are captured and put back.
+ italicRe = regexp.MustCompile(`(^|[^\w])_([^_\n]+?)_($|[^\w])`)
+
+ // Punctuation that ends a sentence rather than belonging to the URL.
+ trailingPunct = ".,;:!?)"
+)
+
+// placeholder marks where a finished link sits while markers are applied, so
+// the underscores and tildes common in URLs aren't read as formatting.
+func placeholder(i int) string { return fmt.Sprintf("\x00%d\x00", i) }
+
+// splitTrailing separates sentence punctuation from the end of a bare URL.
+func splitTrailing(u string) (url, tail string) {
+ end := len(u)
+ for end > 0 && strings.ContainsRune(trailingPunct, rune(u[end-1])) {
+ end--
+ }
+ return u[:end], u[end:]
+}
+
+// ToHTML renders the markers as HTML for an email body.
+func ToHTML(s string) template.HTML {
+ escaped := html.EscapeString(strings.ReplaceAll(s, "\r\n", "\n"))
+
+ var links []string
+ withHolders := linkRe.ReplaceAllStringFunc(escaped, func(m string) string {
+ parts := linkRe.FindStringSubmatch(m)
+ var label, href, tail string
+ if parts[1] != "" {
+ label, href = parts[1], parts[2]
+ } else {
+ href, tail = splitTrailing(parts[3])
+ label = href
+ }
+ // href is already escaped (it came from the escaped input), so it is
+ // safe inside the attribute as-is.
+ links = append(links, fmt.Sprintf(
+ `%s`, href, label))
+ return placeholder(len(links)-1) + tail
+ })
+
+ out := underlineRe.ReplaceAllString(withHolders, `$1`)
+ out = boldRe.ReplaceAllString(out, `$1`)
+ out = strikeRe.ReplaceAllString(out, `$1`)
+ out = italicRe.ReplaceAllString(out, `$1$2$3`)
+
+ for i, l := range links {
+ out = strings.Replace(out, placeholder(i), l, 1)
+ }
+ out = strings.ReplaceAll(out, "\n", "
")
+
+ // Every tag here was written by this function, and all author text was
+ // escaped before any of them were added.
+ return template.HTML(out) //nolint:gosec // see above
+}
+
+// Plain strips the markers for surfaces that can't render them — a push
+// notification or a toast. A labelled link keeps only its label; a bare URL
+// stays as written, since it is still useful to see.
+func Plain(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+
+ var links []string
+ withHolders := linkRe.ReplaceAllStringFunc(s, func(m string) string {
+ parts := linkRe.FindStringSubmatch(m)
+ if parts[1] != "" {
+ links = append(links, parts[1])
+ } else {
+ links = append(links, parts[3])
+ }
+ return placeholder(len(links) - 1)
+ })
+
+ out := underlineRe.ReplaceAllString(withHolders, `$1`)
+ out = boldRe.ReplaceAllString(out, `$1`)
+ out = strikeRe.ReplaceAllString(out, `$1`)
+ out = italicRe.ReplaceAllString(out, `$1$2$3`)
+
+ for i, l := range links {
+ out = strings.Replace(out, placeholder(i), l, 1)
+ }
+ return out
+}
diff --git a/pkg/richtext/richtext_test.go b/pkg/richtext/richtext_test.go
new file mode 100644
index 0000000..d0aa0f3
--- /dev/null
+++ b/pkg/richtext/richtext_test.go
@@ -0,0 +1,85 @@
+package richtext
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestToHTML_Markers(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"*bold*", "bold"},
+ {"_italic_", "italic"},
+ {"__under__", "under"},
+ {"~gone~", "gone"},
+ {"a *b* and _c_", "a b and c"},
+ {"line one\nline two", "line one
line two"},
+ // Underscores inside words are not italics.
+ {"snake_case_name", "snake_case_name"},
+ {"@some_handle here", "@some_handle here"},
+ }
+ for _, c := range cases {
+ if got := string(ToHTML(c.in)); got != c.want {
+ t.Errorf("ToHTML(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestToHTML_Links(t *testing.T) {
+ got := string(ToHTML("see [the gītā](https://prabhupadasays.org/books/bg)"))
+ if !strings.Contains(got, `href="https://prabhupadasays.org/books/bg"`) ||
+ !strings.Contains(got, ">the gītā") {
+ t.Errorf("labelled link: %q", got)
+ }
+
+ // A bare URL becomes a link, but sentence punctuation stays outside it.
+ got = string(ToHTML("Visit https://prabhupadasays.org."))
+ if !strings.Contains(got, `href="https://prabhupadasays.org"`) ||
+ !strings.HasSuffix(got, ".") {
+ t.Errorf("bare link with trailing period: %q", got)
+ }
+
+ // Underscores and tildes inside a URL are not formatting.
+ got = string(ToHTML("https://example.com/a_b_c~d"))
+ if strings.Contains(got, "") || strings.Contains(got, "") {
+ t.Errorf("markers applied inside a URL: %q", got)
+ }
+}
+
+// The security property: nothing typed can become markup of its own.
+func TestToHTML_EscapesAuthorInput(t *testing.T) {
+ cases := []string{
+ ``,
+ ``,
+ `*bold*`,
+ `[click](javascript:alert(1))`,
+ `[x](https://ok.example/" onmouseover="alert(1))`,
+ }
+ for _, in := range cases {
+ got := string(ToHTML(in))
+ if strings.Contains(got, "