Files
silo-server/internal/notifications/email_digest.go
bcf0253c09 feat(notifications): notify requesters of request status changes (#143)
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:

- New request.approved / request.declined delivery types ride the
  operational dispatch path to the requesting profile: inbox, websocket
  toast, email, Discord DM, personal webhooks (gated by the existing
  notify_requests flag), and web push. Submitted stays broadcast-only
  (the requester performed the action themselves). Title/year/decline
  reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
  an off-schedule early send (watermark-durable, last_digest_at left
  alone) instead of waiting for the digest hour. Per-episode recipients
  were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
  (migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
  their OAuth-linked identity (notifications.server_channels.
  mention_requesters, default off). Resolved lazily in the sweep worker
  only when a Discord destination is about to receive the event; the
  ping uses content-level mention with pinned allowed_mentions, and the
  Discord identity never leaks into generic webhook payloads.

Android/Apple clients render the new inbox types with their generic
fallback until they add them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:55 -04:00

235 lines
8.0 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) hasTransactionalPendingSince(ctx context.Context, profileID string, since Cursor) (bool, error) {
return c.deliveries.HasTransactionalForProfileSince(ctx, profileID, since)
}
func (c *emailChannel) listSince(ctx context.Context, tx pgx.Tx, profileID string, since Cursor, until time.Time, limit int) ([]DeliveryRow, error) {
return c.deliveries.ListForProfileSince(ctx, tx, profileID, since, until, 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 ""
}