Files
silo-server/internal/notifications/email_digest.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

231 lines
7.8 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/mail"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/jackc/pgx/v5"
)
// emailChannel implements accountChannel over the shared SMTP core, keyed by
// profile ID. The engine owns the sweep loop and watermark; this adapter only
// knows how to list/claim email prefs rows and compose+send one profile's
// message to its resolved destination (verified custom address, else the
// account email).
type emailChannel struct {
prefs *EmailPrefsRepository
deliveries *DeliveryRepository
settings *Settings
sender mail.Sender
// profileName resolves a display name for the email copy; best-effort
// (empty on any failure). Set by NewSystem after construction.
profileName func(ctx context.Context, userID int, profileID string) string
}
// The assertion also keeps staticcheck's unused-analysis aware that the
// adapter methods are consumed through the generic engine interface.
var _ accountChannel[string] = (*emailChannel)(nil)
func (c *emailChannel) name() string { return "email" }
func (c *emailChannel) enabled(ctx context.Context) bool {
return c.settings.EmailEnabled(ctx) && c.sender.Enabled(ctx)
}
func (c *emailChannel) allowPerEpisode(ctx context.Context) bool {
return c.settings.EmailAllowPerEpisode(ctx)
}
func (c *emailChannel) digestHour(ctx context.Context) int {
return c.settings.EmailDigestHour(ctx)
}
func (c *emailChannel) listRecipients(ctx context.Context) ([]accountRecipient[string], error) {
return c.prefs.ListActiveRecipients(ctx)
}
func (c *emailChannel) hasPendingSince(ctx context.Context, profileID string, since Cursor) (bool, error) {
return c.deliveries.HasForProfileSince(ctx, profileID, since)
}
func (c *emailChannel) listSince(ctx context.Context, tx pgx.Tx, profileID string, since Cursor, limit int) ([]DeliveryRow, error) {
return c.deliveries.ListForProfileSince(ctx, tx, profileID, since, limit)
}
func (c *emailChannel) claim(ctx context.Context, tx pgx.Tx, profileID string) (*accountRecipient[string], error) {
return c.prefs.claimForUpdate(ctx, tx, profileID)
}
func (c *emailChannel) markSent(ctx context.Context, tx pgx.Tx, profileID string, watermark Cursor, digestAt *time.Time) error {
return c.prefs.markSent(ctx, tx, profileID, watermark, digestAt)
}
func (c *emailChannel) markFailure(ctx context.Context, tx pgx.Tx, profileID string, _ error) error {
return c.prefs.markFailure(ctx, tx, profileID)
}
// send composes and sends one profile's pending notifications. The
// destination is re-read under the claim so a mid-pass address removal fails
// cleanly instead of sending to a stale recipient.
func (c *emailChannel) send(ctx context.Context, tx pgx.Tx, profileID string, mode string, rows []DeliveryRow) error {
email, userID, unsubscribeToken, err := c.prefs.destinationForSend(ctx, tx, profileID)
if err != nil {
return err
}
if email == "" {
return fmt.Errorf("profile %s has no usable email address", profileID)
}
// The token is minted lazily, under the claim lock, right before the
// first email that embeds it — this is the only mint point.
if unsubscribeToken == "" {
unsubscribeToken, _, err = newEmailToken()
if err != nil {
return err
}
if err := c.prefs.setUnsubscribeToken(ctx, tx, profileID, unsubscribeToken); err != nil {
return err
}
}
baseURL := c.settings.EmailExternalURL(ctx)
opts := emailComposeOptions{
BaseURL: baseURL,
UnsubscribeURL: emailUnsubscribeURL(baseURL, unsubscribeToken),
}
if c.profileName != nil {
opts.ProfileName = c.profileName(ctx, userID, profileID)
}
content := composeNotificationEmail(mode, rows, opts)
msg := mail.Message{
To: []string{email},
Subject: content.Subject,
TextBody: content.Text,
HTMLBody: content.HTML,
}
if opts.UnsubscribeURL != "" {
// RFC 8058 one-click unsubscribe; the POST target is the same URL.
msg.Headers = map[string]string{
"List-Unsubscribe": "<" + opts.UnsubscribeURL + ">",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
}
err = c.sender.Send(ctx, msg)
if errors.Is(err, mail.ErrNotConfigured) {
return fmt.Errorf("smtp not configured: %w", errChannelUnavailable)
}
return err
}
// emailUnsubscribeURL builds the tokenized unsubscribe link; empty when no
// external URL is configured (the email then renders without one).
func emailUnsubscribeURL(baseURL, token string) string {
if baseURL == "" || token == "" {
return ""
}
return baseURL + "/api/v1/notifications/email/unsubscribe?token=" + token
}
// Errors surfaced by the email preference API layer to map to 4xx responses.
var (
ErrEmailModeInvalid = errors.New("invalid email notification mode")
ErrEmailModeNotAllowed = errors.New("per-episode email is disabled by the administrator")
ErrEmailNoAddress = errors.New("profile has no verified email address")
)
// EmailAvailable reports whether the email channel can deliver right now:
// a sender is wired, the kill switch is on, and SMTP is configured.
func (s *System) EmailAvailable(ctx context.Context) bool {
return s != nil && s.emailWorker != nil &&
s.Settings.EmailEnabled(ctx) && s.mailSender.Enabled(ctx)
}
// EmailPreferencesState is one profile's email channel state as the API
// surfaces it.
type EmailPreferencesState struct {
Mode string
CustomEmail string
PendingEmail string
IsChild bool
}
// EmailPreferences returns the profile's email notification state.
func (s *System) EmailPreferences(ctx context.Context, userID int, profileID string) (EmailPreferencesState, error) {
state := EmailPreferencesState{Mode: EmailModeOff}
if s == nil || s.EmailPrefs == nil {
return state, nil
}
prefs, err := s.EmailPrefs.Get(ctx, profileID)
if err != nil {
return state, err
}
state.Mode = prefs.Mode
state.CustomEmail = prefs.CustomEmail
state.PendingEmail = prefs.PendingEmail
state.IsChild = s.profileIsChild(ctx, userID, profileID)
return state, nil
}
// SetEmailMode validates and stores the profile's email mode. Enabling
// requires the profile's own verified address and, for per-episode, the
// admin allowance.
func (s *System) SetEmailMode(ctx context.Context, userID int, profileID, mode string) error {
if s == nil || s.EmailPrefs == nil {
return ErrEmailModeInvalid
}
if !ValidChannelMode(mode) {
return ErrEmailModeInvalid
}
if ModeIncludesPerEpisode(mode) && !s.Settings.EmailAllowPerEpisode(ctx) {
return ErrEmailModeNotAllowed
}
if mode != EmailModeOff {
prefs, err := s.EmailPrefs.Get(ctx, profileID)
if err != nil {
return err
}
if prefs.CustomEmail == "" {
return ErrEmailNoAddress
}
}
return s.EmailPrefs.SetMode(ctx, userID, profileID, mode)
}
// lookupProfile loads the profile from its account's userstore; nil on any
// failure (callers treat that as the safe default).
func (s *System) lookupProfile(ctx context.Context, userID int, profileID string) *userstore.Profile {
if s == nil || s.stores == nil {
return nil
}
store, err := s.stores.ForUser(ctx, userID)
if err != nil {
return nil
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil {
return nil
}
return profile
}
// profileIsChild reports whether the profile is a child profile. Best-effort:
// lookup failures err on the safe side (treated as child, which only
// restricts custom-address edits).
func (s *System) profileIsChild(ctx context.Context, userID int, profileID string) bool {
profile := s.lookupProfile(ctx, userID, profileID)
return profile == nil || profile.IsChild
}
// lookupProfileName resolves the profile's display name for email copy;
// best-effort, empty on any failure.
func (s *System) lookupProfileName(ctx context.Context, userID int, profileID string) string {
if profile := s.lookupProfile(ctx, userID, profileID); profile != nil {
return profile.Name
}
return ""
}