Files
silo-server/internal/mail/mail.go
QuickandClaude Fable 5 ebf3352bda 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>
2026-06-11 20:55:30 -04:00

261 lines
7.5 KiB
Go

// Package mail provides Silo's shared outbound email facility. It is
// deliberately feature-agnostic: notifications, password resets, invites, and
// any future feature send through the same Sender so SMTP configuration,
// security policy, and diagnostics live in exactly one place.
//
// Configuration is read live from server settings (no restart required):
//
// email.enabled bool, default false
// email.smtp_host hostname (required to enable)
// email.smtp_port default 587
// email.smtp_security starttls (default) | tls | none
// email.smtp_username optional
// email.smtp_password optional; encrypted at rest (SensitiveSettingKeys)
// email.from_address required to enable
// email.from_name default "Silo"
package mail
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
gomail "github.com/wneessen/go-mail"
)
// Server-setting keys. email.smtp_password must stay registered in
// catalog.SensitiveSettingKeys so it is encrypted at rest and redacted from
// the admin API.
const (
SettingEnabled = "email.enabled"
SettingSMTPHost = "email.smtp_host"
SettingSMTPPort = "email.smtp_port"
SettingSMTPSecurity = "email.smtp_security"
SettingSMTPUsername = "email.smtp_username"
SettingSMTPPassword = "email.smtp_password"
SettingFromAddress = "email.from_address"
SettingFromName = "email.from_name"
)
const sendTimeout = 30 * time.Second
// Security modes for email.smtp_security.
const (
securityStartTLS = "starttls"
securityTLS = "tls"
securityNone = "none"
)
// ErrNotConfigured is returned by Send when email is disabled or incomplete.
// Callers treat email as an optional transport and degrade gracefully.
var ErrNotConfigured = errors.New("email is not configured")
// Message is one outbound email. At least one body variant is required; when
// both are set the message is sent as multipart/alternative.
type Message struct {
To []string
Subject string
TextBody string
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
// concurrent use.
type Sender interface {
// Enabled reports whether email is configured and turned on, so features
// can skip composing messages that could never send.
Enabled(ctx context.Context) bool
// Send delivers one message, returning ErrNotConfigured when email is off.
Send(ctx context.Context, msg Message) error
}
// SettingReader reads live server settings. Satisfied by
// catalog.EncryptedSettingsRepo (which transparently decrypts the password).
type SettingReader interface {
Get(ctx context.Context, key string) (string, error)
}
// SMTPSender sends through a user-configured SMTP server. Settings are read
// on every send: email volume is low (notifications, account flows) and live
// reads mean admin changes apply without a restart.
type SMTPSender struct {
settings SettingReader
}
// NewSMTPSender creates the shared SMTP sender.
func NewSMTPSender(settings SettingReader) *SMTPSender {
return &SMTPSender{settings: settings}
}
type smtpConfig struct {
host string
port int
security string
username string
password string
fromAddress string
fromName string
}
func (s *SMTPSender) loadConfig(ctx context.Context) (*smtpConfig, error) {
if s == nil || s.settings == nil {
return nil, ErrNotConfigured
}
// A settings-store failure must surface as an error, never be mistaken
// for "email is not configured" — that would silently hide real backend
// problems behind a graceful-degradation path.
var readErr error
get := func(key string) string {
value, err := s.settings.Get(ctx, key)
if err != nil && readErr == nil {
readErr = fmt.Errorf("read setting %s: %w", key, err)
}
return strings.TrimSpace(value)
}
enabled := truthy(get(SettingEnabled))
if readErr != nil {
return nil, readErr
}
if !enabled {
return nil, ErrNotConfigured
}
cfg := &smtpConfig{
host: get(SettingSMTPHost),
port: 587,
security: strings.ToLower(get(SettingSMTPSecurity)),
username: get(SettingSMTPUsername),
password: get(SettingSMTPPassword),
fromAddress: get(SettingFromAddress),
fromName: get(SettingFromName),
}
portRaw := get(SettingSMTPPort)
if readErr != nil {
return nil, readErr
}
if cfg.host == "" || cfg.fromAddress == "" {
return nil, ErrNotConfigured
}
if raw := portRaw; raw != "" {
port, err := strconv.Atoi(raw)
if err != nil || port < 1 || port > 65535 {
return nil, fmt.Errorf("invalid email.smtp_port %q", raw)
}
cfg.port = port
}
switch cfg.security {
case "":
cfg.security = securityStartTLS
case securityStartTLS, securityTLS, securityNone:
default:
return nil, fmt.Errorf("invalid email.smtp_security %q", cfg.security)
}
if cfg.fromName == "" {
cfg.fromName = "Silo"
}
return cfg, nil
}
// Enabled reports whether email can send right now.
func (s *SMTPSender) Enabled(ctx context.Context) bool {
_, err := s.loadConfig(ctx)
return err == nil
}
// Send delivers one message over SMTP.
func (s *SMTPSender) Send(ctx context.Context, msg Message) error {
cfg, err := s.loadConfig(ctx)
if err != nil {
return err
}
if len(msg.To) == 0 {
return errors.New("email message has no recipients")
}
if msg.TextBody == "" && msg.HTMLBody == "" {
return errors.New("email message has no body")
}
message, err := buildMessage(cfg, msg)
if err != nil {
return err
}
client, err := newClient(cfg)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
if err := client.DialAndSendWithContext(sendCtx, message); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return nil
}
func buildMessage(cfg *smtpConfig, msg Message) (*gomail.Msg, error) {
message := gomail.NewMsg()
if err := message.FromFormat(cfg.fromName, cfg.fromAddress); err != nil {
return nil, fmt.Errorf("invalid from address: %w", err)
}
if err := message.To(msg.To...); err != nil {
return nil, fmt.Errorf("invalid recipient: %w", err)
}
if msg.ReplyTo != "" {
if err := message.ReplyTo(msg.ReplyTo); err != nil {
return nil, fmt.Errorf("invalid reply-to address: %w", err)
}
}
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)
message.AddAlternativeString(gomail.TypeTextHTML, msg.HTMLBody)
case msg.HTMLBody != "":
message.SetBodyString(gomail.TypeTextHTML, msg.HTMLBody)
default:
message.SetBodyString(gomail.TypeTextPlain, msg.TextBody)
}
return message, nil
}
func newClient(cfg *smtpConfig) (*gomail.Client, error) {
options := []gomail.Option{
gomail.WithPort(cfg.port),
gomail.WithTimeout(sendTimeout),
}
switch cfg.security {
case securityTLS: // implicit TLS (typically port 465)
options = append(options, gomail.WithSSL())
case securityNone:
options = append(options, gomail.WithTLSPolicy(gomail.NoTLS))
default: // starttls
options = append(options, gomail.WithTLSPolicy(gomail.TLSMandatory))
}
if cfg.username != "" {
options = append(options,
gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover),
gomail.WithUsername(cfg.username),
gomail.WithPassword(cfg.password),
)
}
return gomail.NewClient(cfg.host, options...)
}
func truthy(value string) bool {
switch strings.ToLower(value) {
case "true", "1", "yes", "on":
return true
default:
return false
}
}