Files
spsays-bff/internal/service/notification_service.go
T
2026-06-16 14:47:45 +05:30

795 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service — NotificationService is the single seam every
// user-facing event flows through ("the notification plugin"). Today it
// does two things per event:
//
// 1. Persists a row in `notifications` (so an in-app notification center
// and, later, websocket + push delivery can read the same history).
// 2. Sends an email to the recipient via the shared mailer.
//
// Everything here is fire-and-forget from the caller's perspective: a
// follow request must succeed even if the email bounces or the insert
// fails, so Notify* never returns an error — failures are logged. When
// websocket/push land they hook in right here alongside the email send.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"slices"
"strings"
"time"
"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/realtime"
"go.uber.org/zap"
)
// Notification kinds — short machine strings stored in notifications.kind.
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
NotifPostLike = "post_like"
NotifPostMention = "post_mention"
NotifPostComment = "post_comment"
NotifCommentReply = "comment_reply"
NotifCommentMention = "comment_mention"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
NotifChantReminder = "chant_reminder"
NotifQuoteReminder = "quote_reminder"
NotifChantIncomplete = "chant_incomplete_reminder"
)
// PrefCategories groups notification kinds into the coarser event categories
// the Settings matrix exposes — storage stays kind-level (so backfilled
// opt-outs map exactly), but the UI reads/writes whole categories.
var PrefCategories = map[string][]string{
"follows": {NotifFollowRequest, NotifFollowAccepted},
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
}
// PrefChannels is every delivery channel a matrix cell can address.
var PrefChannels = []string{
repository.ChannelInApp, repository.ChannelEmail, repository.ChannelPush,
}
type NotificationService struct {
notifs *repository.NotificationRepo
prefs *repository.NotificationPrefsRepo
users *repository.UserRepo
mail mailer.Mailer
rt *realtime.SoketiClient // optional — nil disables WebSocket push
push *PushSender // optional — nil disables Web Push
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
prefs: prefs,
users: users,
mail: mail,
rt: rt,
push: push,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
}
// disabledPrefs loads the recipient's notification opt-outs. Fails open
// (empty set): absence means enabled, and a prefs lookup hiccup must never
// silence delivery for users who opted out of nothing.
func (s *NotificationService) disabledPrefs(ctx context.Context, userID string) map[string]struct{} {
if s.prefs == nil {
return nil
}
set, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
s.logger.Warn("load notification prefs failed", zap.String("user", userID), zap.Error(err))
return nil
}
return set
}
// prefOff reports whether (kind, channel) is opted out in a disabled set.
func prefOff(set map[string]struct{}, kind, channel string) bool {
_, off := set[repository.PrefKey(kind, channel)]
return off
}
// List returns the caller's notifications, newest first.
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
if offset < 0 {
offset = 0
}
return s.notifs.ListByUser(ctx, userID, limit, offset)
}
// UnreadCount returns the caller's unread notification count (for the bell
// badge).
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
return s.notifs.CountUnread(ctx, userID)
}
// MarkAllRead marks all of the caller's notifications read.
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
return s.notifs.MarkAllRead(ctx, userID)
}
// ErrUnknownPref marks a PrefMatrix/SetPref call with an unrecognized
// category or channel (a client bug — surfaced as a 400).
var ErrUnknownPref = errors.New("unknown notification preference")
// PrefMatrix returns the resolved Settings matrix: category → channel →
// enabled. A cell reads enabled only when every kind in the category is
// enabled on that channel, so a backfilled single-kind opt-out (like emails)
// shows as the category being off rather than silently on.
func (s *NotificationService) PrefMatrix(ctx context.Context, userID string) (map[string]map[string]bool, error) {
off, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
// The handler sanitizes this into a generic 500 — log the real
// cause here or it's invisible in production.
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
return nil, err
}
matrix := make(map[string]map[string]bool, len(PrefCategories))
for category, kinds := range PrefCategories {
row := make(map[string]bool, len(PrefChannels))
for _, ch := range PrefChannels {
enabled := true
for _, kind := range kinds {
if prefOff(off, kind, ch) {
enabled = false
break
}
}
row[ch] = enabled
}
matrix[category] = row
}
return matrix, nil
}
// SetPref writes one category × channel cell, fanning out to every kind in
// the category, and keeps the legacy users columns in sync for old clients.
func (s *NotificationService) SetPref(ctx context.Context, userID, category, channel string, enabled bool) error {
kinds, ok := PrefCategories[category]
if !ok {
return ErrUnknownPref
}
if !slices.Contains(PrefChannels, channel) {
return ErrUnknownPref
}
var err error
if enabled {
err = s.prefs.Enable(ctx, userID, kinds, []string{channel})
} else {
err = s.prefs.Disable(ctx, userID, kinds, []string{channel})
}
if err == nil {
err = s.prefs.SyncLegacyColumns(ctx, userID)
}
if err != nil {
s.logger.Error("notification prefs: write failed",
zap.String("user", userID), zap.String("category", category),
zap.String("channel", channel), zap.Error(err))
}
return err
}
// LikeEmailPref / SetLikeEmailPref back the legacy like-email endpoint —
// now a shim over the prefs table (post_like × email) that dual-writes the
// old users column. Kept until the matrix UI is fully rolled out.
func (s *NotificationService) LikeEmailPref(ctx context.Context, userID string) (bool, error) {
off, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
return false, err
}
return !prefOff(off, NotifPostLike, repository.ChannelEmail), nil
}
func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error {
kinds, channels := []string{NotifPostLike}, []string{repository.ChannelEmail}
var err error
if enabled {
err = s.prefs.Enable(ctx, userID, kinds, channels)
} else {
err = s.prefs.Disable(ctx, userID, kinds, channels)
}
if err == nil {
err = s.prefs.SyncLegacyColumns(ctx, userID)
}
if err != nil {
s.logger.Error("notification prefs: like-email write failed",
zap.String("user", userID), zap.Error(err))
}
return err
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
"%s requested to follow you — Prabhupāda Says")
}
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
// follow request (i.e. the recipient can now see actor's activity).
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
"%s accepted your follow request — Prabhupāda Says")
}
// NotifyPostLike tells the post author that `likerID` liked their post. Sent
// only when the liker isn't the author (callers also guard). Unlike follows,
// likes are high-volume, so this delivers a persisted row + realtime nudge +
// Web Push but NO email. The push is tagged per-post so repeated likes on the
// same post collapse into one notification rather than stacking.
func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, authorID, likerID string) {
if authorID == "" || likerID == "" || authorID == likerID {
return
}
off := s.disabledPrefs(ctx, authorID)
// Aggregate into the single (author, post) like-notification row — always,
// even with in-app off: the row carries the like aggregation + email
// throttle state, and read-time filtering keeps it out of the feed.
count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle)
if err != nil {
s.logger.Warn("aggregate like notification failed",
zap.String("recipient", authorID), zap.Error(err))
count, emailDue = 1, false
}
actorUser, _ := s.users.FindByID(ctx, likerID)
who := "Someone"
if actorUser != nil {
who = actorDisplay(actorUser)
}
body := likeMessage(who, count)
// Realtime nudge — carry actor_count so the toast can read "and N others".
if s.rt != nil && !prefOff(off, NotifPostLike, repository.ChannelInApp) {
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
if actorUser != nil {
payload["actor_name"] = actorDisplay(actorUser)
payload["actor_handle"] = actorUser.Handle
payload["actor_avatar_url"] = actorUser.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
}
}
// Tapping a notification opens the author's own feed anchored to the post.
author, _ := s.users.FindByID(ctx, authorID)
url := s.frontendURL + "/notifications"
if author != nil {
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
}
// Web Push — tagged per-post so repeated likes collapse into one.
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
})
}
// Aggregated email — only when the throttle says one is due (so a burst of
// likes yields a single email), the author has an address, and they
// haven't opted out. `body` already aggregates ("X and N others …").
if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" {
return
}
if prefOff(off, NotifPostLike, repository.ChannelEmail) {
return
}
var buf bytes.Buffer
if err := likeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": author.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render like email failed", zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*author.Email), "New likes on your post — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("like email send failed", zap.String("to", *author.Email), zap.Error(err))
}
}
// likeEmailThrottle is the minimum gap between aggregated like emails for a
// single post — a burst of likes within the window produces one email.
const likeEmailThrottle = 6 * time.Hour
// NotifyPostMention tells `mentionedID` that `actorID` (the post author)
// tagged them in a post. Persisted row + realtime + Web Push; no email
// (push/in-app is enough, and a post can tag many people). Skips self-tags.
func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID, actorID, postID string) {
if mentionedID == "" || actorID == "" || mentionedID == actorID {
return
}
off := s.disabledPrefs(ctx, mentionedID)
if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil {
s.logger.Warn("persist mention notification failed",
zap.String("recipient", mentionedID), zap.Error(err))
}
actorUser, _ := s.users.FindByID(ctx, actorID)
if !prefOff(off, NotifPostMention, repository.ChannelInApp) {
s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser)
}
if s.push == nil || prefOff(off, NotifPostMention, repository.ChannelPush) {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actorUser != nil {
who = actorDisplay(actorUser)
url = s.frontendURL + "/u/" + actorUser.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, mentionedID, PushPayload{
Title: "You were mentioned",
Body: who + " mentioned you in a post",
URL: url,
Icon: s.iconURL(),
Tag: NotifPostMention + ":" + postID,
})
}
// NotifyPostComment tells the post author that `commenterID` commented on
// their post. NotifyCommentReply tells `recipientID` (the author of the
// comment being replied to) that `replierID` replied. Both: persisted row +
// realtime + Web Push, no email (comments are interactive, not digest-worthy).
// Self-actions are skipped.
func (s *NotificationService) NotifyPostComment(ctx context.Context, postID, authorID, commenterID string) {
s.notifyComment(ctx, NotifPostComment, postID, authorID, commenterID)
}
func (s *NotificationService) NotifyCommentReply(ctx context.Context, postID, recipientID, replierID string) {
s.notifyComment(ctx, NotifCommentReply, postID, recipientID, replierID)
}
func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, recipientID, actorID string) {
if recipientID == "" || actorID == "" || recipientID == actorID {
return
}
off := s.disabledPrefs(ctx, recipientID)
if err := s.notifs.CreateComment(ctx, recipientID, actorID, postID, kind); err != nil {
s.logger.Warn("persist comment notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
}
actorUser, _ := s.users.FindByID(ctx, actorID)
if !prefOff(off, kind, repository.ChannelInApp) {
s.publishRealtime(ctx, recipientID, kind, actorUser)
}
if s.push == nil || prefOff(off, kind, repository.ChannelPush) {
return
}
who := "Someone"
if actorUser != nil {
who = actorDisplay(actorUser)
}
// Tapping it opens the post (via its author's handle — the actor isn't the
// post owner on a reply). Falls back to the notification center.
url := s.frontendURL + "/notifications"
if h, err := s.notifs.PostAuthorHandle(ctx, postID); err == nil && h != "" {
url = s.frontendURL + "/u/" + h + "/posts#post-" + postID
}
title, body := "New comment", who+" commented on your post"
switch kind {
case NotifCommentReply:
title, body = "New reply", who+" replied to your comment"
case NotifCommentMention:
title, body = "You were mentioned", who+" mentioned you in a comment"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: title,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: kind + ":" + postID,
})
}
// NotifyCommentMentions tells each user named by `handles` that `actorID`
// mentioned them in a comment on `postID`. Handles are resolved to users,
// deduped, and the actor plus any `skip` ids (e.g. the post/parent author who
// already got a comment/reply notification) are excluded so nobody is
// double-pinged. Reuses the comment notification path (row + realtime + push).
func (s *NotificationService) NotifyCommentMentions(ctx context.Context, postID, actorID string, handles []string, skip ...string) {
if actorID == "" || len(handles) == 0 {
return
}
skipSet := map[string]bool{actorID: true}
for _, id := range skip {
if id != "" {
skipSet[id] = true
}
}
for _, h := range handles {
u, err := s.users.FindByHandle(ctx, h)
if err != nil || u == nil || skipSet[u.ID] {
continue
}
skipSet[u.ID] = true // dedupe repeats of the same mention
s.notifyComment(ctx, NotifCommentMention, postID, u.ID, actorID)
}
}
// likeMessage renders the aggregated like text: "X liked your post" or
// "X and N others liked your post".
func likeMessage(who string, count int) string {
if count <= 1 {
return who + " liked your post"
}
others := count - 1
noun := "others"
if others == 1 {
noun = "other"
}
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
}
// dispatch persists the notification and sends it over every channel the
// recipient hasn't opted out of. All failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
off := s.disabledPrefs(ctx, recipientID)
// Always persist — the in-app pref filters display at read time, and
// the row is the durable record either way.
actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
// Persistence failed, but still try the other channels.
}
// Look up the actor once — both the realtime push and the email use it.
actorUser, aerr := s.users.FindByID(ctx, actorID)
if aerr != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(aerr))
}
// Realtime push (best-effort) — a lightweight nudge so the client can
// bump the bell + toast immediately. Part of the in-app channel.
if !prefOff(off, kind, repository.ChannelInApp) {
s.publishRealtime(ctx, recipientID, kind, actorUser)
}
// Web Push (best-effort) — reaches the recipient even with the app
// closed. Same seam as realtime/email; nil sender is a no-op.
if !prefOff(off, kind, repository.ChannelPush) {
s.publishPush(ctx, recipientID, kind, actorUser)
}
// Email (best-effort).
if prefOff(off, kind, repository.ChannelEmail) {
return
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
return
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row + realtime push are the notification.
return
}
if actorUser == nil {
// Can't render the email body without the actor's name.
return
}
body, err := s.render(kind, recipient, actorUser)
if err != nil {
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
return
}
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
s.logger.Warn("notification email send failed",
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
// publishRealtime pushes a lightweight notification event to the
// recipient's Soketi channel. PUBLIC channel for now ("notifications.{id}")
// — switch to a "private-" prefix once Soketi App auth is wired up. The
// payload carries just enough for a toast; the client refetches the
// authoritative unread count + list from REST.
func (s *NotificationService) publishRealtime(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.rt == nil {
return
}
payload := map[string]any{"kind": kind}
if actor != nil {
payload["actor_name"] = actorDisplay(actor)
payload["actor_handle"] = actor.Handle
payload["actor_avatar_url"] = actor.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", payload); err != nil {
s.logger.Warn("realtime notification publish failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
}
}
// SendReminder delivers an ephemeral sādhana reminder across all live
// channels — a websocket toast (no unread bump, not persisted), a Web Push,
// and an email. Best-effort; nothing is persisted to the notification
// center. `url` is where tapping the notification lands.
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
off := s.disabledPrefs(ctx, userID)
// Realtime toast — a distinct "reminder" event so the client toasts
// without bumping the unread badge (reminders have no DB row).
if s.rt != nil && !prefOff(off, kind, repository.ChannelInApp) {
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
"kind": kind, "title": title, "body": body, "url": url,
})
}
// Web Push (reaches closed apps).
if s.push != nil && !prefOff(off, kind, repository.ChannelPush) {
s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Icon: s.iconURL(), Tag: kind, RequireInteraction: true})
}
// Email (only if there's an address on file).
if prefOff(off, kind, repository.ChannelEmail) {
return
}
recipient, err := s.users.FindByID(ctx, userID)
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
return
}
var buf bytes.Buffer
if err := reminderEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil {
s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
// publishPush delivers a Web Push notification to the recipient's devices.
// The body quotes the actor; tapping it opens their profile (where a pending
// follow request can be accepted). Best-effort; nil sender is a no-op.
func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actor != nil {
who = actorDisplay(actor)
url = s.frontendURL + "/u/" + actor.Handle
}
// Title must be meaningful, not the app name: iOS renders installed-PWA
// notifications as "<title> from <app name>", so a generic "Prabhupāda
// Says" title reads as "Prabhupāda Says from Prabhupāda Says".
var title, body string
switch kind {
case NotifFollowRequest:
title = "New Friend Request"
body = who + " requested to follow you"
case NotifFollowAccepted:
title = "Friend Request Accepted"
body = who + " accepted your follow request"
default:
title = "New notification"
body = "You have a new notification"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: title,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: kind,
})
}
// NotifyAdminMessage delivers a free-text message from an admin to one user:
// a persisted in-app notification (kind 'admin_message') plus best-effort
// realtime, Web Push, and email. Admin messages bypass the per-kind opt-out
// matrix (there's no category for them) — they're deliberate, low-volume, and
// operationally important. Returns an error only if the row couldn't be
// persisted; the three delivery channels are best-effort.
func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientID, title, body string) error {
if err := s.notifs.CreateAdminMessage(ctx, recipientID, body); err != nil {
return err
}
url := s.frontendURL + "/notifications"
if s.rt != nil {
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", map[string]any{
"kind": "admin_message",
"body": body,
}); err != nil {
s.logger.Warn("realtime admin message publish failed",
zap.String("recipient", recipientID), zap.Error(err))
}
}
if s.push != nil {
pushTitle := strings.TrimSpace(title)
if pushTitle == "" {
pushTitle = "Prabhupāda Says"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: pushTitle,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: "admin_message",
})
}
// Email — best-effort, only when the recipient has a verified-or-not address.
recipient, _ := s.users.FindByID(ctx, recipientID)
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
var buf bytes.Buffer
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Title": strings.TrimSpace(title),
"Body": body,
"URL": url,
}); err != nil {
s.logger.Warn("render admin message email failed", zap.Error(err))
} else {
subject := strings.TrimSpace(title)
if subject == "" {
subject = "A message from Prabhupāda Says"
}
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
s.logger.Warn("admin message email send failed", zap.Error(err))
}
}
}
return nil
}
// iconURL is the absolute URL of the notification icon (the app's square
// icon), so it renders regardless of the client's base path.
func (s *NotificationService) iconURL() string {
return strings.TrimRight(s.frontendURL, "/") + "/icon-192.png"
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
"ActorName": actorDisplay(actor),
"ActorHandle": actor.Handle,
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
}
var tpl *template.Template
switch kind {
case NotifFollowRequest:
tpl = followRequestEmailTpl
case NotifFollowAccepted:
tpl = followAcceptedEmailTpl
default:
tpl = followRequestEmailTpl
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// actorDisplay prefers the display name, falling back to @handle.
func actorDisplay(u *model.User) string {
if n := strings.TrimSpace(u.Name); n != "" {
return n
}
return "@" + u.Handle
}
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
// the actor and link to their profile (where the recipient can accept a
// pending request).
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has requested to follow you on Prabhupāda Says.</p>
<p>You can review and accept or decline the request from their profile:</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Daily sādhana reminder. The Message is the recency-aware nudge; the CTA
// opens the streak page.
var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open your sādhana</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn these reminders off any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Aggregated like email. Message is "X and N others liked your post"; the CTA
// opens the author's post.
var likeEmailTpl = template.Must(template.New("postLike").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}.</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your post</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn off like emails any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// adminMessageEmailTpl renders an admin → user direct message. Body is
// auto-escaped by html/template, so admin-entered text can't inject markup.
var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
<p style="white-space:pre-line">{{.Body}}</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))