diff --git a/internal/api/handlers/notifications_email.go b/internal/api/handlers/notifications_email.go index 64dcb9e3..e9863965 100644 --- a/internal/api/handlers/notifications_email.go +++ b/internal/api/handlers/notifications_email.go @@ -3,38 +3,66 @@ package handlers import ( "encoding/json" "errors" + "fmt" + "html" "net/http" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/notifications" ) -// emailPreferencesResponse is the account-level email notification setting. -// Unlike the per-profile preferences, one mode covers every profile on the -// login account: email addresses live on accounts, and the emails themselves -// aggregate across profiles. +// emailPreferencesResponse is one profile's email notification state. The +// channel is profile-scoped: each profile verifies its own destination +// address and receives nothing until it has one — there is no account-email +// fallback. type emailPreferencesResponse struct { Mode string `json:"mode"` + // CustomEmail is the verified destination ('' = none; channel inert). + CustomEmail string `json:"custom_email"` + // PendingEmail is an address awaiting link-click verification. + PendingEmail string `json:"pending_email"` + // CanEditAddress is false for child profiles, which cannot set + // addresses (and so cannot receive email notifications). + CanEditAddress bool `json:"can_edit_address"` } type updateEmailPreferencesRequest struct { Mode string `json:"mode"` } -// HandleGetEmailPreferences handles GET /notifications/email-preferences. -func (h *NotificationsHandler) HandleGetEmailPreferences(w http.ResponseWriter, r *http.Request) { - userID := apimw.GetUserID(r.Context()) - mode, err := h.system.EmailMode(r.Context(), userID) +type updateEmailAddressRequest struct { + Email string `json:"email"` +} + +func emailPreferencesPayload(state notifications.EmailPreferencesState) emailPreferencesResponse { + return emailPreferencesResponse{ + Mode: state.Mode, + CustomEmail: state.CustomEmail, + PendingEmail: state.PendingEmail, + CanEditAddress: !state.IsChild, + } +} + +// respondEmailPreferences re-reads and writes the profile's full email state, +// so every mutation returns the same shape as GET. +func (h *NotificationsHandler) respondEmailPreferences(w http.ResponseWriter, r *http.Request, userID int, profileID string) { + state, err := h.system.EmailPreferences(r.Context(), userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load email preferences") return } - writeJSON(w, http.StatusOK, emailPreferencesResponse{Mode: mode}) + writeJSON(w, http.StatusOK, emailPreferencesPayload(state)) +} + +// HandleGetEmailPreferences handles GET /notifications/email-preferences. +func (h *NotificationsHandler) HandleGetEmailPreferences(w http.ResponseWriter, r *http.Request) { + h.respondEmailPreferences(w, r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context())) } // HandleUpdateEmailPreferences handles PUT /notifications/email-preferences. func (h *NotificationsHandler) HandleUpdateEmailPreferences(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) var req updateEmailPreferencesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -42,7 +70,7 @@ func (h *NotificationsHandler) HandleUpdateEmailPreferences(w http.ResponseWrite return } - err := h.system.SetEmailMode(r.Context(), userID, req.Mode) + err := h.system.SetEmailMode(r.Context(), userID, profileID, req.Mode) switch { case err == nil: case errors.Is(err, notifications.ErrEmailModeInvalid): @@ -52,11 +80,130 @@ func (h *NotificationsHandler) HandleUpdateEmailPreferences(w http.ResponseWrite writeError(w, http.StatusBadRequest, "not_allowed", "Per-episode email is disabled by the administrator") return case errors.Is(err, notifications.ErrEmailNoAddress): - writeError(w, http.StatusBadRequest, "no_email", "Your account has no email address") + writeError(w, http.StatusBadRequest, "no_email", "Verify an email address for this profile first") return default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save email preferences") return } - writeJSON(w, http.StatusOK, emailPreferencesResponse{Mode: req.Mode}) + h.respondEmailPreferences(w, r, userID, profileID) +} + +// HandleRequestEmailAddress handles PUT /notifications/email-preferences/address. +// It stores the candidate address and emails it a verification link; the +// address only becomes the destination once that link is clicked. +func (h *NotificationsHandler) HandleRequestEmailAddress(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req updateEmailAddressRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + err := h.system.RequestEmailAddress(r.Context(), userID, profileID, req.Email) + switch { + case err == nil: + case errors.Is(err, notifications.ErrEmailInvalidAddress): + writeError(w, http.StatusBadRequest, "bad_request", "Invalid email address") + return + case errors.Is(err, notifications.ErrEmailChildProfile): + writeError(w, http.StatusForbidden, "child_profile", "Child profiles cannot set a custom notification address") + return + case errors.Is(err, notifications.ErrEmailAddressInUse): + writeError(w, http.StatusConflict, "address_in_use", "That email address is already used by another profile or account") + return + case errors.Is(err, notifications.ErrEmailVerifyRateLimited): + writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many verification emails; try again later") + return + case errors.Is(err, notifications.ErrEmailNoLinkBase): + writeError(w, http.StatusConflict, "no_external_url", "The server has no external URL configured for verification links") + return + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send the verification email") + return + } + h.respondEmailPreferences(w, r, userID, profileID) +} + +// HandleClearEmailAddress handles DELETE /notifications/email-preferences/address. +func (h *NotificationsHandler) HandleClearEmailAddress(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + err := h.system.ClearEmailAddress(r.Context(), userID, profileID) + switch { + case err == nil: + case errors.Is(err, notifications.ErrEmailChildProfile): + writeError(w, http.StatusForbidden, "child_profile", "Child profiles cannot change the notification address") + return + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove the custom address") + return + } + h.respondEmailPreferences(w, r, userID, profileID) +} + +// EmailLinkHandler serves the public tokenized email endpoints: address +// verification and unsubscribe. Both are clicked from email clients on +// devices that may have no Silo session, so they render minimal standalone +// HTML instead of redirecting into the authenticated app. +type EmailLinkHandler struct { + system *notifications.System +} + +// NewEmailLinkHandler creates an EmailLinkHandler. +func NewEmailLinkHandler(system *notifications.System) *EmailLinkHandler { + return &EmailLinkHandler{system: system} +} + +// HandleVerify handles GET /notifications/email/verify?token=... +func (h *EmailLinkHandler) HandleVerify(w http.ResponseWriter, r *http.Request) { + outcome, err := h.system.VerifyEmailToken(r.Context(), r.URL.Query().Get("token")) + switch { + case err != nil: + writeEmailLinkPage(w, http.StatusInternalServerError, "Something went wrong", + "The address could not be verified. Try the link again in a moment.") + case outcome == notifications.EmailVerifyConflict: + writeEmailLinkPage(w, http.StatusConflict, "Address already in use", + "This address now belongs to another profile or account. Choose a different address in Silo's notification settings.") + case outcome == notifications.EmailVerifyInvalid: + writeEmailLinkPage(w, http.StatusBadRequest, "Link expired or already used", + "Request a new verification email from Silo's notification settings.") + default: + writeEmailLinkPage(w, http.StatusOK, "Address verified", + "Silo notifications for this profile will now be delivered here. You can close this page.") + } +} + +// HandleUnsubscribe handles GET and POST /notifications/email/unsubscribe?token=... +// POST is the RFC 8058 one-click target mail clients call directly. +func (h *EmailLinkHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) { + ok, err := h.system.UnsubscribeEmail(r.Context(), r.URL.Query().Get("token")) + switch { + case err != nil: + writeEmailLinkPage(w, http.StatusInternalServerError, "Something went wrong", + "Could not unsubscribe. Try the link again in a moment.") + case !ok: + writeEmailLinkPage(w, http.StatusBadRequest, "Link invalid", + "This unsubscribe link is no longer valid. Manage notifications in Silo's settings.") + default: + writeEmailLinkPage(w, http.StatusOK, "Unsubscribed", + "This profile will no longer receive notification emails. Re-enable them any time in Silo's settings.") + } +} + +// writeEmailLinkPage renders the minimal standalone page behind tokenized +// email links. +func writeEmailLinkPage(w http.ResponseWriter, status int, title, detail string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + fmt.Fprintf(w, `%s — Silo + +
+

