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>
235 lines
8.0 KiB
Go
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 ""
|
|
}
|