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>
261 lines
7.5 KiB
Go
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
|
|
}
|
|
}
|