%s

+

%s

+
`, + html.EscapeString(title), html.EscapeString(title), html.EscapeString(detail)) } diff --git a/internal/api/router.go b/internal/api/router.go index b7e4c33c..0d17c003 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1488,6 +1488,17 @@ func NewRouter(deps Dependencies) chi.Router { if deps.Notifications != nil { discordNotificationsHandler = handlers.NewDiscordNotificationsHandler(deps.Notifications, deps.PublicURL) r.Get("/notifications/discord/link/callback", discordNotificationsHandler.HandleLinkCallback) + + // Tokenized email links: public — clicked from mail clients on + // devices without a Silo session; the single-use token (verify) + // or per-profile capability token (unsubscribe) authenticates the + // request. Static paths coexist with the authenticated + // /notifications subrouter below, same as the Discord callback. + deps.Notifications.SetPublicURL(deps.PublicURL) + emailLinkHandler := handlers.NewEmailLinkHandler(deps.Notifications) + r.Get("/notifications/email/verify", emailLinkHandler.HandleVerify) + r.Get("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe) + r.Post("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe) } // API key management routes (auth only, no viewer access needed). @@ -1557,6 +1568,8 @@ func NewRouter(deps Dependencies) chi.Router { r.Put("/preferences", notificationsHandler.HandleUpdatePreferences) r.Get("/email-preferences", notificationsHandler.HandleGetEmailPreferences) r.Put("/email-preferences", notificationsHandler.HandleUpdateEmailPreferences) + r.Put("/email-preferences/address", notificationsHandler.HandleRequestEmailAddress) + r.Delete("/email-preferences/address", notificationsHandler.HandleClearEmailAddress) if discordNotificationsHandler != nil { r.Get("/discord-preferences", discordNotificationsHandler.HandleGetPreferences) r.Put("/discord-preferences", discordNotificationsHandler.HandleUpdatePreferences) diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 5f7a9a79..e75ee4f2 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -62,6 +62,8 @@ type Message struct { HTMLBody string // ReplyTo optionally overrides the reply address. ReplyTo string + // Headers sets additional top-level headers (e.g. List-Unsubscribe). + Headers map[string]string } // Sender is the feature-facing abstraction. Implementations must be safe for @@ -210,6 +212,9 @@ func buildMessage(cfg *smtpConfig, msg Message) (*gomail.Msg, error) { } } message.Subject(msg.Subject) + for key, value := range msg.Headers { + message.SetGenHeader(gomail.Header(key), value) + } switch { case msg.HTMLBody != "" && msg.TextBody != "": message.SetBodyString(gomail.TypeTextPlain, msg.TextBody) diff --git a/internal/notifications/account_channel_engine.go b/internal/notifications/account_channel_engine.go index 076f20ff..0dd9c5f4 100644 --- a/internal/notifications/account_channel_engine.go +++ b/internal/notifications/account_channel_engine.go @@ -11,10 +11,10 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// Account-channel modes shared by every account-level notification channel -// (email, Discord). These channels are account-scoped: one setting covers -// every profile on the login account, and the sweep collapses cross-profile -// duplicates into a single send. +// Channel modes shared by every watermark-sweep notification channel (email, +// Discord). A channel is keyed by recipient: profiles for email (each profile +// owns its address and watermark), login accounts for Discord (the linked +// identity is account-level, and one send collapses cross-profile duplicates). const ( ChannelModeOff = "off" ChannelModePerEpisode = "per_episode" @@ -59,7 +59,7 @@ const ( // errChannelUnavailable aborts a sweep pass entirely: the channel's transport // is unconfigured or globally down, so nothing else will send either. The -// failing account is not penalized with backoff. +// failing recipient is not penalized with backoff. var errChannelUnavailable = errors.New("notification channel unavailable") // effectiveChannelMode coerces per-episode modes to the daily digest when the @@ -103,12 +103,13 @@ func channelRetryEligible(now time.Time, lastAttemptAt *time.Time, consecutiveFa return !now.Before(lastAttemptAt.Add(backoff)) } -// accountRecipient is the channel-agnostic sweep state for one account: the +// accountRecipient is the channel-agnostic sweep state for one recipient: the // user-chosen mode plus the dispatch watermark and failure backoff counters. -// Channel-specific contact details (email address, Discord identity) stay -// inside the channel implementation. -type accountRecipient struct { - UserID int +// K is the channel's recipient key — profile ID (string) for email, login +// account ID (int) for Discord. Channel-specific contact details (email +// address, Discord identity) stay inside the channel implementation. +type accountRecipient[K comparable] struct { + Key K Mode string WatermarkCreatedAt time.Time WatermarkID string @@ -117,10 +118,11 @@ type accountRecipient struct { ConsecutiveFailures int } -// accountChannel supplies the channel-specific pieces of the account -// watermark sweep: prefs-table access and the actual send. The engine owns -// the loop, eligibility, claim transaction, and watermark advancement. -type accountChannel interface { +// accountChannel supplies the channel-specific pieces of the watermark sweep: +// prefs-table access, recipient-scoped delivery reads, and the actual send. +// The engine owns the loop, eligibility, claim transaction, and watermark +// advancement. +type accountChannel[K comparable] interface { // name labels log lines. name() string // enabled gates a whole pass (kill switch + transport configured). @@ -129,59 +131,62 @@ type accountChannel interface { allowPerEpisode(ctx context.Context) bool // digestHour is the hour of day (0-23, server-local) for daily digests. digestHour(ctx context.Context) int - // listRecipients returns every account with the channel switched on and a - // usable destination. Disabled or deleted accounts must not appear. - listRecipients(ctx context.Context) ([]accountRecipient, error) - // claim locks the account's prefs row for one dispatch attempt with + // listRecipients returns every recipient with the channel switched on and + // a usable destination. Disabled or deleted accounts must not appear. + listRecipients(ctx context.Context) ([]accountRecipient[K], error) + // hasPendingSince cheaply reports whether the recipient has deliveries + // past the watermark, so idle recipients don't open a claim transaction + // every pass. + hasPendingSince(ctx context.Context, key K, since Cursor) (bool, error) + // listSince returns the recipient's deliveries newer than the watermark, + // ascending, inside the claim transaction. + listSince(ctx context.Context, tx pgx.Tx, key K, since Cursor, limit int) ([]DeliveryRow, error) + // claim locks the recipient's prefs row for one dispatch attempt with // FOR UPDATE SKIP LOCKED; (nil, nil) means another node holds the row. - claim(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient, error) + claim(ctx context.Context, tx pgx.Tx, key K) (*accountRecipient[K], error) // markSent advances the watermark past everything the send covered and // resets failure backoff. digestAt is non-nil for digest sends. - markSent(ctx context.Context, tx pgx.Tx, userID int, watermark Cursor, digestAt *time.Time) error + markSent(ctx context.Context, tx pgx.Tx, key K, watermark Cursor, digestAt *time.Time) error // markFailure records a failed send for backoff; the watermark stays put // so the next eligible pass retries the same items. - markFailure(ctx context.Context, tx pgx.Tx, userID int, sendErr error) error - // send delivers one account's pending rows. It runs inside the claim + markFailure(ctx context.Context, tx pgx.Tx, key K, sendErr error) error + // send delivers one recipient's pending rows. It runs inside the claim // transaction; tx is for channel-state updates only (the engine owns // commit/rollback). Errors wrapping errChannelUnavailable abort the pass - // without penalizing the account. - send(ctx context.Context, tx pgx.Tx, userID int, mode string, rows []DeliveryRow) error + // without penalizing the recipient. + send(ctx context.Context, tx pgx.Tx, key K, mode string, rows []DeliveryRow) error } -// accountChannelWorker drives one account-level channel. Unlike webhooks and -// web push it keeps no per-target outbox: deliveries already carry user_id, -// so a per-account watermark over notification_deliveries is the durable -// dispatch state. The watermark advances only after a successful send, and -// one send covers everything since the last one — which also collapses the -// duplicate rows an account gets when several of its profiles follow the -// same series. -type accountChannelWorker struct { - pool *pgxpool.Pool - deliveries *DeliveryRepository - channel accountChannel - logger *slog.Logger - nudge chan struct{} - now func() time.Time +// accountChannelWorker drives one watermark-sweep channel. Unlike webhooks +// and web push it keeps no per-target outbox: deliveries already carry +// user_id and profile_id, so a per-recipient watermark over +// notification_deliveries is the durable dispatch state. The watermark +// advances only after a successful send, and one send covers everything +// since the last one. +type accountChannelWorker[K comparable] struct { + pool *pgxpool.Pool + channel accountChannel[K] + logger *slog.Logger + nudge chan struct{} + now func() time.Time } -func newAccountChannelWorker( +func newAccountChannelWorker[K comparable]( pool *pgxpool.Pool, - deliveries *DeliveryRepository, - channel accountChannel, -) *accountChannelWorker { - return &accountChannelWorker{ - pool: pool, - deliveries: deliveries, - channel: channel, - logger: slog.Default().With("component", "notifications."+channel.name()), - nudge: make(chan struct{}, 1), - now: time.Now, + channel accountChannel[K], +) *accountChannelWorker[K] { + return &accountChannelWorker[K]{ + pool: pool, + channel: channel, + logger: slog.Default().With("component", "notifications."+channel.name()), + nudge: make(chan struct{}, 1), + now: time.Now, } } // Nudge schedules a near-term pass so per-episode sends follow fanout within // seconds instead of waiting for the next poll. Non-blocking. -func (w *accountChannelWorker) Nudge() { +func (w *accountChannelWorker[K]) Nudge() { if w == nil { return } @@ -191,8 +196,8 @@ func (w *accountChannelWorker) Nudge() { } } -// Run sweeps eligible accounts until ctx is canceled. -func (w *accountChannelWorker) Run(ctx context.Context) { +// Run sweeps eligible recipients until ctx is canceled. +func (w *accountChannelWorker[K]) Run(ctx context.Context) { ticker := time.NewTicker(channelPollInterval) defer ticker.Stop() for { @@ -214,10 +219,10 @@ func (w *accountChannelWorker) Run(ctx context.Context) { } } -// runPass attempts one send per eligible account. Failures back off per -// account; the pass aborts entirely on errChannelUnavailable or after a few +// runPass attempts one send per eligible recipient. Failures back off per +// recipient; the pass aborts entirely on errChannelUnavailable or after a few // consecutive failures, since both indicate a global transport problem. -func (w *accountChannelWorker) runPass(ctx context.Context) { +func (w *accountChannelWorker[K]) runPass(ctx context.Context) { recipients, err := w.channel.listRecipients(ctx) if err != nil { w.logger.Error("channel pass: list recipients failed", "error", err) @@ -245,13 +250,13 @@ func (w *accountChannelWorker) runPass(ctx context.Context) { if mode == ChannelModePerEpisodeAndDigest && digestDue { break // the digest leg has work regardless of pending rows } - // Cheap pre-check so idle accounts don't open a claim + // Cheap pre-check so idle recipients don't open a claim // transaction every pass. A stale watermark only ever // produces a harmless extra claim. - pending, err := w.deliveries.HasForUserSince(ctx, rec.UserID, + pending, err := w.channel.hasPendingSince(ctx, rec.Key, Cursor{CreatedAt: rec.WatermarkCreatedAt, ID: rec.WatermarkID}) if err != nil { - w.logger.Warn("channel pass: pending check failed", "user_id", rec.UserID, "error", err) + w.logger.Warn("channel pass: pending check failed", "recipient", rec.Key, "error", err) continue } if !pending { @@ -264,33 +269,33 @@ func (w *accountChannelWorker) runPass(ctx context.Context) { default: continue } - if err := w.processAccount(ctx, rec); err != nil { + if err := w.processRecipient(ctx, rec); err != nil { if errors.Is(err, errChannelUnavailable) { return // channel turned off mid-pass; nothing else will send either } failures++ - w.logger.Warn("channel send failed", "user_id", rec.UserID, "mode", mode, "error", err) + w.logger.Warn("channel send failed", "recipient", rec.Key, "mode", mode, "error", err) } } } -// processAccount sends one account's pending notifications under the prefs -// row lock. The send happens inside the claim transaction: the row lock is -// per-account and only contends with other nodes, and committing the +// processRecipient sends one recipient's pending notifications under the +// prefs row lock. The send happens inside the claim transaction: the row lock +// is per-recipient and only contends with other nodes, and committing the // watermark only after a successful send is what makes the channel durable. -func (w *accountChannelWorker) processAccount(ctx context.Context, rec accountRecipient) error { +func (w *accountChannelWorker[K]) processRecipient(ctx context.Context, rec accountRecipient[K]) error { tx, err := w.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin channel dispatch tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() - claimed, err := w.channel.claim(ctx, tx, rec.UserID) + claimed, err := w.channel.claim(ctx, tx, rec.Key) if err != nil { return err } if claimed == nil { - return nil // another node is handling this account + return nil // another node is handling this recipient } // Re-derive eligibility from the locked row: the pre-scan snapshot may @@ -335,7 +340,7 @@ func (w *accountChannelWorker) processAccount(ctx context.Context, rec accountRe return nil } - rows, err := w.deliveries.ListForUserSince(ctx, tx, rec.UserID, fetchFrom, channelFetchLimit) + rows, err := w.channel.listSince(ctx, tx, rec.Key, fetchFrom, channelFetchLimit) if err != nil { return err } @@ -344,7 +349,7 @@ func (w *accountChannelWorker) processAccount(ctx context.Context, rec accountRe // Nothing new. Digests still stamp so eligibility stops re-checking // until tomorrow; the watermark needs no update. if digestAt != nil { - if err := w.channel.markSent(ctx, tx, rec.UserID, since, digestAt); err != nil { + if err := w.channel.markSent(ctx, tx, rec.Key, since, digestAt); err != nil { return err } return tx.Commit(ctx) @@ -375,11 +380,11 @@ func (w *accountChannelWorker) processAccount(ctx context.Context, rec accountRe } if len(items) > 0 { - if err := w.channel.send(ctx, tx, rec.UserID, sendKind, items); err != nil { + if err := w.channel.send(ctx, tx, rec.Key, sendKind, items); err != nil { if errors.Is(err, errChannelUnavailable) { return err } - if markErr := w.channel.markFailure(ctx, tx, rec.UserID, err); markErr != nil { + if markErr := w.channel.markFailure(ctx, tx, rec.Key, err); markErr != nil { return errors.Join(err, markErr) } if commitErr := tx.Commit(ctx); commitErr != nil { @@ -388,24 +393,30 @@ func (w *accountChannelWorker) processAccount(ctx context.Context, rec accountRe return err } w.logger.Info("notification sent", - "user_id", rec.UserID, "mode", mode, "items", len(items)) + "recipient", rec.Key, "mode", mode, "items", len(items)) } - if err := w.channel.markSent(ctx, tx, rec.UserID, watermark, digestAt); err != nil { + if err := w.channel.markSent(ctx, tx, rec.Key, watermark, digestAt); err != nil { return err } return tx.Commit(ctx) } +// nudger is the cross-key-type surface of accountChannelWorker the dispatch +// path needs. +type nudger interface { + Nudge() +} + // nudgeDispatcher plugs an account-channel worker into the MultiDispatcher: a // new delivery just nudges the sweep, which reads everything since the // watermark. No per-delivery state is kept, so dropped nudges cost only poll // latency. type nudgeDispatcher struct { - worker *accountChannelWorker + worker nudger } -func newNudgeDispatcher(worker *accountChannelWorker) *nudgeDispatcher { +func newNudgeDispatcher(worker nudger) *nudgeDispatcher { return &nudgeDispatcher{worker: worker} } diff --git a/internal/notifications/delivery_repo.go b/internal/notifications/delivery_repo.go index c3cf6c58..7a43ab99 100644 --- a/internal/notifications/delivery_repo.go +++ b/internal/notifications/delivery_repo.go @@ -262,6 +262,40 @@ func (r *DeliveryRepository) HasForUserSince(ctx context.Context, userID int, si return exists, nil } +// ListForProfileSince returns the profile's deliveries newer than the +// watermark, ascending. Runs inside the email worker's claim transaction so +// the rows read are the rows the advanced watermark covers. +func (r *DeliveryRepository) ListForProfileSince(ctx context.Context, tx pgx.Tx, profileID string, since Cursor, limit int) ([]DeliveryRow, error) { + rows, err := tx.Query(ctx, + deliveryRowSelect+` + WHERE d.profile_id = $1 AND (d.created_at, d.id) > ($2, $3) + ORDER BY d.created_at ASC, d.id ASC + LIMIT $4`, + profileID, since.CreatedAt, since.ID, limit) + if err != nil { + return nil, fmt.Errorf("list profile deliveries since: %w", err) + } + return scanDeliveryRows(rows) +} + +// HasForProfileSince reports whether the profile has any delivery newer than +// the given watermark. Cheap pre-check (index-only) so account-channel sweeps +// do not open a claim transaction for idle profiles every pass. +func (r *DeliveryRepository) HasForProfileSince(ctx context.Context, profileID string, since Cursor) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM notification_deliveries + WHERE profile_id = $1 AND (created_at, id) > ($2, $3) + )`, + profileID, since.CreatedAt, since.ID, + ).Scan(&exists) + if err != nil { + return false, fmt.Errorf("check profile deliveries since watermark: %w", err) + } + return exists, nil +} + // RecentUnread returns the newest unread rows for the websocket snapshot. func (r *DeliveryRepository) RecentUnread(ctx context.Context, profileID string, limit int) ([]DeliveryRow, error) { rows, err := r.pool.Query(ctx, diff --git a/internal/notifications/discord_dm.go b/internal/notifications/discord_dm.go index eb14b24d..69fdc3f9 100644 --- a/internal/notifications/discord_dm.go +++ b/internal/notifications/discord_dm.go @@ -16,15 +16,22 @@ import ( const discordDMBlockedMessage = "Discord rejected the direct message. " + "Make sure you share a server with the bot and allow direct messages from server members." -// discordChannel implements accountChannel over the Discord bot REST API. -// The bot token is read live from settings on every pass, so admin changes -// apply without a restart (same pattern as the SMTP sender). +// discordChannel implements accountChannel over the Discord bot REST API, +// keyed by login account: the linked identity is account-level, so one DM +// collapses cross-profile duplicates. The bot token is read live from +// settings on every pass, so admin changes apply without a restart (same +// pattern as the SMTP sender). type discordChannel struct { - prefs *DiscordPrefsRepository - settings *Settings - client *discord.Client + prefs *DiscordPrefsRepository + deliveries *DeliveryRepository + settings *Settings + client *discord.Client } +// The assertion also keeps staticcheck's unused-analysis aware that the +// adapter methods are consumed through the generic engine interface. +var _ accountChannel[int] = (*discordChannel)(nil) + // newDiscordWorker assembles the Discord DM channel on the shared // account-channel engine. func newDiscordWorker( @@ -33,11 +40,12 @@ func newDiscordWorker( prefs *DiscordPrefsRepository, settings *Settings, client *discord.Client, -) *accountChannelWorker { - return newAccountChannelWorker(pool, deliveries, &discordChannel{ - prefs: prefs, - settings: settings, - client: client, +) *accountChannelWorker[int] { + return newAccountChannelWorker(pool, &discordChannel{ + prefs: prefs, + deliveries: deliveries, + settings: settings, + client: client, }) } @@ -55,11 +63,19 @@ func (c *discordChannel) digestHour(ctx context.Context) int { return c.settings.DiscordDigestHour(ctx) } -func (c *discordChannel) listRecipients(ctx context.Context) ([]accountRecipient, error) { +func (c *discordChannel) listRecipients(ctx context.Context) ([]accountRecipient[int], error) { return c.prefs.ListActiveRecipients(ctx) } -func (c *discordChannel) claim(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient, error) { +func (c *discordChannel) hasPendingSince(ctx context.Context, userID int, since Cursor) (bool, error) { + return c.deliveries.HasForUserSince(ctx, userID, since) +} + +func (c *discordChannel) listSince(ctx context.Context, tx pgx.Tx, userID int, since Cursor, limit int) ([]DeliveryRow, error) { + return c.deliveries.ListForUserSince(ctx, tx, userID, since, limit) +} + +func (c *discordChannel) claim(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient[int], error) { return c.prefs.claimForUpdate(ctx, tx, userID) } diff --git a/internal/notifications/discord_prefs_repo.go b/internal/notifications/discord_prefs_repo.go index 1ff6e45d..a95c5b56 100644 --- a/internal/notifications/discord_prefs_repo.go +++ b/internal/notifications/discord_prefs_repo.go @@ -138,7 +138,7 @@ func (r *DiscordPrefsRepository) SetMode(ctx context.Context, userID int, mode s // ListActiveRecipients returns every linked account with Discord DMs on. // Disabled or deleted accounts drop out of the join. -func (r *DiscordPrefsRepository) ListActiveRecipients(ctx context.Context) ([]accountRecipient, error) { +func (r *DiscordPrefsRepository) ListActiveRecipients(ctx context.Context) ([]accountRecipient[int], error) { rows, err := r.pool.Query(ctx, ` SELECT p.user_id, p.mode, p.watermark_created_at, p.watermark_id, p.last_digest_at, p.last_attempt_at, p.consecutive_failures @@ -150,10 +150,10 @@ func (r *DiscordPrefsRepository) ListActiveRecipients(ctx context.Context) ([]ac return nil, fmt.Errorf("list discord recipients: %w", err) } defer rows.Close() - out := make([]accountRecipient, 0, 8) + out := make([]accountRecipient[int], 0, 8) for rows.Next() { - var rec accountRecipient - if err := rows.Scan(&rec.UserID, &rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID, + var rec accountRecipient[int] + if err := rows.Scan(&rec.Key, &rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID, &rec.LastDigestAt, &rec.LastAttemptAt, &rec.ConsecutiveFailures); err != nil { return nil, fmt.Errorf("scan discord recipient: %w", err) } @@ -165,8 +165,8 @@ func (r *DiscordPrefsRepository) ListActiveRecipients(ctx context.Context) ([]ac // claimForUpdate locks the account's prefs row for one dispatch attempt. // SKIP LOCKED makes concurrent nodes pass over each other's in-flight users // instead of double-sending; (nil, nil) means another node holds the row. -func (r *DiscordPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient, error) { - rec := accountRecipient{UserID: userID} +func (r *DiscordPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient[int], error) { + rec := accountRecipient[int]{Key: userID} err := tx.QueryRow(ctx, ` SELECT mode, watermark_created_at, watermark_id, last_digest_at, last_attempt_at, consecutive_failures diff --git a/internal/notifications/email_address.go b/internal/notifications/email_address.go new file mode 100644 index 00000000..238ce9df --- /dev/null +++ b/internal/notifications/email_address.go @@ -0,0 +1,169 @@ +package notifications + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "html" + netmail "net/mail" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/mail" +) + +// emailVerifyTTL bounds how long a verification link stays usable. +const emailVerifyTTL = 24 * time.Hour + +// Errors surfaced by the custom-address flow for the API layer. +var ( + ErrEmailInvalidAddress = errors.New("invalid email address") + ErrEmailChildProfile = errors.New("child profiles cannot set a custom notification address") + ErrEmailNoLinkBase = errors.New("no external URL is configured for verification links") +) + +// newEmailToken mints a single-use capability token and its SHA-256 hex +// digest for at-rest storage. +func newEmailToken() (token, tokenHash string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", fmt.Errorf("generate email token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, hashEmailToken(token), nil +} + +// hashEmailToken returns the at-rest digest of a verification token. +func hashEmailToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// emailLinkBase is the externally reachable base URL for tokenized email +// links: the admin's notifications.email.external_url, falling back to the +// server's public URL. +func (s *System) emailLinkBase(ctx context.Context) string { + if base := s.Settings.EmailExternalURL(ctx); base != "" { + return base + } + return s.publicURL +} + +// SetPublicURL wires the server's externally reachable base URL, used as the +// fallback for verification links when notifications.email.external_url is +// unset. Optional. +func (s *System) SetPublicURL(url string) { + if s != nil { + s.publicURL = strings.TrimRight(url, "/") + } +} + +// RequestEmailAddress starts custom-address verification for the profile: it +// validates and stores the pending address, then emails it a single-use +// confirmation link. Notifications keep flowing to the previous destination +// until the new address is verified. Child profiles are refused — a session +// acting as a child profile must not be able to route the household's +// viewing activity to an arbitrary address. +func (s *System) RequestEmailAddress(ctx context.Context, userID int, profileID, address string) error { + if s == nil || s.EmailPrefs == nil { + return ErrEmailInvalidAddress + } + parsed, err := netmail.ParseAddress(strings.TrimSpace(address)) + if err != nil || parsed.Address != strings.TrimSpace(address) { + return ErrEmailInvalidAddress + } + address = parsed.Address + profile := s.lookupProfile(ctx, userID, profileID) + if profile == nil || profile.IsChild { + return ErrEmailChildProfile + } + linkBase := s.emailLinkBase(ctx) + if linkBase == "" { + return ErrEmailNoLinkBase + } + + token, tokenHash, err := newEmailToken() + if err != nil { + return err + } + expiresAt := time.Now().Add(emailVerifyTTL) + if err := s.EmailPrefs.RequestPendingAddress(ctx, userID, profileID, address, + tokenHash, expiresAt); err != nil { + return err + } + + verifyURL := linkBase + "/api/v1/notifications/email/verify?token=" + token + content := composeVerificationEmail(profile.Name, verifyURL) + err = s.mailSender.Send(ctx, mail.Message{ + To: []string{address}, + Subject: content.Subject, + TextBody: content.Text, + HTMLBody: content.HTML, + }) + if err != nil { + return fmt.Errorf("send verification email: %w", err) + } + return nil +} + +// ClearEmailAddress removes the profile's verified address (and any +// in-flight verification), which also switches the channel off — there is no +// fallback destination. Restricted to non-child profiles like setting one, +// so a child session cannot drop a parent-configured destination. +func (s *System) ClearEmailAddress(ctx context.Context, userID int, profileID string) error { + if s == nil || s.EmailPrefs == nil { + return nil + } + if s.profileIsChild(ctx, userID, profileID) { + return ErrEmailChildProfile + } + return s.EmailPrefs.ClearCustomAddress(ctx, profileID) +} + +// VerifyEmailToken consumes a verification token from a clicked link, +// promoting that profile's pending address to the verified destination. +func (s *System) VerifyEmailToken(ctx context.Context, token string) (EmailVerifyOutcome, error) { + if s == nil || s.EmailPrefs == nil || token == "" { + return EmailVerifyInvalid, nil + } + return s.EmailPrefs.ConsumeVerifyToken(ctx, hashEmailToken(token)) +} + +// UnsubscribeEmail handles a tokenized unsubscribe link: the matching +// profile's email mode switches off. +func (s *System) UnsubscribeEmail(ctx context.Context, token string) (ok bool, err error) { + if s == nil || s.EmailPrefs == nil || token == "" { + return false, nil + } + return s.EmailPrefs.UnsubscribeByToken(ctx, token) +} + +// composeVerificationEmail renders the address-confirmation message. +func composeVerificationEmail(profileName, verifyURL string) emailContent { + who := "your profile" + if profileName != "" { + who = "the profile “" + profileName + "”" + } + text := fmt.Sprintf( + "This address was entered as the notification destination for %s on a Silo server.\n\n"+ + "To confirm and start receiving notifications here, open this link:\n\n %s\n\n"+ + "The link expires in 24 hours. If you didn't request this, ignore this email — "+ + "nothing will be sent to this address.\n", who, verifyURL) + htmlBody := fmt.Sprintf(`
+

