Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

109 lines
2.9 KiB
Go

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
}