package mail import ( "errors" "fmt" "log" "strings" ) // ErrNotConfigured is returned when a send is required but SMTP is not // admin-configured (and no env fallback is available). var ErrNotConfigured = errors.New("mail: SMTP is not configured; set platform mail settings in admin") // ResolveFunc loads current SMTP config (typically from platformsettings). // Callers must not log the returned password. type ResolveFunc func() (Config, error) // NewDynamic returns a Mailer that resolves SMTP config on each Send/Enabled. // Prefer this over New(cfg) so admin dashboard changes apply without restart. // When disabled or host empty, Send matches the historical no-op (log + nil) // so invite re-issue can still mint tokens; callers that need hard failure // should check Enabled() or use RequireConfigured. func NewDynamic(resolve ResolveFunc) Mailer { if resolve == nil { return &noopMailer{} } return &dynamicMailer{resolve: resolve} } type dynamicMailer struct { resolve ResolveFunc } func (d *dynamicMailer) load() (Config, error) { cfg, err := d.resolve() if err != nil { return Config{}, err } if strings.TrimSpace(cfg.Port) == "" { cfg.Port = "587" } return cfg, nil } func (d *dynamicMailer) Enabled() bool { cfg, err := d.load() if err != nil { return false } return cfg.Enabled && strings.TrimSpace(cfg.Host) != "" } func (d *dynamicMailer) Send(msg Message) error { cfg, err := d.load() if err != nil { log.Printf("mail: config resolve failed subject=%q", msg.Subject) return fmt.Errorf("%w: %v", ErrNotConfigured, err) } if !cfg.Enabled || strings.TrimSpace(cfg.Host) == "" { return (&noopMailer{}).Send(msg) } return (&smtpMailer{cfg: cfg}).Send(msg) } // RequireConfigured wraps a Mailer so Send fails clearly when delivery is off. func RequireConfigured(inner Mailer) Mailer { if inner == nil { return &requireConfiguredMailer{inner: &noopMailer{}} } return &requireConfiguredMailer{inner: inner} } type requireConfiguredMailer struct { inner Mailer } func (r *requireConfiguredMailer) Enabled() bool { return r.inner.Enabled() } func (r *requireConfiguredMailer) Send(msg Message) error { if !r.inner.Enabled() { return ErrNotConfigured } return r.inner.Send(msg) } // ConfigFromParts builds a Config from discrete fields (platformsettings bridge). func ConfigFromParts(enabled bool, host, port, user, password, from string) Config { if strings.TrimSpace(port) == "" { port = "587" } return Config{ Enabled: enabled, Host: strings.TrimSpace(host), Port: strings.TrimSpace(port), User: strings.TrimSpace(user), Password: password, From: strings.TrimSpace(from), } } // ApplyDryRun forces Enabled=false when dry-run is on so New/NewDynamic use the // noop path (log subject only). Host/from are preserved for diagnostics. func ApplyDryRun(dryRun bool, cfg Config) Config { if dryRun { cfg.Enabled = false } return cfg }