This address was entered as the notification destination for %s on a Silo server.

+

To confirm and start receiving notifications here:

+

Confirm this address

+
+

The link expires in 24 hours. If you didn't request this, ignore this email — nothing will be sent to this address.

+
`, + html.EscapeString(who), html.EscapeString(verifyURL)) + return emailContent{ + Subject: "Confirm your Silo notification address", + Text: text, + HTML: htmlBody, + } +} diff --git a/internal/notifications/email_compose.go b/internal/notifications/email_compose.go index d3cd18e3..d4b13092 100644 --- a/internal/notifications/email_compose.go +++ b/internal/notifications/email_compose.go @@ -194,10 +194,23 @@ func itemURL(baseURL, itemID string) string { 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. baseURL is the admin-configured external URL; empty renders -// without links. -func composeNotificationEmail(mode string, rows []DeliveryRow, baseURL string) emailContent { +// delivery rows. +func composeNotificationEmail(mode string, rows []DeliveryRow, opts emailComposeOptions) emailContent { + baseURL := opts.BaseURL items := collateEmailItems(rows) var text strings.Builder @@ -274,12 +287,25 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, baseURL string) e `

%s

`, html.EscapeString(more))) } - intro := "New in your library:" - if mode == EmailModeDailyDigest { - intro = "Here's what's new since your last digest:" + forProfile := "" + if opts.ProfileName != "" { + forProfile = " for " + opts.ProfileName } - footer := "You're receiving this because email notifications are enabled for your Silo account. " + - "Manage them in Settings → Notifications." + 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") @@ -287,6 +313,11 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, baseURL string) e "Settings → Notifications", fmt.Sprintf(`Settings → Notifications`, settingsURL), 1) } + if opts.UnsubscribeURL != "" { + footer += " To stop these emails, open: " + opts.UnsubscribeURL + footerHTML += fmt.Sprintf(` Unsubscribe`, + html.EscapeString(opts.UnsubscribeURL)) + } htmlBody := fmt.Sprintf(`

