package smtp2shoutrrr import ( "bytes" "errors" "fmt" "log/slog" "net/url" "os" "strings" "github.com/pelletier/go-toml/v2" ) // LoadConfig reads the TOML configuration at path, applies its defaults and // checks that it can actually deliver something. func LoadConfig(path string) (*Config, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file %q: %w", path, err) } var config Config if err := toml.Unmarshal(raw, &config); err != nil { return nil, fmt.Errorf("decoding config file %q: %w", path, err) } warnUnknownKeys(path, raw) config.SetDefaults() if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid config file %q: %w", path, err) } return &config, nil } // warnUnknownKeys reports keys the decode silently ignored. Rejecting them // would break configurations that already carry a stray key, but dropping them // without a word hides the mistypes ([[Recipient]], Adresses) that otherwise // produce a server which accepts mail and forwards none of it. func warnUnknownKeys(path string, raw []byte) { var discard Config var strict *toml.StrictMissingError if !errors.As(toml.NewDecoder(bytes.NewReader(raw)).DisallowUnknownFields().Decode(&discard), &strict) { return } for i := range strict.Errors { slog.Warn("ignoring unknown key in config file", slog.String("path", path), slog.String("key", strings.Join(strict.Errors[i].Key(), "."))) } } type Config struct { Port int Username string Password string Recipients []ConfigRecipient CatchAll *ConfigRecipient } func (c *Config) SetDefaults() { if c.Port == 0 { c.Port = 11125 } } // Validate rejects configuration that would leave the server relaying for // anyone, or accepting mail it can never forward. A TOML decode ignores // unknown keys, so a mistyped table or field name ([[Recipient]], Adresses) // otherwise yields a server that answers 250 OK to everything and sends // nothing. func (c *Config) Validate() error { // Credentials used to fall back to username/password when unset, which // left the AUTH gate open to the most obvious guess there is — for // exactly the deployments that had configured the least. if c.Username == "" { return errors.New("no Username configured") } if c.Password == "" { return errors.New("no Password configured") } for i := range c.Recipients { r := &c.Recipients[i] if len(r.Addresses) == 0 { return fmt.Errorf("recipient %d has no Addresses", i) } if len(r.GetTargetURLs()) == 0 { return fmt.Errorf("recipient %d (%s) has no usable Targets", i, strings.Join(r.Addresses, ",")) } } if c.CatchAll != nil && len(c.CatchAll.GetTargetURLs()) == 0 { return errors.New("CatchAll has no usable Targets") } if len(c.Recipients) == 0 && c.CatchAll == nil { return errors.New("no Recipients and no CatchAll configured") } return nil } type ConfigRecipient struct { Addresses []string // email addresses Target string // deprecated: use Targets instead Targets []string // shoutrrr addresses (supports multiple) targetURLs []*url.URL // cached parsed URLs } func (cr *ConfigRecipient) GetTargetURLs() []*url.URL { if cr.targetURLs == nil { cr.targetURLs = make([]*url.URL, 0) // Collect all targets, merging Target into Targets allTargets := make([]string, 0) // Handle deprecated Target field if cr.Target != "" { allTargets = append(allTargets, cr.Target) slog.Warn("Target field is deprecated, use Targets instead", slog.String("addresses", strings.Join(cr.Addresses, ","))) } // Add all Targets allTargets = append(allTargets, cr.Targets...) // Parse and cache all URLs for _, target := range allTargets { parsedURL, err := url.Parse(target) if err != nil { slog.Error("failed to parse shoutrrr target URL", slog.String("target", target), slog.String("err", err.Error())) continue // Skip invalid URLs } // url.Parse accepts almost any string, so the scheme is what // actually distinguishes a shoutrrr target from a typo. if parsedURL.Scheme == "" { slog.Error("shoutrrr target URL has no scheme", slog.String("target", target)) continue } cr.targetURLs = append(cr.targetURLs, parsedURL) } } return cr.targetURLs }