Replace the bare-bones inline HTML in notification, verification, and admin test emails with a shared branded layout in internal/mail, matching the web UI's Midnight Cinema theme (dark card shell, wordmark, mono episode-code badges, white primary CTA). The shell is built for email clients: tables + inline styles, explicit dark color-scheme, Outlook-safe button, and a width:100%/max-width pattern so the card shrinks correctly on phones. Plain-text bodies, subjects, and the link-free-when-unconfigured guarantee are unchanged; the admin test email gains an HTML body so the SMTP test doubles as a design preview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
6.3 KiB
Go
179 lines
6.3 KiB
Go
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 + "”"
|
|
}
|
|
expiry := "The link expires in 24 hours. If you didn't request this, ignore this email — " +
|
|
"nothing will be sent to this address."
|
|
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"+
|
|
"%s\n", who, verifyURL, expiry)
|
|
|
|
var body strings.Builder
|
|
body.WriteString(mail.EmailParagraph(fmt.Sprintf(
|
|
"This address was entered as the notification destination for %s on a Silo server.", who)))
|
|
body.WriteString(mail.EmailParagraph("To confirm and start receiving notifications here:"))
|
|
body.WriteString(mail.EmailButton("Confirm this address", verifyURL))
|
|
body.WriteString(fmt.Sprintf(
|
|
`<p style="margin:20px 0 0;font:400 12px/1.7 %s;color:%s;">Or paste this link into your browser:<br>`+
|
|
`<span style="font:400 12px/1.7 %s;word-break:break-all;">%s</span></p>`,
|
|
mail.EmailFont, mail.EmailColorMuted, mail.EmailFontMono, html.EscapeString(verifyURL)))
|
|
|
|
return emailContent{
|
|
Subject: "Confirm your Silo notification address",
|
|
Text: text,
|
|
HTML: mail.RenderLayout(mail.LayoutOptions{
|
|
Preheader: "Confirm this address to start receiving Silo notifications.",
|
|
Title: "Confirm your notification address",
|
|
BodyHTML: body.String(),
|
|
FooterHTML: html.EscapeString(expiry),
|
|
}),
|
|
}
|
|
}
|