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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user