Files
silo-server/internal/notifications/email_compose.go
T
QuickandClaude Fable 5 ebf3352bda feat(notifications): per-profile email channel with verified addresses
Re-key the email notification channel from login accounts to profiles.
Each profile owns its mode, dispatch watermark, and destination address;
there is deliberately no fallback to the account email, so the account
holder no longer receives mail for every household profile. A profile
receives nothing until its own address is verified.

- Genericize the watermark-sweep engine over a recipient key
  (accountChannel[K]): email keys by profile_id, Discord stays on
  user_id. Delivery reads move into the channel adapters.
- Custom addresses verify via single-use SHA-256-hashed token links
  served by a public endpoint; enabling the channel requires a verified
  address, and clearing the address switches the channel off.
- Addresses are globally unique (case-insensitive): rejected when
  verified for another profile or matching another account's email or
  username. Checked at request time, re-checked at verify time
  (first-to-verify wins), backstopped by a partial unique index.
- Every email carries an RFC 8058 one-click unsubscribe link backed by
  a per-profile capability token, minted lazily under the claim tx.
- Child profiles cannot set addresses (and so receive no email in v1).
- Verification sends are rate limited (1/min, 10/day per profile);
  mail.Message gains custom header support for List-Unsubscribe.
- Migration drops the account-level prefs table without carrying
  opt-ins over, so nobody gets surprise emails post-upgrade.

Android/Apple notification settings need follow-up for the new
profile-scoped response shape and address-management endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:55:30 -04:00

336 lines
10 KiB
Go

