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
+
+`,
+ 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, "