%s

@@ -297,7 +328,7 @@ func composeNotificationEmail(mode string, rows []DeliveryRow, baseURL string) e html.EscapeString(intro), body.String(), footerHTML) return emailContent{ - Subject: emailSubject(mode, items), + Subject: emailSubject(mode, items) + subjectFor, Text: intro + "\n\n" + text.String() + "\n" + footer + "\n", HTML: htmlBody, } diff --git a/internal/notifications/email_digest.go b/internal/notifications/email_digest.go index d99dde64..9028723f 100644 --- a/internal/notifications/email_digest.go +++ b/internal/notifications/email_digest.go @@ -7,34 +7,28 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/mail" + "github.com/Silo-Server/silo-server/internal/userstore" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" ) -// emailChannel implements accountChannel over the shared SMTP core. The -// engine owns the sweep loop and watermark; this adapter only knows how to -// list/claim email prefs rows and compose+send one account's message. +// 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 - settings *Settings - sender mail.Sender + 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 } -// newEmailWorker assembles the email channel on the shared account-channel -// engine. -func newEmailWorker( - pool *pgxpool.Pool, - deliveries *DeliveryRepository, - prefs *EmailPrefsRepository, - settings *Settings, - sender mail.Sender, -) *accountChannelWorker { - return newAccountChannelWorker(pool, deliveries, &emailChannel{ - prefs: prefs, - settings: settings, - sender: sender, - }) -} +// 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" } @@ -50,55 +44,97 @@ func (c *emailChannel) digestHour(ctx context.Context) int { return c.settings.EmailDigestHour(ctx) } -func (c *emailChannel) listRecipients(ctx context.Context) ([]accountRecipient, error) { +func (c *emailChannel) listRecipients(ctx context.Context) ([]accountRecipient[string], error) { return c.prefs.ListActiveRecipients(ctx) } -func (c *emailChannel) claim(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient, error) { - return c.prefs.claimForUpdate(ctx, tx, userID) +func (c *emailChannel) hasPendingSince(ctx context.Context, profileID string, since Cursor) (bool, error) { + return c.deliveries.HasForProfileSince(ctx, profileID, since) } -func (c *emailChannel) markSent(ctx context.Context, tx pgx.Tx, userID int, watermark Cursor, digestAt *time.Time) error { - return c.prefs.markSent(ctx, tx, userID, watermark, digestAt) +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) markFailure(ctx context.Context, tx pgx.Tx, userID int, _ error) error { - return c.prefs.markFailure(ctx, tx, userID) +func (c *emailChannel) claim(ctx context.Context, tx pgx.Tx, profileID string) (*accountRecipient[string], error) { + return c.prefs.claimForUpdate(ctx, tx, profileID) } -// send composes and sends one account's pending notifications. The address 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, userID int, mode string, rows []DeliveryRow) error { - var email string - err := tx.QueryRow(ctx, - `SELECT COALESCE(email, '') FROM users WHERE id = $1 AND enabled`, userID, - ).Scan(&email) - if errors.Is(err, pgx.ErrNoRows) || (err == nil && email == "") { - return fmt.Errorf("account %d has no usable email address", userID) - } +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 fmt.Errorf("look up account email: %w", err) + 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 + } } - content := composeNotificationEmail(mode, rows, c.settings.EmailExternalURL(ctx)) - err = c.sender.Send(ctx, mail.Message{ + 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 } -// Errors surfaced by SetEmailMode for the API layer to map to 4xx responses. +// 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("account has no email address") + ErrEmailNoAddress = errors.New("profile has no verified email address") ) // EmailAvailable reports whether the email channel can deliver right now: @@ -108,22 +144,36 @@ func (s *System) EmailAvailable(ctx context.Context) bool { s.Settings.EmailEnabled(ctx) && s.mailSender.Enabled(ctx) } -// EmailMode returns the account's chosen email mode (off when never set). -func (s *System) EmailMode(ctx context.Context, userID int) (string, error) { - if s == nil || s.EmailPrefs == nil { - return EmailModeOff, nil - } - prefs, err := s.EmailPrefs.Get(ctx, userID) - if err != nil { - return "", err - } - return prefs.Mode, nil +// EmailPreferencesState is one profile's email channel state as the API +// surfaces it. +type EmailPreferencesState struct { + Mode string + CustomEmail string + PendingEmail string + IsChild bool } -// SetEmailMode validates and stores the account's email mode. Enabling -// requires an email address on the account and, for per-episode, the admin -// allowance. -func (s *System) SetEmailMode(ctx context.Context, userID int, mode string) error { +// 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 } @@ -134,16 +184,47 @@ func (s *System) SetEmailMode(ctx context.Context, userID int, mode string) erro return ErrEmailModeNotAllowed } if mode != EmailModeOff { - var email string - err := s.pool.QueryRow(ctx, - `SELECT COALESCE(email, '') FROM users WHERE id = $1`, userID, - ).Scan(&email) + prefs, err := s.EmailPrefs.Get(ctx, profileID) if err != nil { - return fmt.Errorf("look up account email: %w", err) + return err } - if email == "" { + if prefs.CustomEmail == "" { return ErrEmailNoAddress } } - return s.EmailPrefs.SetMode(ctx, userID, mode) + 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 "" } diff --git a/internal/notifications/email_logic_test.go b/internal/notifications/email_logic_test.go index d56a8f19..5c58105c 100644 --- a/internal/notifications/email_logic_test.go +++ b/internal/notifications/email_logic_test.go @@ -155,7 +155,7 @@ func TestEmailSubject(t *testing.T) { func TestComposeNotificationEmailLinks(t *testing.T) { rows := []DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)} - withLinks := composeNotificationEmail(EmailModePerEpisode, rows, "https://silo.example.com") + withLinks := composeNotificationEmail(EmailModePerEpisode, rows, emailComposeOptions{BaseURL: "https://silo.example.com"}) if !strings.Contains(withLinks.HTML, `href="https://silo.example.com/item/ep-1"`) { t.Fatalf("episode link missing from HTML:\n%s", withLinks.HTML) } @@ -163,7 +163,7 @@ func TestComposeNotificationEmailLinks(t *testing.T) { t.Fatalf("settings link missing from HTML footer:\n%s", withLinks.HTML) } - withoutLinks := composeNotificationEmail(EmailModePerEpisode, rows, "") + withoutLinks := composeNotificationEmail(EmailModePerEpisode, rows, emailComposeOptions{}) if strings.Contains(withoutLinks.HTML, "href=") { t.Fatalf("HTML contains links with no external URL configured:\n%s", withoutLinks.HTML) } @@ -175,19 +175,86 @@ func TestComposeNotificationEmailLinks(t *testing.T) { func TestComposeNotificationEmailEscapesHTML(t *testing.T) { row := emailEpisodeRow("01A", "p1", "ep-1", 2, 3) row.SeriesTitle = `` - content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, "") + content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, emailComposeOptions{}) if strings.Contains(content.HTML, "