// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd // Package config loads nuntius configuration from a TOML file. // // Configuration supports env var expansion for secrets: // 1. A TOML file (config.toml) — checked into the repo or provisioned // per environment. Holds non-secret defaults and references to env vars. // 2. Environment variables — used for secrets like SMTP passwords. // Reference them in the TOML file as ${VAR_NAME} or $VAR_NAME. package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "sourcedock.dev/petrbalvin/interpres" ) // ValidFormTypes lists the form types nuntius knows how to handle. // Each form's `type` field is validated against this list in validate(). var ValidFormTypes = map[string]bool{ "contact": true, "feedback": true, "newsletter": true, "generic": true, } // Config is the root configuration for nuntius. type Config struct { Server ServerConfig `toml:"server"` Forms []Form `toml:"forms"` DataDir string `toml:"data_dir"` } // ServerConfig holds server-wide settings. type ServerConfig struct { Port int `toml:"port"` } // Form is one contact / feedback / newsletter endpoint. type Form struct { Name string `toml:"name"` Path string `toml:"path"` Type string `toml:"type"` SMTP SMTPConfig `toml:"smtp"` To string `toml:"to"` From string `toml:"from"` RateLimitPerHour int `toml:"rate_limit_per_hour"` HoneypotField string `toml:"honeypot_field"` AllowedOrigins []string `toml:"allowed_origins"` } // SMTPConfig holds SMTP credentials and connection details. type SMTPConfig struct { Host string `toml:"host"` Port int `toml:"port"` User string `toml:"user"` Password string `toml:"password"` } // Load reads, expands env vars in, and parses the TOML file at path. // If the file does not exist, a default template is created first. func Load(path string) (*Config, error) { if _, err := os.Stat(path); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) } if err := os.WriteFile(path, defaultConfig, 0644); err != nil { return nil, fmt.Errorf("write default config to %s: %w", path, err) } } raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } // Step 1: expand ${VAR_NAME} references in the raw text. expanded := os.Expand(string(raw), os.Getenv) // Step 2: parse TOML, rejecting unknown fields to catch typos early. var cfg Config if err := interpres.NewDecoder().DisallowUnknownFields().Decode([]byte(expanded), &cfg); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } if err := cfg.validate(); err != nil { return nil, err } return &cfg, nil } // validate runs sanity checks on the loaded config and applies defaults. func (c *Config) validate() error { if c.Server.Port == 0 { c.Server.Port = 8080 } if c.DataDir == "" { c.DataDir = "./data" } if len(c.Forms) == 0 { return fmt.Errorf("at least one form must be defined under `forms`") } paths := make(map[string]string, len(c.Forms)) for i := range c.Forms { f := &c.Forms[i] if f.Name == "" { return fmt.Errorf("form #%d: name is required", i+1) } if f.Path == "" { return fmt.Errorf("form %q: path is required", f.Name) } if f.Type == "" { c.Forms[i].Type = "contact" } if !ValidFormTypes[c.Forms[i].Type] { supported := make([]string, 0, len(ValidFormTypes)) for k := range ValidFormTypes { supported = append(supported, k) } return fmt.Errorf("form %q: type %q is not supported (must be one of: %s)", f.Name, c.Forms[i].Type, strings.Join(supported, ", ")) } if !strings.HasPrefix(f.Path, "/") { return fmt.Errorf("form %q: path must start with /", f.Name) } if existing, ok := paths[f.Path]; ok { return fmt.Errorf("form %q: duplicate path %q (also used by %q)", f.Name, f.Path, existing) } paths[f.Path] = f.Name if f.SMTP.Host == "" { return fmt.Errorf("form %q: smtp.host is required", f.Name) } if f.SMTP.Port == 0 { return fmt.Errorf("form %q: smtp.port is required", f.Name) } if f.SMTP.User == "" { return fmt.Errorf("form %q: smtp.user is required", f.Name) } if f.To == "" { return fmt.Errorf("form %q: `to` is required", f.Name) } if f.From == "" { // Default From to the SMTP user. c.Forms[i].From = f.SMTP.User } if f.RateLimitPerHour == 0 { c.Forms[i].RateLimitPerHour = 10 } if f.HoneypotField == "" { c.Forms[i].HoneypotField = "website" } } return nil } // ConfigPath returns the canonical path for nuntius configuration. func ConfigPath() string { return "/etc/nuntius/config.toml" } // AddrFor returns "host:port" for the given SMTP config. func (s SMTPConfig) AddrFor() string { return s.Host + ":" + strconv.Itoa(s.Port) } // defaultConfig is written to disk when no config exists yet. var defaultConfig = []byte(`# nuntius configuration. # # Secrets (SMTP passwords) are referenced as ${VAR_NAME} and expanded from the # environment at startup, so they never live in this file. data_dir = "./data" [server] port = 8080 [[forms]] name = "contact" type = "contact" path = "/api/nuntius/contact" to = "you@example.com" from = "contact@example.com" rate_limit_per_hour = 10 honeypot_field = "website" allowed_origins = ["https://example.com"] [forms.smtp] host = "smtp.example.com" port = 587 user = "contact@example.com" password = "${NUNTIUS_SMTP_PASSWORD}" [[forms]] name = "feedback" type = "feedback" path = "/api/nuntius/feedback" to = "you@example.com" from = "contact@example.com" rate_limit_per_hour = 10 honeypot_field = "website" allowed_origins = ["https://example.com"] [forms.smtp] host = "smtp.example.com" port = 587 user = "contact@example.com" password = "${NUNTIUS_SMTP_PASSWORD}" [[forms]] name = "newsletter" type = "newsletter" path = "/api/nuntius/newsletter" to = "you@example.com" from = "contact@example.com" rate_limit_per_hour = 100 honeypot_field = "bot_email" allowed_origins = ["https://example.com"] [forms.smtp] host = "smtp.example.com" port = 587 user = "contact@example.com" password = "${NUNTIUS_SMTP_PASSWORD}" `)