feat(notifications): per-profile email channel with verified addresses

Re-key the email notification channel from login accounts to profiles.
Each profile owns its mode, dispatch watermark, and destination address;
there is deliberately no fallback to the account email, so the account
holder no longer receives mail for every household profile. A profile
receives nothing until its own address is verified.

- Genericize the watermark-sweep engine over a recipient key
  (accountChannel[K]): email keys by profile_id, Discord stays on
  user_id. Delivery reads move into the channel adapters.
- Custom addresses verify via single-use SHA-256-hashed token links
  served by a public endpoint; enabling the channel requires a verified
  address, and clearing the address switches the channel off.
- Addresses are globally unique (case-insensitive): rejected when
  verified for another profile or matching another account's email or
  username. Checked at request time, re-checked at verify time
  (first-to-verify wins), backstopped by a partial unique index.
- Every email carries an RFC 8058 one-click unsubscribe link backed by
  a per-profile capability token, minted lazily under the claim tx.
- Child profiles cannot set addresses (and so receive no email in v1).
- Verification sends are rate limited (1/min, 10/day per profile);
  mail.Message gains custom header support for List-Unsubscribe.
- Migration drops the account-level prefs table without carrying
  opt-ins over, so nobody gets surprise emails post-upgrade.

Android/Apple notification settings need follow-up for the new
profile-scoped response shape and address-management endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-11 20:55:30 -04:00
co-authored by Claude Fable 5
parent 5f05374a1d
commit ebf3352bda
17 changed files with 1389 additions and 239 deletions
+159 -12
View File
@@ -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, `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s — Silo</title></head>
<body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#101014;color:#e8e8ec;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">
<div style="max-width:420px;padding:32px;text-align:center;">
<h1 style="font-size:20px;margin:0 0 12px;">%s</h1>
<p style="font-size:14px;color:#9a9aa4;margin:0;">%s</p>
</div></body></html>`,
html.EscapeString(title), html.EscapeString(title), html.EscapeString(detail))
}
+13
View File
@@ -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)
+5
View File
@@ -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)
@@ -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}
}
+34
View File
@@ -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,
+29 -13
View File
@@ -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)
}
+6 -6
View File
@@ -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
+169
View File
@@ -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(`<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;max-width:560px;">
<p style="margin:0 0 8px;">This address was entered as the notification destination for %s on a Silo server.</p>
<p style="margin:0 0 16px;">To confirm and start receiving notifications here:</p>
<p style="margin:0 0 16px;"><a href="%s" style="background:#6d6df7;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;display:inline-block;">Confirm this address</a></p>
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0 8px;">
<p style="margin:0;font-size:12px;color:#888;">The link expires in 24 hours. If you didn't request this, ignore this email — nothing will be sent to this address.</p>
</div>`,
html.EscapeString(who), html.EscapeString(verifyURL))
return emailContent{
Subject: "Confirm your Silo notification address",
Text: text,
HTML: htmlBody,
}
}
+40 -9
View File
@@ -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
`<p style="margin:8px 0;color:#888;">%s</p>`, 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(`<a href="%s" style="color:#888;">Settings → Notifications</a>`, settingsURL), 1)
}
if opts.UnsubscribeURL != "" {
footer += " To stop these emails, open: " + opts.UnsubscribeURL
footerHTML += fmt.Sprintf(` <a href="%s" style="color:#888;">Unsubscribe</a>`,
html.EscapeString(opts.UnsubscribeURL))
}
htmlBody := fmt.Sprintf(`<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;max-width:560px;">
<p style="margin:0 0 8px;">%s</p>
@@ -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,
}
+148 -67
View File
@@ -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 ""
}
+71 -4
View File
@@ -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 = `<script>alert("x")</script>`
content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, "")
content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, emailComposeOptions{})
if strings.Contains(content.HTML, "<script>") {
t.Fatalf("series title not escaped:\n%s", content.HTML)
}
}
func TestComposeNotificationEmailProfileAndUnsubscribe(t *testing.T) {
rows := []DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)}
opts := emailComposeOptions{
BaseURL: "https://silo.example.com",
ProfileName: "Emma & <Kids>",
UnsubscribeURL: "https://silo.example.com/api/v1/notifications/email/unsubscribe?token=tok",
}
content := composeNotificationEmail(EmailModePerEpisode, rows, opts)
if !strings.Contains(content.Subject, "(for Emma & <Kids>)") {
t.Fatalf("subject missing profile label: %q", content.Subject)
}
if strings.Contains(content.HTML, "<Kids>") {
t.Fatalf("profile name not escaped in HTML:\n%s", content.HTML)
}
if !strings.Contains(content.HTML, `href="https://silo.example.com/api/v1/notifications/email/unsubscribe?token=tok"`) {
t.Fatalf("unsubscribe link missing from HTML:\n%s", content.HTML)
}
if !strings.Contains(content.Text, "To stop these emails, open: "+opts.UnsubscribeURL) {
t.Fatalf("unsubscribe link missing from text:\n%s", content.Text)
}
plain := composeNotificationEmail(EmailModePerEpisode, rows, emailComposeOptions{})
if strings.Contains(plain.Subject, "(for") || strings.Contains(plain.Text, "To stop these emails") {
t.Fatalf("profile/unsubscribe copy leaked into unconfigured email: %q", plain.Subject)
}
}
func TestComposeVerificationEmail(t *testing.T) {
content := composeVerificationEmail(`<b>Emma</b>`, "https://silo.example.com/api/v1/notifications/email/verify?token=tok")
if !strings.Contains(content.Text, "https://silo.example.com/api/v1/notifications/email/verify?token=tok") {
t.Fatalf("verify link missing from text:\n%s", content.Text)
}
if strings.Contains(content.HTML, "<b>Emma</b>") {
t.Fatalf("profile name not escaped:\n%s", content.HTML)
}
}
func TestEmailTokens(t *testing.T) {
token, hash, err := newEmailToken()
if err != nil {
t.Fatalf("newEmailToken: %v", err)
}
if token == "" || hash == "" || token == hash {
t.Fatalf("degenerate token/hash: %q / %q", token, hash)
}
if hashEmailToken(token) != hash {
t.Fatal("hashEmailToken does not round-trip newEmailToken")
}
other, _, _ := newEmailToken()
if other == token {
t.Fatal("tokens are not unique")
}
}
func TestEmailUnsubscribeURL(t *testing.T) {
if got := emailUnsubscribeURL("", "tok"); got != "" {
t.Fatalf("URL built without a base: %q", got)
}
if got := emailUnsubscribeURL("https://x", ""); got != "" {
t.Fatalf("URL built without a token: %q", got)
}
want := "https://x/api/v1/notifications/email/unsubscribe?token=tok"
if got := emailUnsubscribeURL("https://x", "tok"); got != want {
t.Fatalf("unexpected unsubscribe URL %q", got)
}
}
func TestComposeNotificationEmailCapsRenderedItems(t *testing.T) {
rows := make([]DeliveryRow, 0, emailMaxItemsRendered+10)
for i := range emailMaxItemsRendered + 10 {
rows = append(rows, emailEpisodeRow(
fmt.Sprintf("01%03d", i), "p1", fmt.Sprintf("ep-%d", i), 1, i+1))
}
content := composeNotificationEmail(EmailModeDailyDigest, rows, "")
content := composeNotificationEmail(EmailModeDailyDigest, rows, emailComposeOptions{})
if !strings.Contains(content.Text, "and 10 more in your Silo inbox") {
t.Fatalf("overflow line missing:\n%s", content.Text)
}
+373 -42
View File
@@ -7,24 +7,39 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Email notification modes. The channel is account-level: email addresses
// live on users, not profiles, so one setting covers every profile on the
// account and the worker collapses cross-profile duplicates. The values are
// the shared account-channel modes.
// Email notification modes. The channel is profile-level: each profile owns
// its mode, dispatch watermark, and verified destination address. There is
// deliberately no fallback to the login account's email — without it, every
// profile of a household would funnel mail to the account holder. A profile
// receives nothing until its own address is verified. The values are the
// shared account-channel modes.
const (
EmailModeOff = ChannelModeOff
EmailModePerEpisode = ChannelModePerEpisode
EmailModeDailyDigest = ChannelModeDailyDigest
)
// EmailPrefs is one account's email notification state: the user-chosen mode
// plus the worker's dispatch watermark and failure backoff counters.
// Verification-send rate limits: a minimum gap between sends plus a daily
// cap, so the server cannot be used to spray arbitrary addresses.
const (
emailVerifyMinInterval = time.Minute
emailVerifyDailyCap = 10
)
// EmailPrefs is one profile's email notification state: the chosen mode, the
// custom-address verification state, and the worker's dispatch watermark and
// failure backoff counters.
type EmailPrefs struct {
ProfileID string
UserID int
Mode string
CustomEmail string
PendingEmail string
PendingExpiresAt *time.Time
WatermarkCreatedAt time.Time
WatermarkID string
LastDigestAt *time.Time
@@ -42,15 +57,17 @@ func NewEmailPrefsRepository(pool *pgxpool.Pool) *EmailPrefsRepository {
return &EmailPrefsRepository{pool: pool}
}
// Get returns the account's email prefs; missing rows default to mode off.
func (r *EmailPrefsRepository) Get(ctx context.Context, userID int) (EmailPrefs, error) {
prefs := EmailPrefs{UserID: userID, Mode: EmailModeOff}
// Get returns the profile's email prefs; missing rows default to mode off.
func (r *EmailPrefsRepository) Get(ctx context.Context, profileID string) (EmailPrefs, error) {
prefs := EmailPrefs{ProfileID: profileID, Mode: EmailModeOff}
err := r.pool.QueryRow(ctx, `
SELECT mode, watermark_created_at, watermark_id, last_digest_at,
SELECT user_id, mode, custom_email, pending_email, pending_expires_at,
watermark_created_at, watermark_id, last_digest_at,
last_attempt_at, consecutive_failures
FROM notification_email_prefs WHERE user_id = $1`,
userID,
).Scan(&prefs.Mode, &prefs.WatermarkCreatedAt, &prefs.WatermarkID,
FROM notification_email_prefs WHERE profile_id = $1`,
profileID,
).Scan(&prefs.UserID, &prefs.Mode, &prefs.CustomEmail, &prefs.PendingEmail,
&prefs.PendingExpiresAt, &prefs.WatermarkCreatedAt, &prefs.WatermarkID,
&prefs.LastDigestAt, &prefs.LastAttemptAt, &prefs.ConsecutiveFailures)
if errors.Is(err, pgx.ErrNoRows) {
return prefs, nil
@@ -58,21 +75,29 @@ func (r *EmailPrefsRepository) Get(ctx context.Context, userID int) (EmailPrefs,
if err != nil {
return prefs, fmt.Errorf("get email prefs: %w", err)
}
// An expired pending verification is dead; don't surface it.
if prefs.PendingExpiresAt != nil && prefs.PendingExpiresAt.Before(time.Now()) {
prefs.PendingEmail = ""
prefs.PendingExpiresAt = nil
}
return prefs, nil
}
// SetMode upserts the account's email mode. Enabling from off (or creating
// SetMode upserts the profile's email mode. Enabling from off (or creating
// the row) resets the watermark to now so the backlog never floods a fresh
// opt-in, and clears failure backoff so the first send happens promptly.
func (r *EmailPrefsRepository) SetMode(ctx context.Context, userID int, mode string) error {
// The unsubscribe token is not minted here: the send path backfills it under
// the claim lock right before the first email that embeds it.
func (r *EmailPrefsRepository) SetMode(ctx context.Context, userID int, profileID, mode string) error {
if !ValidChannelMode(mode) {
return fmt.Errorf("invalid email mode %q", mode)
}
_, err := r.pool.Exec(ctx, `
INSERT INTO notification_email_prefs (user_id, mode)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET
INSERT INTO notification_email_prefs (profile_id, user_id, mode)
VALUES ($1, $2, $3)
ON CONFLICT (profile_id) DO UPDATE SET
mode = EXCLUDED.mode,
user_id = EXCLUDED.user_id,
watermark_created_at = CASE
WHEN notification_email_prefs.mode = 'off' THEN now()
ELSE notification_email_prefs.watermark_created_at
@@ -84,31 +109,302 @@ func (r *EmailPrefsRepository) SetMode(ctx context.Context, userID int, mode str
last_attempt_at = NULL,
consecutive_failures = 0,
updated_at = now()`,
userID, mode)
profileID, userID, mode)
if err != nil {
return fmt.Errorf("set email mode: %w", err)
}
return nil
}
// ListActiveRecipients returns every account with email notifications on and
// a usable address. Disabled or deleted accounts drop out of the join.
func (r *EmailPrefsRepository) ListActiveRecipients(ctx context.Context) ([]accountRecipient, error) {
// Errors surfaced by the custom-address verification flow.
var (
ErrEmailVerifyRateLimited = errors.New("verification emails are rate limited; try again later")
ErrEmailAddressInUse = errors.New("email address is already in use")
)
// addressInUse reports whether the address is already claimed: verified for
// another profile, or identifying another login account (its email, or a
// username that is an email address). The requesting profile and its own
// account are excluded — pointing a profile at its own account email is the
// expected common case.
func addressInUse(ctx context.Context, q querier, address, profileID string, userID int) (bool, error) {
var inUse bool
err := q.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM notification_email_prefs
WHERE lower(custom_email) = lower($1) AND profile_id <> $2
) OR EXISTS (
SELECT 1 FROM users
WHERE id <> $3
AND (lower(COALESCE(email, '')) = lower($1) OR lower(username) = lower($1))
)`,
address, profileID, userID,
).Scan(&inUse)
if err != nil {
return false, fmt.Errorf("check address in use: %w", err)
}
return inUse, nil
}
// querier is the subset of pgx.Tx / pgxpool.Pool the uniqueness check needs.
type querier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// RequestPendingAddress stores a new pending custom address and its
// verification token, enforcing the resend rate limits atomically. The
// previous pending state (if any) is replaced. The daily cap's "day" is the
// UTC day of pending_last_sent_at: the counter resets on the first request of
// a new day.
func (r *EmailPrefsRepository) RequestPendingAddress(ctx context.Context, userID int, profileID, email, tokenHash string, expiresAt time.Time) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin pending address tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var lastSentAt *time.Time
var sendsToday int
err = tx.QueryRow(ctx, `
SELECT pending_last_sent_at, verify_sends_today
FROM notification_email_prefs WHERE profile_id = $1 FOR UPDATE`,
profileID,
).Scan(&lastSentAt, &sendsToday)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("read verify rate state: %w", err)
}
now := time.Now()
if lastSentAt != nil && now.Sub(*lastSentAt) < emailVerifyMinInterval {
return ErrEmailVerifyRateLimited
}
if lastSentAt == nil || !sameUTCDay(*lastSentAt, now) {
sendsToday = 0
}
if sendsToday >= emailVerifyDailyCap {
return ErrEmailVerifyRateLimited
}
// Friendly early rejection; the authoritative check re-runs at verify
// time, so a conflict that appears in between still cannot land.
inUse, err := addressInUse(ctx, tx, email, profileID, userID)
if err != nil {
return err
}
if inUse {
return ErrEmailAddressInUse
}
_, err = tx.Exec(ctx, `
INSERT INTO notification_email_prefs
(profile_id, user_id, pending_email, pending_token_hash,
pending_expires_at, pending_last_sent_at, verify_sends_today)
VALUES ($1, $2, $3, $4, $5, now(), $6)
ON CONFLICT (profile_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
pending_email = EXCLUDED.pending_email,
pending_token_hash = EXCLUDED.pending_token_hash,
pending_expires_at = EXCLUDED.pending_expires_at,
pending_last_sent_at = now(),
verify_sends_today = EXCLUDED.verify_sends_today,
updated_at = now()`,
profileID, userID, email, tokenHash, expiresAt, sendsToday+1)
if err != nil {
return fmt.Errorf("store pending address: %w", err)
}
return tx.Commit(ctx)
}
// sameUTCDay reports whether both instants fall on the same UTC calendar day.
func sameUTCDay(a, b time.Time) bool {
ay, am, ad := a.UTC().Date()
by, bm, bd := b.UTC().Date()
return ay == by && am == bm && ad == bd
}
// EmailVerifyOutcome classifies one verification-link click.
type EmailVerifyOutcome int
const (
// EmailVerifyInvalid: unknown, already-used, or expired token.
EmailVerifyInvalid EmailVerifyOutcome = iota
// EmailVerifyOK: the pending address is now the verified destination.
EmailVerifyOK
// EmailVerifyConflict: the address was claimed by another profile or
// account after the verification email went out.
EmailVerifyConflict
)
// ConsumeVerifyToken promotes the pending address matching the token hash to
// the verified custom address. Single-use: the pending state is cleared on
// every outcome except invalid. Uniqueness is re-checked here — request-time
// checks can be raced — and the partial unique index on lower(custom_email)
// backstops concurrent verifications of the same address.
func (r *EmailPrefsRepository) ConsumeVerifyToken(ctx context.Context, tokenHash string) (EmailVerifyOutcome, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return EmailVerifyInvalid, fmt.Errorf("begin verify tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var profileID, pendingEmail string
var userID int
err = tx.QueryRow(ctx, `
SELECT profile_id, user_id, pending_email
FROM notification_email_prefs
WHERE pending_token_hash = $1 AND pending_token_hash <> ''
AND pending_email <> ''
AND (pending_expires_at IS NULL OR pending_expires_at > now())
FOR UPDATE`,
tokenHash,
).Scan(&profileID, &userID, &pendingEmail)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVerifyInvalid, nil
}
if err != nil {
return EmailVerifyInvalid, fmt.Errorf("look up verify token: %w", err)
}
clearPending := func(outcome EmailVerifyOutcome) (EmailVerifyOutcome, error) {
_, err := tx.Exec(ctx, `
UPDATE notification_email_prefs SET
pending_email = '',
pending_token_hash = '',
pending_expires_at = NULL,
updated_at = now()
WHERE profile_id = $1`,
profileID)
if err != nil {
return EmailVerifyInvalid, fmt.Errorf("clear pending address: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return EmailVerifyInvalid, err
}
return outcome, nil
}
inUse, err := addressInUse(ctx, tx, pendingEmail, profileID, userID)
if err != nil {
return EmailVerifyInvalid, err
}
if inUse {
return clearPending(EmailVerifyConflict)
}
_, err = tx.Exec(ctx, `
UPDATE notification_email_prefs SET
custom_email = pending_email,
pending_email = '',
pending_token_hash = '',
pending_expires_at = NULL,
updated_at = now()
WHERE profile_id = $1`,
profileID)
if isUniqueViolation(err) {
// Lost a same-instant race with another profile verifying the same
// address; the unique index decided the winner.
_ = tx.Rollback(ctx)
return r.consumeConflictLoser(ctx, profileID)
}
if err != nil {
return EmailVerifyInvalid, fmt.Errorf("consume verify token: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return EmailVerifyInvalid, err
}
return EmailVerifyOK, nil
}
// consumeConflictLoser clears the pending state of a profile that lost a
// concurrent-verification race, in a fresh transaction (the racing one is
// poisoned by the constraint violation).
func (r *EmailPrefsRepository) consumeConflictLoser(ctx context.Context, profileID string) (EmailVerifyOutcome, error) {
_, err := r.pool.Exec(ctx, `
UPDATE notification_email_prefs SET
pending_email = '',
pending_token_hash = '',
pending_expires_at = NULL,
updated_at = now()
WHERE profile_id = $1`,
profileID)
if err != nil {
return EmailVerifyInvalid, fmt.Errorf("clear losing pending address: %w", err)
}
return EmailVerifyConflict, nil
}
// isUniqueViolation reports whether err is a Postgres unique-constraint
// violation (SQLSTATE 23505).
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// ClearCustomAddress removes the verified address and any in-flight
// verification. The channel switches off in the same statement: without an
// address there is no destination, and leaving the mode on would silently
// re-arm delivery the moment a new address verifies.
func (r *EmailPrefsRepository) ClearCustomAddress(ctx context.Context, profileID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notification_email_prefs SET
custom_email = '',
pending_email = '',
pending_token_hash = '',
pending_expires_at = NULL,
mode = 'off',
updated_at = now()
WHERE profile_id = $1`,
profileID)
if err != nil {
return fmt.Errorf("clear custom address: %w", err)
}
return nil
}
// UnsubscribeByToken switches the matching profile's email mode off. ok is
// false when no row carries the token.
func (r *EmailPrefsRepository) UnsubscribeByToken(ctx context.Context, token string) (ok bool, err error) {
tag, err := r.pool.Exec(ctx, `
UPDATE notification_email_prefs SET
mode = 'off',
updated_at = now()
WHERE unsubscribe_token = $1 AND unsubscribe_token <> ''`,
token)
if err != nil {
return false, fmt.Errorf("unsubscribe by token: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// DeleteForProfile purges the profile's email prefs (profile deletion).
func (r *EmailPrefsRepository) DeleteForProfile(ctx context.Context, profileID string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM notification_email_prefs WHERE profile_id = $1`, profileID)
if err != nil {
return fmt.Errorf("delete email prefs: %w", err)
}
return nil
}
// ListActiveRecipients returns every profile with email notifications on and
// a verified destination address. Disabled or deleted accounts drop out of
// the join.
func (r *EmailPrefsRepository) ListActiveRecipients(ctx context.Context) ([]accountRecipient[string], error) {
rows, err := r.pool.Query(ctx, `
SELECT p.user_id, p.mode, p.watermark_created_at, p.watermark_id,
SELECT p.profile_id, p.mode, p.watermark_created_at, p.watermark_id,
p.last_digest_at, p.last_attempt_at, p.consecutive_failures
FROM notification_email_prefs p
JOIN users u ON u.id = p.user_id AND u.enabled AND COALESCE(u.email, '') <> ''
WHERE p.mode <> 'off'
ORDER BY p.user_id`)
JOIN users u ON u.id = p.user_id AND u.enabled
WHERE p.mode <> 'off' AND p.custom_email <> ''
ORDER BY p.profile_id`)
if err != nil {
return nil, fmt.Errorf("list email recipients: %w", err)
}
defer rows.Close()
out := make([]accountRecipient, 0, 8)
out := make([]accountRecipient[string], 0, 8)
for rows.Next() {
var rec accountRecipient
if err := rows.Scan(&rec.UserID, &rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID,
var rec accountRecipient[string]
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 email recipient: %w", err)
}
@@ -117,18 +413,19 @@ func (r *EmailPrefsRepository) ListActiveRecipients(ctx context.Context) ([]acco
return out, rows.Err()
}
// 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 *EmailPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient, error) {
rec := accountRecipient{UserID: userID}
// claimForUpdate locks the profile's prefs row for one dispatch attempt.
// SKIP LOCKED makes concurrent nodes pass over each other's in-flight
// profiles instead of double-sending; (nil, nil) means another node holds
// the row.
func (r *EmailPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, profileID string) (*accountRecipient[string], error) {
rec := accountRecipient[string]{Key: profileID}
err := tx.QueryRow(ctx, `
SELECT mode, watermark_created_at, watermark_id, last_digest_at,
last_attempt_at, consecutive_failures
FROM notification_email_prefs
WHERE user_id = $1
WHERE profile_id = $1
FOR UPDATE SKIP LOCKED`,
userID,
profileID,
).Scan(&rec.Mode, &rec.WatermarkCreatedAt, &rec.WatermarkID,
&rec.LastDigestAt, &rec.LastAttemptAt, &rec.ConsecutiveFailures)
if errors.Is(err, pgx.ErrNoRows) {
@@ -140,10 +437,44 @@ func (r *EmailPrefsRepository) claimForUpdate(ctx context.Context, tx pgx.Tx, us
return &rec, nil
}
// destinationForSend resolves where the locked profile's email goes — its
// verified address — plus the row's owner and unsubscribe token. Read under
// the claim so a mid-pass address removal fails cleanly instead of sending
// to a stale recipient.
func (r *EmailPrefsRepository) destinationForSend(ctx context.Context, tx pgx.Tx, profileID string) (email string, userID int, unsubscribeToken string, err error) {
err = tx.QueryRow(ctx, `
SELECT p.custom_email, p.user_id, p.unsubscribe_token
FROM notification_email_prefs p
JOIN users u ON u.id = p.user_id AND u.enabled
WHERE p.profile_id = $1`,
profileID,
).Scan(&email, &userID, &unsubscribeToken)
if errors.Is(err, pgx.ErrNoRows) {
return "", 0, "", fmt.Errorf("profile %s has no usable account", profileID)
}
if err != nil {
return "", 0, "", fmt.Errorf("resolve email destination: %w", err)
}
return email, userID, unsubscribeToken, nil
}
// setUnsubscribeToken backfills a missing unsubscribe token under the claim
// lock (rows migrated from the account-level table start without one).
func (r *EmailPrefsRepository) setUnsubscribeToken(ctx context.Context, tx pgx.Tx, profileID, token string) error {
_, err := tx.Exec(ctx, `
UPDATE notification_email_prefs SET unsubscribe_token = $2, updated_at = now()
WHERE profile_id = $1 AND unsubscribe_token = ''`,
profileID, token)
if err != nil {
return fmt.Errorf("set unsubscribe token: %w", err)
}
return nil
}
// markSent advances the watermark past everything the email covered and
// resets failure backoff. digestAt is non-nil for digest sends (including
// empty digests, so eligibility stops re-checking until tomorrow).
func (r *EmailPrefsRepository) markSent(ctx context.Context, tx pgx.Tx, userID int, watermark Cursor, digestAt *time.Time) error {
func (r *EmailPrefsRepository) markSent(ctx context.Context, tx pgx.Tx, profileID string, watermark Cursor, digestAt *time.Time) error {
_, err := tx.Exec(ctx, `
UPDATE notification_email_prefs SET
watermark_created_at = $2,
@@ -152,8 +483,8 @@ func (r *EmailPrefsRepository) markSent(ctx context.Context, tx pgx.Tx, userID i
last_attempt_at = now(),
consecutive_failures = 0,
updated_at = now()
WHERE user_id = $1`,
userID, watermark.CreatedAt, watermark.ID, digestAt)
WHERE profile_id = $1`,
profileID, watermark.CreatedAt, watermark.ID, digestAt)
if err != nil {
return fmt.Errorf("mark email sent: %w", err)
}
@@ -162,14 +493,14 @@ func (r *EmailPrefsRepository) markSent(ctx context.Context, tx pgx.Tx, userID i
// markFailure records a failed send for backoff; the watermark stays put so
// the next eligible pass retries the same items.
func (r *EmailPrefsRepository) markFailure(ctx context.Context, tx pgx.Tx, userID int) error {
func (r *EmailPrefsRepository) markFailure(ctx context.Context, tx pgx.Tx, profileID string) error {
_, err := tx.Exec(ctx, `
UPDATE notification_email_prefs SET
last_attempt_at = now(),
consecutive_failures = consecutive_failures + 1,
updated_at = now()
WHERE user_id = $1`,
userID)
WHERE profile_id = $1`,
profileID)
if err != nil {
return fmt.Errorf("mark email failure: %w", err)
}
+22 -4
View File
@@ -58,9 +58,12 @@ type System struct {
DiscordPrefs *DiscordPrefsRepository
mailSender mail.Sender
emailWorker *accountChannelWorker
discordWorker *accountChannelWorker
emailWorker *accountChannelWorker[string]
discordWorker *accountChannelWorker[int]
discordClient *discord.Client
// publicURL is the server's externally reachable base URL, used as the
// fallback for tokenized email links (see SetPublicURL).
publicURL string
webhookRepo *WebhookRepository
webhookDispatcher *WebhookDispatcher
@@ -137,10 +140,17 @@ func NewSystem(
// Email rides the shared SMTP core. Unlike the per-target channels it
// keeps no outbox: its dispatcher only nudges the watermark sweep.
var emailPrefs *EmailPrefsRepository
var emailWorker *accountChannelWorker
var emailChannelInst *emailChannel
var emailWorker *accountChannelWorker[string]
if mailSender != nil {
emailPrefs = NewEmailPrefsRepository(pool)
emailWorker = newEmailWorker(pool, deliveries, emailPrefs, settings, mailSender)
emailChannelInst = &emailChannel{
prefs: emailPrefs,
deliveries: deliveries,
settings: settings,
sender: mailSender,
}
emailWorker = newAccountChannelWorker(pool, emailChannelInst)
dispatchers = append(dispatchers, newNudgeDispatcher(emailWorker))
}
@@ -194,6 +204,9 @@ func NewSystem(
logger: slog.Default().With("component", "notifications.system"),
}
wsDispatcher.payload = system.PayloadForRow
if emailChannelInst != nil {
emailChannelInst.profileName = system.lookupProfileName
}
if sender != nil {
sender.operational = system.DispatchOperational
}
@@ -323,6 +336,11 @@ func (s *System) PurgeProfile(ctx context.Context, profileID string) error {
return fmt.Errorf("purge web push subscriptions: %w", err)
}
}
if s.EmailPrefs != nil {
if err := s.EmailPrefs.DeleteForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge email prefs: %w", err)
}
}
return nil
}
@@ -0,0 +1,83 @@
-- +goose Up
-- +goose StatementBegin
-- Re-keys the email notification channel from login accounts to profiles.
-- Each profile now owns its mode, dispatch watermark, and verified
-- destination address. There is deliberately no fallback to the account
-- email (it would funnel every profile's mail to the account holder), so
-- old account-level opt-ins are NOT carried over: a profile receives
-- nothing until its own address is verified and the mode re-enabled.
-- user_id is denormalized (no FK, no join to profile storage): profiles may
-- live in per-user SQLite stores, so Postgres never joins notification
-- tables against profile tables (see 20260611100000).
DROP TABLE public.notification_email_prefs;
CREATE TABLE public.notification_email_prefs (
profile_id text PRIMARY KEY,
user_id integer NOT NULL,
mode text NOT NULL DEFAULT 'off'
CHECK (mode IN ('off', 'per_episode', 'daily_digest', 'per_episode_and_digest')),
-- Verified destination address; '' = fall back to users.email.
custom_email text NOT NULL DEFAULT '',
-- In-flight address verification: the candidate address and the SHA-256
-- hex of its single-use token. Cleared on success or replacement.
pending_email text NOT NULL DEFAULT '',
pending_token_hash text NOT NULL DEFAULT '',
pending_expires_at timestamptz,
-- Verification-send rate limiting: minimum gap since the last send, plus
-- a daily cap counted within pending_last_sent_at's UTC day.
pending_last_sent_at timestamptz,
verify_sends_today integer NOT NULL DEFAULT 0,
-- Capability token embedded in every email's unsubscribe link; only
-- powers "set this profile's mode to off", so stored plaintext. Minted by
-- the send path right before the first email that embeds it.
unsubscribe_token text NOT NULL DEFAULT '',
watermark_created_at timestamptz NOT NULL DEFAULT now(),
watermark_id text NOT NULL DEFAULT '',
last_digest_at timestamptz,
last_attempt_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notification_email_prefs_user_idx
ON public.notification_email_prefs (user_id);
CREATE INDEX notification_email_prefs_pending_token_idx
ON public.notification_email_prefs (pending_token_hash)
WHERE pending_token_hash <> '';
CREATE INDEX notification_email_prefs_unsubscribe_token_idx
ON public.notification_email_prefs (unsubscribe_token)
WHERE unsubscribe_token <> '';
-- One verified destination per profile, globally unique: an address may not
-- serve two profiles. Backstops the application-level checks against
-- concurrent verifications.
CREATE UNIQUE INDEX notification_email_prefs_custom_email_key
ON public.notification_email_prefs (lower(custom_email))
WHERE custom_email <> '';
-- The per-profile sweep reads deliveries by profile in watermark order.
CREATE INDEX notification_deliveries_profile_created_idx
ON public.notification_deliveries (profile_id, created_at, id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS public.notification_deliveries_profile_created_idx;
-- Rollback restores the account-keyed schema empty: per-profile state
-- (verified addresses, watermarks) has no account-level representation, so
-- accounts re-opt-in. Symmetric with the up migration, which also carries
-- nothing over.
DROP TABLE public.notification_email_prefs;
CREATE TABLE public.notification_email_prefs (
user_id integer PRIMARY KEY,
mode text NOT NULL DEFAULT 'off'
CHECK (mode IN ('off', 'per_episode', 'daily_digest', 'per_episode_and_digest')),
watermark_created_at timestamptz NOT NULL DEFAULT now(),
watermark_id text NOT NULL DEFAULT '',
last_digest_at timestamptz,
last_attempt_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose StatementEnd
+16 -1
View File
@@ -2352,9 +2352,24 @@ export type NotificationChannelMode =
export type NotificationEmailMode = NotificationChannelMode;
export type NotificationDiscordMode = NotificationChannelMode;
/** Account-level (not per-profile): one mode covers all profiles. */
/**
* Profile-scoped email channel state. Each profile verifies its own
* destination address and receives nothing until it has one — there is no
* account-email fallback.
*/
export interface NotificationEmailPreferences {
mode: NotificationEmailMode;
/** Verified destination; "" = none, channel inert. */
custom_email: string;
/** Address awaiting link-click verification. */
pending_email: string;
/** False for child profiles, which cannot set addresses. */
can_edit_address: boolean;
}
/** PUT /notifications/email-preferences body: only the mode is writable. */
export interface NotificationEmailPreferencesUpdate {
mode: NotificationEmailMode;
}
/** Account-level Discord DM channel: link state, mode, and delivery health. */
+36 -1
View File
@@ -7,6 +7,7 @@ import type {
NotificationDiscordMode,
NotificationDiscordPreferences,
NotificationEmailPreferences,
NotificationEmailPreferencesUpdate,
NotificationListResponse,
NotificationPreferences,
NotificationReadEventPayload,
@@ -108,7 +109,7 @@ export function useEmailNotificationPreferences(enabled = true) {
export function useUpdateEmailNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: NotificationEmailPreferences) =>
mutationFn: (update: NotificationEmailPreferencesUpdate) =>
api<NotificationEmailPreferences>("/notifications/email-preferences", {
method: "PUT",
body: JSON.stringify(update),
@@ -122,6 +123,40 @@ export function useUpdateEmailNotificationPreferences() {
});
}
export function useRequestEmailNotificationAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
api<NotificationEmailPreferences>("/notifications/email-preferences/address", {
method: "PUT",
body: JSON.stringify({ email }),
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
toast.success(`Verification email sent to ${prefs.pending_email}`);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to send the verification email");
},
});
}
export function useClearEmailNotificationAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
api<NotificationEmailPreferences>("/notifications/email-preferences/address", {
method: "DELETE",
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to remove the custom address");
},
});
}
export function useDiscordNotificationPreferences(enabled = true) {
return useQuery({
queryKey: notificationKeys.discordPreferences(),
@@ -18,6 +18,7 @@ import {
import { toast } from "sonner";
import type {
NotificationChannelMode,
NotificationEmailPreferences,
NotificationPreferences,
NotificationWebhook,
NotificationWebhookInput,
@@ -47,12 +48,13 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/useAuth";
import {
useClearEmailNotificationAddress,
useDiscordLinkInit,
useDiscordNotificationPreferences,
useEmailNotificationPreferences,
useNotificationPreferences,
useRequestEmailNotificationAddress,
useUnlinkDiscord,
useUpdateDiscordNotificationPreferences,
useUpdateEmailNotificationPreferences,
@@ -216,8 +218,97 @@ function ChannelFrequencyRow({
);
}
/**
* Destination address for this profile's emails. There is no account-email
* fallback: the profile receives nothing until an address is verified here.
* Changing it sends a verification link to the new address; the old address
* keeps receiving mail until the link is clicked. Removing the address also
* turns the channel off. Child profiles cannot set addresses.
*/
function EmailDestinationRow({ prefs }: { prefs: NotificationEmailPreferences }) {
const [editing, setEditing] = useState(false);
const [address, setAddress] = useState("");
const requestAddress = useRequestEmailNotificationAddress();
const clearAddress = useClearEmailNotificationAddress();
const hasAddress = prefs.custom_email !== "";
const submit = () => {
const trimmed = address.trim();
if (!trimmed) {
return;
}
requestAddress.mutate(trimmed, {
onSuccess: () => {
setEditing(false);
setAddress("");
},
});
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm">Deliver to</div>
<div className="text-muted-foreground text-xs">
{hasAddress ? prefs.custom_email : "No address set — verify one to receive emails"}
</div>
</div>
{prefs.can_edit_address && (
<div className="flex items-center gap-2">
{hasAddress && (
<Button
variant="ghost"
size="sm"
disabled={clearAddress.isPending}
onClick={() => clearAddress.mutate()}
>
Remove
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setEditing((value) => !value)}>
{editing ? "Cancel" : hasAddress ? "Change" : "Add address"}
</Button>
</div>
)}
</div>
{editing && (
<div className="flex items-center gap-2">
<Input
type="email"
placeholder="name@example.com"
value={address}
onChange={(event) => setAddress(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
submit();
}
}}
className="max-w-xs"
/>
<Button size="sm" disabled={requestAddress.isPending || !address.trim()} onClick={submit}>
{requestAddress.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Send verification
</Button>
</div>
)}
{prefs.pending_email !== "" && (
<div className="text-xs text-amber-500">
Verification email sent to {prefs.pending_email} it becomes active once the link in it
is opened.
</div>
)}
{!prefs.can_edit_address && (
<div className="text-muted-foreground text-xs">
Child profiles can't receive email notifications.
</div>
)}
</div>
);
}
function EmailSection() {
const { user } = useAuth();
const capability = useNotificationCapability();
const emailCap = capability.data?.email;
const available = emailCap?.available ?? false;
@@ -233,7 +324,7 @@ function EmailSection() {
const allowPerEpisode = emailCap?.modes.includes("per_episode") ?? false;
const digestHour = String(emailCap?.digest_hour ?? 8).padStart(2, "0");
if (isLoading) {
if (isLoading || !prefs) {
return (
<SettingsGroup title="Email Notifications">
<Skeleton className="h-16 w-full" />
@@ -241,26 +332,29 @@ function EmailSection() {
);
}
const hasAddress = prefs.custom_email !== "";
return (
<SettingsGroup
title="Email Notifications"
description="Account-wide: one email covers every profile on this account."
description="Per profile: each profile verifies its own address and picks its own frequency."
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Send to {user?.email || "your account email"}</div>
<div className="text-sm font-medium">Email this profile's notifications</div>
<div className="text-muted-foreground text-xs">
Notifications you'd see in the inbox, delivered by email
</div>
</div>
<Switch
checked={enabled}
disabled={updatePrefs.isPending}
disabled={updatePrefs.isPending || (!enabled && !hasAddress)}
onCheckedChange={(checked) =>
updatePrefs.mutate({ mode: checked ? "daily_digest" : "off" })
}
/>
</div>
<EmailDestinationRow prefs={prefs} />
{enabled && (
<ChannelFrequencyRow
mode={mode}