// 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 " 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>`))