package notifications
import (
"fmt"
"html"
"sort"
"strings"
)
// emailMaxItemsRendered caps how many lines one email renders; the remainder
// collapses into a "+N more" line (the inbox always has everything).
const emailMaxItemsRendered = 30
// untitledSeriesGroup is the group heading for episode rows whose series
// metadata is missing or was deleted.
const untitledSeriesGroup = "New episodes"
// emailContent is one rendered notification email.
type emailContent struct {
Subject string
Text string
HTML string
}
// emailSeriesGroup is one series' new episodes, in first-appearance order.
type emailSeriesGroup struct {
seriesID string
title string
episodes []DeliveryRow
}
// emailItems is the collated, deduplicated content of one email. Accounts
// with several profiles following the same series have one delivery row per
// profile; an email reports the episode once.
type emailItems struct {
series []emailSeriesGroup
episodes int
requests []DeliveryRow
others []DeliveryRow
}
// collateEmailItems groups and dedupes delivery rows for rendering.
func collateEmailItems(rows []DeliveryRow) emailItems {
var items emailItems
seenEpisodes := make(map[string]struct{}, len(rows))
seenRequests := make(map[string]struct{}, 4)
groupIndex := make(map[string]int, 4)
for _, row := range rows {
switch row.Type {
case DeliveryTypeEpisodeAvailable:
key := row.ID
if row.EpisodeID != nil && *row.EpisodeID != "" {
key = *row.EpisodeID
}
if _, ok := seenEpisodes[key]; ok {
continue
}
seenEpisodes[key] = struct{}{}
seriesID := ""
if row.SeriesID != nil {
seriesID = *row.SeriesID
}
idx, ok := groupIndex[seriesID]
if !ok {
idx = len(items.series)
groupIndex[seriesID] = idx
title := row.SeriesTitle
if title == "" {
title = untitledSeriesGroup
}
items.series = append(items.series, emailSeriesGroup{seriesID: seriesID, title: title})
}
items.series[idx].episodes = append(items.series[idx].episodes, row)
items.episodes++
case DeliveryTypeRequestFulfilled:
key := parseRequestFulfilledFlags(row.ReasonFlags).RequestID
if key == "" {
key = row.ID
}
if _, ok := seenRequests[key]; ok {
continue
}
seenRequests[key] = struct{}{}
items.requests = append(items.requests, row)
default:
items.others = append(items.others, row)
}
}
for i := range items.series {
sort.SliceStable(items.series[i].episodes, func(a, b int) bool {
ea, eb := items.series[i].episodes[a], items.series[i].episodes[b]
if ea.SeasonNumber == nil || eb.SeasonNumber == nil ||
ea.EpisodeNumber == nil || eb.EpisodeNumber == nil {
return false
}
if *ea.SeasonNumber != *eb.SeasonNumber {
return *ea.SeasonNumber < *eb.SeasonNumber
}
return *ea.EpisodeNumber < *eb.EpisodeNumber
})
}
return items
}
// episodeCode renders "S02E03"; empty when numbering is unknown.
func episodeCode(row DeliveryRow) string {
if row.SeasonNumber == nil || row.EpisodeNumber == nil {
return ""
}
return fmt.Sprintf("S%02dE%02d", *row.SeasonNumber, *row.EpisodeNumber)
}
// episodeLine renders one episode entry: "S02E03 — Title", degrading to
// whichever part exists.
func episodeLine(row DeliveryRow) string {
code := episodeCode(row)
switch {
case code != "" && row.EpisodeTitle != "":
return code + " — " + row.EpisodeTitle
case code != "":
return code
case row.EpisodeTitle != "":
return row.EpisodeTitle
default:
return genericEpisodeTitle
}
}
// requestLine renders one fulfilled-request entry.
func requestLine(row DeliveryRow) string {
if row.SeriesTitle != "" {
return row.SeriesTitle + " is now available"
}
return "Your media request is now available"
}
// otherLine renders operational and unknown delivery types generically.
func otherLine(row DeliveryRow) string {
if row.Type == DeliveryTypeWebhookAutoDisabled {
return "A webhook stopped working — open notification settings to fix it"
}
return genericNotificationTitle
}
// countPart pluralizes "3 new episodes" style subject fragments.
func countPart(count int, singular, plural string) string {
if count == 1 {
return "1 " + singular
}
return fmt.Sprintf("%d %s", count, plural)
}
// emailSubject builds the subject line from the collated items.
func emailSubject(mode string, items emailItems) string {
parts := make([]string, 0, 3)
if items.episodes > 0 {
parts = append(parts, countPart(items.episodes, "new episode", "new episodes"))
}
if len(items.requests) > 0 {
parts = append(parts, countPart(len(items.requests), "request ready", "requests ready"))
}
if len(items.others) > 0 {
parts = append(parts, countPart(len(items.others), "update", "updates"))
}
summary := strings.Join(parts, ", ")
if mode == EmailModeDailyDigest {
return "Silo daily digest: " + summary
}
if items.episodes == 1 && len(items.requests) == 0 && len(items.others) == 0 {
row := items.series[0].episodes[0]
subject := genericEpisodeTitle
if items.series[0].title != untitledSeriesGroup {
subject += " of " + items.series[0].title
}
if line := episodeLine(row); line != genericEpisodeTitle {
subject += ": " + line
}
return subject
}
if items.episodes == 0 && len(items.requests) == 1 && len(items.others) == 0 {
return requestLine(items.requests[0])
}
return "Silo: " + summary
}
// itemURL builds a deep link; empty when no external URL is configured.
func itemURL(baseURL, itemID string) string {
if baseURL == "" || itemID == "" {
return ""
}
return baseURL + "/item/" + itemID
}
// emailComposeOptions carries the per-send rendering context.
type emailComposeOptions struct {
// BaseURL is the admin-configured external URL; empty renders without
// links.
BaseURL string
// ProfileName labels whose notifications these are — several profiles on
// one account may deliver to the same fallback address.
ProfileName string
// UnsubscribeURL is the tokenized one-click unsubscribe link; empty
// renders without one.
UnsubscribeURL string
}
// composeNotificationEmail renders one email (text + HTML) for the given
// delivery rows.
func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailComposeOptions) emailContent {
baseURL := opts.BaseURL
items := collateEmailItems(rows)
var text strings.Builder
var body strings.Builder
rendered := 0
total := items.episodes + len(items.requests) + len(items.others)
writeLine := func(plain, href string) {
rendered++
if rendered > emailMaxItemsRendered {
return
}
text.WriteString(" " + plain + "\n")
if href != "" {
body.WriteString(fmt.Sprintf(
`<li style="margin:2px 0;"><a href="%s" style="color:#6d6df7;text-decoration:none;">%s</a></li>`,
html.EscapeString(href), html.EscapeString(plain)))
} else {
body.WriteString(fmt.Sprintf(`<li style="margin:2px 0;">%s</li>`, html.EscapeString(plain)))
}
}
writeHeading := func(title, href string) {
text.WriteString(title + "\n")
label := html.EscapeString(title)
if href != "" {
label = fmt.Sprintf(`<a href="%s" style="color:inherit;text-decoration:none;">%s</a>`,
html.EscapeString(href), label)
}
body.WriteString(fmt.Sprintf(
`<h3 style="margin:14px 0 4px;font-size:15px;">%s</h3>`, label))
}
openList := func() { body.WriteString(`<ul style="margin:4px 0;padding-left:20px;">`) }
closeList := func() { body.WriteString(`</ul>`) }
for _, group := range items.series {
if rendered >= emailMaxItemsRendered {
break
}
writeHeading(group.title, itemURL(baseURL, group.seriesID))
openList()
for _, row := range group.episodes {
episodeID := ""
if row.EpisodeID != nil {
episodeID = *row.EpisodeID
}
writeLine(episodeLine(row), itemURL(baseURL, episodeID))
}
closeList()
}
if len(items.requests) > 0 && rendered < emailMaxItemsRendered {
writeHeading("Requests ready", "")
openList()
for _, row := range items.requests {
seriesID := ""
if row.SeriesID != nil {
seriesID = *row.SeriesID
}
writeLine(requestLine(row), itemURL(baseURL, seriesID))
}
closeList()
}
if len(items.others) > 0 && rendered < emailMaxItemsRendered {
writeHeading("Other updates", "")
openList()
for _, row := range items.others {
writeLine(otherLine(row), "")
}
closeList()
}
if remainder := total - emailMaxItemsRendered; remainder > 0 {
more := fmt.Sprintf("…and %d more in your Silo inbox.", remainder)
text.WriteString(more + "\n")
body.WriteString(fmt.Sprintf(
`<p style="margin:8px 0;color:#888;">%s</p>`, html.EscapeString(more)))
}
forProfile := ""
if opts.ProfileName != "" {
forProfile = " for " + opts.ProfileName
}
intro := fmt.Sprintf("New in your library%s:", forProfile)
if mode == EmailModeDailyDigest {
intro = fmt.Sprintf("Here's what's new%s since the last digest:", forProfile)
}
subjectFor := ""
if opts.ProfileName != "" {
subjectFor = " (for " + opts.ProfileName + ")"
}
profileLabel := "this profile"
if opts.ProfileName != "" {
profileLabel = "the profile “" + opts.ProfileName + "”"
}
footer := fmt.Sprintf("You're receiving this because email notifications are enabled for"+
" %s on your Silo account. Manage them in Settings → Notifications.", profileLabel)
footerHTML := html.EscapeString(footer)
if baseURL != "" {
settingsURL := html.EscapeString(baseURL + "/settings/notifications")
footerHTML = strings.Replace(footerHTML,
"Settings → Notifications",
fmt.Sprintf(`<a href="%s" style="color:#888;">Settings → Notifications</a>`, settingsURL), 1)
}
if opts.UnsubscribeURL != "" {
footer += " To stop these emails, open: " + opts.UnsubscribeURL
footerHTML += fmt.Sprintf(` <a href="%s" style="color:#888;">Unsubscribe</a>`,
html.EscapeString(opts.UnsubscribeURL))
}
htmlBody := fmt.Sprintf(`<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;max-width:560px;">
<p style="margin:0 0 8px;">%s</p>
%s
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0 8px;">
<p style="margin:0;font-size:12px;color:#888;">%s</p>
</div>`,
html.EscapeString(intro), body.String(), footerHTML)
return emailContent{
Subject: emailSubject(mode, items) + subjectFor,
Text: intro + "\n\n" + text.String() + "\n" + footer + "\n",
HTML: htmlBody,
}
}