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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user