// 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. It 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" "time" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/nuntius/internal/contactform" ) // Default values applied by validate() when fields are omitted. const ( DefaultPort = 8080 DefaultDataDir = "./data" DefaultRateLimitPerHour = 10 DefaultHoneypotField = "website" // Bind is the host part of the listen address: the dual-stack IPv6 // wildcard, rendered as "[::]:port" once the port is joined on. DefaultBind = "::" // HTTP server timeouts, in seconds. An explicit 0 keeps its net/http // meaning: the timeout is switched off. DefaultReadHeaderTimeoutSeconds = 10 DefaultReadTimeoutSeconds = 15 DefaultWriteTimeoutSeconds = 30 DefaultIdleTimeoutSeconds = 60 DefaultShutdownTimeoutSeconds = 15 // Request body cap and the rate limiter's memory mechanics. DefaultMaxBodyBytes = 1 << 20 DefaultRateLimitMaxBuckets = 32768 DefaultRateLimitCleanupSeconds = 3600 DefaultRateLimitMaxBucketAgeSeconds = 7200 // Newsletter double opt-in lifetime: 72 hours. DefaultPendingTTLSeconds = 72 * 3600 // Telegram API call bound, in seconds. DefaultTelegramTimeoutSeconds = 10 // SMTP conversation bound, in seconds. DefaultSMTPTimeoutSeconds = 20 // Mail branding: the subject prefix and the "Delivered by" footer. DefaultSubjectPrefix = "nuntius" DefaultEmailBrand = "nuntius" ) // validFormTypes lists the form types nuntius knows how to handle. var validFormTypes = map[string]bool{ "contact": true, "feedback": true, "newsletter": true, "generic": true, } // IsValidFormType reports whether t is a supported form type. func IsValidFormType(t string) bool { return validFormTypes[t] } // 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"` // Bind is the host part of the listen address, resolved to DefaultBind // by validate() when omitted. "::" binds the dual-stack wildcard, an // address or hostname binds that one only. Bind string `toml:"bind"` // TrustProxyHeaders opts in to client IPs read from X-Forwarded-For / // X-Real-IP instead of the connection peer address. It is off by // default because those headers are client-controlled: without a // trusted reverse proxy in front of nuntius that overwrites them, // enabling this would let any caller forge its rate-limit identity. TrustProxyHeaders bool `toml:"trust_proxy_headers"` // MetricsToken guards GET /metrics with a bearer token. Empty (the // default) keeps the endpoint open, protected at the reverse proxy // like any other operational surface. The value supports the same // ${VAR} expansion as the rest of the file. MetricsToken string `toml:"metrics_token"` // The timeout fields are pointers so that an omitted key falls back to // its default while an explicit 0 keeps its net/http meaning: the // timeout is switched off. The getters return durations. ReadHeaderTimeoutSeconds *int `toml:"read_header_timeout_seconds"` ReadTimeoutSeconds *int `toml:"read_timeout_seconds"` WriteTimeoutSeconds *int `toml:"write_timeout_seconds"` IdleTimeoutSeconds *int `toml:"idle_timeout_seconds"` ShutdownTimeoutSeconds *int `toml:"shutdown_timeout_seconds"` // MaxBodyBytes caps the JSON request body. An explicit value must be // at least 1 byte. MaxBodyBytes *int `toml:"max_body_bytes"` // Rate limiter mechanics: the per-form cap on distinct IP buckets, the // cleanup tick and the age at which an idle bucket is dropped (also // applied when a restart restores the persisted buckets). Explicit // values must be at least 1. RateLimitMaxBuckets *int `toml:"rate_limit_max_buckets"` RateLimitCleanupSeconds *int `toml:"rate_limit_cleanup_seconds"` RateLimitMaxBucketAgeSeconds *int `toml:"rate_limit_max_bucket_age_seconds"` } // ReadHeaderTimeout returns the budget for reading the request headers. func (s ServerConfig) ReadHeaderTimeout() time.Duration { return secondsOrDefault(s.ReadHeaderTimeoutSeconds, DefaultReadHeaderTimeoutSeconds) } // ReadTimeout returns the budget for reading the request body. func (s ServerConfig) ReadTimeout() time.Duration { return secondsOrDefault(s.ReadTimeoutSeconds, DefaultReadTimeoutSeconds) } // WriteTimeout returns the budget for writing the response. func (s ServerConfig) WriteTimeout() time.Duration { return secondsOrDefault(s.WriteTimeoutSeconds, DefaultWriteTimeoutSeconds) } // IdleTimeout returns the keep-alive idle budget. func (s ServerConfig) IdleTimeout() time.Duration { return secondsOrDefault(s.IdleTimeoutSeconds, DefaultIdleTimeoutSeconds) } // ShutdownTimeout returns the graceful shutdown budget. func (s ServerConfig) ShutdownTimeout() time.Duration { return secondsOrDefault(s.ShutdownTimeoutSeconds, DefaultShutdownTimeoutSeconds) } // BodyLimit returns the request body cap in bytes. func (s ServerConfig) BodyLimit() int { if s.MaxBodyBytes == nil { return DefaultMaxBodyBytes } return *s.MaxBodyBytes } // MaxRateLimitBuckets returns the per-form cap on distinct IP buckets. func (s ServerConfig) MaxRateLimitBuckets() int { if s.RateLimitMaxBuckets == nil { return DefaultRateLimitMaxBuckets } return *s.RateLimitMaxBuckets } // RateLimitCleanup returns the interval between bucket cleanup sweeps. func (s ServerConfig) RateLimitCleanup() time.Duration { return secondsOrDefault(s.RateLimitCleanupSeconds, DefaultRateLimitCleanupSeconds) } // RateLimitMaxBucketAge returns the age at which an idle bucket is dropped. func (s ServerConfig) RateLimitMaxBucketAge() time.Duration { return secondsOrDefault(s.RateLimitMaxBucketAgeSeconds, DefaultRateLimitMaxBucketAgeSeconds) } // secondsOrDefault converts an optional seconds value into a duration: a // nil pointer yields the default, an explicit 0 stays 0 (the documented // "switched off" meaning), anything else is the value in seconds. func secondsOrDefault(v *int, def int) time.Duration { if v == nil { return time.Duration(def) * time.Second } return time.Duration(*v) * time.Second } // Form is one contact / feedback / newsletter endpoint. // // Optional numeric and string options are pointers so that an omitted key // can fall back to its default while an explicit zero value keeps its // documented meaning (`rate_limit_per_hour = 0` disables the limit, // `honeypot_field = ""` disables the honeypot). // // The validation keys (services, require_name, require_message and the // four rune limits) override the built-in preset of the form's type, so a // new form shape is a matter of configuration, never of Go code. 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"` // RedirectURL turns the form into a plain HTML form target: an // accepted submission answers 303 See Other with this location, so // the form works without JavaScript. Empty (the default) keeps the // JSON contract. RedirectURL string `toml:"redirect_url"` // Archive persists every accepted submission to // data_dir/archive-.jsonl before the mail is attempted, so a // failed SMTP round-trip loses nothing. Newsletter forms always // persist through the double opt-in log instead and reject the key. Archive bool `toml:"archive"` // AutoReply mails the submitter a short receipt confirming the // message arrived. The submitter's address is always validated // first and the request is rate limited like any other, so the // receipt cannot be turned into a mail relay. Newsletter forms // reject the key: their subscribers already receive the // confirmation mail. AutoReply bool `toml:"auto_reply"` // Telegram is the optional notification channel: the submission // summary lands in the chat alongside the mail. The submission // counts as delivered when either channel gets through. Newsletter // forms reject the key: the double opt-in flow is mail-native. Telegram *TelegramConfig `toml:"telegram"` // Services is the allow-list for the optional service payload field. // A nil value (the key omitted) keeps the type's preset behaviour: the // built-in list for contact, no service validation for the other // types. An explicit list applies to any type; the empty value always // passes and the "*" entry accepts any value. An explicit empty list // accepts only the empty value. Services []string `toml:"services"` // RequireName and RequireMessage switch the two free-text fields into // the validation. The email address is always required. RequireName *bool `toml:"require_name"` RequireMessage *bool `toml:"require_message"` // Length limits in runes; the preset values are 2 and 100 for the name // and 10 and 5000 for the message. MinNameRunes *int `toml:"min_name_runes"` MaxNameRunes *int `toml:"max_name_runes"` MinMessageRunes *int `toml:"min_message_runes"` MaxMessageRunes *int `toml:"max_message_runes"` // PendingTTLSeconds is the double opt-in lifetime for newsletter // forms; the default is 72 hours. PendingTTLSeconds *int `toml:"pending_ttl_seconds"` // SubjectPrefix carries the "[nuntius/]" segment of every mail // subject; an explicit empty string drops the segment. SubjectPrefix *string `toml:"subject_prefix"` // EmailBrand carries the "Delivered by " footer; an explicit // empty string drops the footer. EmailBrand *string `toml:"email_brand"` } // RateLimit returns the effective submissions-per-hour cap for the form. // An omitted value yields DefaultRateLimitPerHour; an explicit 0 disables // rate limiting. Validate rejects negative values at load time. func (f *Form) RateLimit() int { if f.RateLimitPerHour == nil { return DefaultRateLimitPerHour } return *f.RateLimitPerHour } // Honeypot returns the name of the hidden anti-bot field. An omitted value // yields DefaultHoneypotField; an explicit empty string disables the // honeypot for the form. func (f *Form) Honeypot() string { if f.HoneypotField == nil { return DefaultHoneypotField } return *f.HoneypotField } // PendingTTL returns the double opt-in lifetime for newsletter forms. func (f *Form) PendingTTL() time.Duration { if f.PendingTTLSeconds == nil { return time.Duration(DefaultPendingTTLSeconds) * time.Second } return time.Duration(*f.PendingTTLSeconds) * time.Second } // EmailSubjectPrefix returns the "[/
]" segment of every // mail subject; an explicit empty string disables the segment. func (f *Form) EmailSubjectPrefix() string { if f.SubjectPrefix == nil { return DefaultSubjectPrefix } return *f.SubjectPrefix } // Brand returns the "Delivered by " footer name; an explicit empty // string disables the footer. func (f *Form) Brand() string { if f.EmailBrand == nil { return DefaultEmailBrand } return *f.EmailBrand } // Policy builds the validation policy for this form: the built-in preset // for its type with every configured key overriding the preset value. func (f *Form) Policy() contactform.Policy { p := contactform.Preset(f.Type) if f.RequireName != nil { p.RequireName = *f.RequireName } if f.RequireMessage != nil { p.RequireMessage = *f.RequireMessage } if f.MinNameRunes != nil { p.MinNameRunes = *f.MinNameRunes } if f.MaxNameRunes != nil { p.MaxNameRunes = *f.MaxNameRunes } if f.MinMessageRunes != nil { p.MinMessageRunes = *f.MinMessageRunes } if f.MaxMessageRunes != nil { p.MaxMessageRunes = *f.MaxMessageRunes } if f.Services != nil { p.Services = f.Services } return p } // SMTPConfig holds SMTP credentials and connection details. type SMTPConfig struct { Host string `toml:"host"` Port int `toml:"port"` User string `toml:"user"` // Password is expanded from the environment before the TOML parse. Password string `toml:"password"` // RequireTLS aborts delivery when an SMTP server on a STARTTLS port // never advertises STARTTLS. Irrelevant for implicit-TLS ports. RequireTLS bool `toml:"require_tls"` // TimeoutSeconds bounds the whole SMTP conversation. An explicit value // must be at least 1. TimeoutSeconds *int `toml:"timeout_seconds"` } // TelegramConfig holds the optional Telegram notification channel: one // bot posting submission summaries into one chat. type TelegramConfig struct { // BotToken is the bot's token from BotFather, expanded from the // environment like every other secret. BotToken string `toml:"bot_token"` // ChatID is the chat receiving the summaries: a numeric chat id // (group chats carry a negative number) or an @channelusername. ChatID string `toml:"chat_id"` // TimeoutSeconds bounds the whole API call. An explicit value must // be at least 1. TimeoutSeconds *int `toml:"timeout_seconds"` } // Timeout returns the bound on the whole Telegram API call. func (t TelegramConfig) Timeout() time.Duration { if t.TimeoutSeconds == nil { return time.Duration(DefaultTelegramTimeoutSeconds) * time.Second } return time.Duration(*t.TimeoutSeconds) * time.Second } // Timeout returns the bound on the whole SMTP conversation. func (s SMTPConfig) Timeout() time.Duration { if s.TimeoutSeconds == nil { return time.Duration(DefaultSMTPTimeoutSeconds) * time.Second } return time.Duration(*s.TimeoutSeconds) * time.Second } // ImplicitTLSPort is the SMTP port that speaks TLS from the first byte. // Port 465 upgrades the connection itself instead of negotiating STARTTLS // inside a plaintext session. const ImplicitTLSPort = 465 // 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) } // O_EXCL makes the create atomic: if two instances race, only one // succeeds and the other sees os.ErrExist, which is safe to ignore. f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) if err == nil { if _, werr := f.Write(defaultConfig); werr != nil { f.Close() return nil, fmt.Errorf("write default config to %s: %w", path, werr) } if cerr := f.Close(); cerr != nil { return nil, fmt.Errorf("close default config %s: %w", path, cerr) } } else if !os.IsExist(err) { return nil, fmt.Errorf("create default config %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, failing on // variables that are referenced but not set. expanded, err := expandConfig(string(raw)) if err != nil { return nil, fmt.Errorf("expand env vars in %s: %w", path, err) } // Step 2: parse TOML, rejecting unknown fields to catch typos early. var cfg Config if err := interpres.Unmarshal([]byte(expanded), &cfg, interpres.RejectUnknownFields(true)); 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 = DefaultPort } if c.Server.Port < 1 || c.Server.Port > 65535 { return fmt.Errorf("server.port must be between 1 and 65535, got %d", c.Server.Port) } if c.Server.Bind == "" { c.Server.Bind = DefaultBind } if strings.ContainsAny(c.Server.Bind, " \t\r\n") { return fmt.Errorf("server.bind %q must not contain whitespace", c.Server.Bind) } for name, v := range map[string]*int{ "server.read_header_timeout_seconds": c.Server.ReadHeaderTimeoutSeconds, "server.read_timeout_seconds": c.Server.ReadTimeoutSeconds, "server.write_timeout_seconds": c.Server.WriteTimeoutSeconds, "server.idle_timeout_seconds": c.Server.IdleTimeoutSeconds, "server.shutdown_timeout_seconds": c.Server.ShutdownTimeoutSeconds, } { if v != nil && *v < 0 { return fmt.Errorf("%s must be >= 0, got %d", name, *v) } } for name, v := range map[string]*int{ "server.max_body_bytes": c.Server.MaxBodyBytes, "server.rate_limit_max_buckets": c.Server.RateLimitMaxBuckets, "server.rate_limit_cleanup_seconds": c.Server.RateLimitCleanupSeconds, "server.rate_limit_max_bucket_age_seconds": c.Server.RateLimitMaxBucketAgeSeconds, } { if v != nil && *v < 1 { return fmt.Errorf("%s must be >= 1, got %d", name, *v) } } if c.DataDir == "" { c.DataDir = DefaultDataDir } 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 !isValidName(f.Name) { return fmt.Errorf("form %q: name may only contain letters, digits, hyphens and underscores", f.Name) } if f.Path == "" { return fmt.Errorf("form %q: path is required", f.Name) } if f.Type == "" { c.Forms[i].Type = "contact" } if !IsValidFormType(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 !isValidPath(f.Path) { return fmt.Errorf("form %q: path %q must start with / and contain only letters, digits, '-', '_', '.', '/' with no empty segments", f.Name, f.Path) } 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.RedirectURL != "" && strings.ContainsAny(f.RedirectURL, " \t\r\n") { return fmt.Errorf("form %q: redirect_url %q must not contain whitespace", f.Name, f.RedirectURL) } if f.Archive && f.Type == "newsletter" { return fmt.Errorf("form %q: archive does not apply to newsletter forms; they persist through the double opt-in log", f.Name) } if f.AutoReply && f.Type == "newsletter" { return fmt.Errorf("form %q: auto_reply does not apply to newsletter forms; the subscriber already receives the confirmation mail", f.Name) } if f.Telegram != nil { if f.Type == "newsletter" { return fmt.Errorf("form %q: telegram does not apply to newsletter forms; the double opt-in flow is mail-native", f.Name) } if f.Telegram.BotToken == "" { return fmt.Errorf("form %q: telegram.bot_token is required", f.Name) } if f.Telegram.ChatID == "" { return fmt.Errorf("form %q: telegram.chat_id is required", f.Name) } if f.Telegram.TimeoutSeconds != nil && *f.Telegram.TimeoutSeconds < 1 { return fmt.Errorf("form %q: telegram.timeout_seconds must be >= 1, got %d", f.Name, *f.Telegram.TimeoutSeconds) } } // An explicit 0 keeps its documented meaning: rate limiting off. if f.RateLimitPerHour != nil && *f.RateLimitPerHour < 0 { return fmt.Errorf("form %q: rate_limit_per_hour must be >= 0, got %d", f.Name, *f.RateLimitPerHour) } if f.PendingTTLSeconds != nil && *f.PendingTTLSeconds < 1 { return fmt.Errorf("form %q: pending_ttl_seconds must be >= 1, got %d", f.Name, *f.PendingTTLSeconds) } if f.SMTP.TimeoutSeconds != nil && *f.SMTP.TimeoutSeconds < 1 { return fmt.Errorf("form %q: smtp.timeout_seconds must be >= 1, got %d", f.Name, *f.SMTP.TimeoutSeconds) } for _, svc := range f.Services { if svc == "" { return fmt.Errorf("form %q: services must not contain an empty entry; the empty value is always accepted", f.Name) } if strings.TrimSpace(svc) != svc { return fmt.Errorf("form %q: services entry %q must not carry surrounding whitespace", f.Name, svc) } } if err := checkPolicyLimits(f.Name, f.Policy()); err != nil { return err } } return nil } // checkPolicyLimits rejects a form policy whose length limits are // unusable: negative minima, a maximum below one, or a window that // excludes everything. func checkPolicyLimits(formName string, p contactform.Policy) error { for _, l := range []struct { field string min int max int }{ {"name", p.MinNameRunes, p.MaxNameRunes}, {"message", p.MinMessageRunes, p.MaxMessageRunes}, } { if l.min < 0 { return fmt.Errorf("form %q: min_%s_runes must be >= 0, got %d", formName, l.field, l.min) } if l.max < 1 { return fmt.Errorf("form %q: max_%s_runes must be >= 1, got %d", formName, l.field, l.max) } if l.max < l.min { return fmt.Errorf("form %q: max_%s_runes (%d) must be greater than or equal to min_%s_runes (%d)", formName, l.field, l.max, l.field, l.min) } } return nil } // isValidName reports whether s contains only safe characters for use // in file paths and log lines. func isValidName(s string) bool { for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': default: return false } } return true } // isValidPath reports whether s is a safe HTTP route pattern: a static path // built from a leading slash plus letters, digits, hyphens, underscores, // dots and single-slash separators. Characters that carry meaning inside // net/http ServeMux patterns (braces, spaces, ...) are rejected so that a // mistyped config cannot crash route registration or silently widen a form // endpoint into a wildcard or subtree match. func isValidPath(s string) bool { if !strings.HasPrefix(s, "/") || strings.Contains(s, "//") { return false } for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '/': default: return false } } return true } // ConfigPath returns the canonical path for nuntius configuration: // the NUNTIUS_CONFIG environment variable when set, otherwise // /etc/nuntius/config.toml. The override keeps local development free of // root-only paths. func ConfigPath() string { if p := os.Getenv("NUNTIUS_CONFIG"); p != "" { return p } return "/etc/nuntius/config.toml" } // expandConfig applies expandEnv to every non-comment line of a TOML file. // Lines whose first non-blank character is '#' are documentation, never // values, so dollar signs there must survive verbatim even when they show // placeholder syntax like ${VAR_NAME}. func expandConfig(s string) (string, error) { lines := strings.Split(s, "\n") for i, ln := range lines { if strings.HasPrefix(strings.TrimLeft(ln, " \t"), "#") { continue } out, err := expandEnv(ln) if err != nil { return "", err } lines[i] = out } return strings.Join(lines, "\n"), nil } // expandEnv substitutes $VAR and ${VAR} references with their environment // values. Syntax follows os.Expand. Unlike os.Expand it fails when a // referenced variable is not set: a silently empty SMTP password is far // harder to diagnose than an explicit startup error. A dollar sign not // followed by a variable name stays literal. func expandEnv(s string) (string, error) { var b strings.Builder for i := 0; i < len(s); { c := s[i] if c != '$' { b.WriteByte(c) i++ continue } name, width := varName(s[i+1:]) if name == "" { // "$" with no name after it (or unterminated braces): keep it. b.WriteByte('$') i++ continue } value, ok := os.LookupEnv(name) if !ok { return "", fmt.Errorf("environment variable %s is referenced but not set", name) } b.WriteString(value) i += 1 + width } return b.String(), nil } // varName parses the variable name that follows a dollar sign, mirroring // os.Expand rules: either "${NAME}" or a bare run of ASCII letters, digits // and underscores. The second result counts the bytes consumed after the // dollar sign; zero width means there was no name at all. func varName(s string) (string, int) { if len(s) > 0 && s[0] == '{' { end := strings.IndexByte(s, '}') if end < 0 { return "", 0 } return s[1:end], end + 1 } n := 0 for n < len(s) { c := s[n] if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') { break } n++ } return s[:n], n } // 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. Referencing a # variable that is not set aborts startup with an explicit error. # # Every key below is optional: omitting one keeps its default. The commented # lines document the keys most deployments never need to touch. data_dir = "./data" [server] port = 8080 # Host part of the listen address; the default "::" binds the dual-stack # wildcard, so port 8080 answers on IPv4 and IPv6 alike. #bind = "::" # HTTP timeouts in seconds; 0 switches a timeout off. Defaults: 10, 15, 30, 60. #read_header_timeout_seconds = 10 #read_timeout_seconds = 15 #write_timeout_seconds = 30 #idle_timeout_seconds = 60 # Graceful shutdown budget in seconds (default 15). #shutdown_timeout_seconds = 15 # Request body cap in bytes (default 1048576, i.e. 1 MiB). #max_body_bytes = 1048576 # Rate limiter mechanics: per-form cap on distinct IP buckets, cleanup tick # and the age at which an idle bucket is dropped (defaults 32768, 3600, 7200). #rate_limit_max_buckets = 32768 #rate_limit_cleanup_seconds = 3600 #rate_limit_max_bucket_age_seconds = 7200 # Bearer token guarding GET /metrics; empty keeps the endpoint open. #metrics_token = "${NUNTIUS_METRICS_TOKEN}" [[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"] # Plain HTML form mode: an accepted submission answers 303 See Other with # this location, so the form works without JavaScript. Omitted (the # default), accepted submissions answer JSON. #redirect_url = "https://example.com/thanks" # Archive accepted submissions to data_dir/archive-.jsonl before the # mail goes out, so a failed SMTP round-trip loses nothing. Newsletter # forms always persist through the double opt-in log instead. #archive = true # Send the submitter a short automated receipt. Newsletter forms reject # the key; their subscribers already receive the confirmation mail. #auto_reply = true # Telegram notification channel: the summary lands in the chat alongside # the mail, and the submission counts as delivered when either gets # through. Newsletter forms reject the key. #[forms.telegram] #bot_token = "${NUNTIUS_TELEGRAM_TOKEN}" #chat_id = "123456789" #timeout_seconds = 10 # Allow-list for the optional "service" payload field. The empty value is # always accepted; the "*" entry accepts any value; omitting the key keeps # the built-in list below; an explicit empty list accepts only the empty # value. The same key works on any form type. services = ["architecture", "ai", "infrastructure", "software", "unix", "other"] # Validation overrides on top of the type preset; the values shown are the # defaults. The email address is always required and never length-limited. #require_name = true #require_message = true #min_name_runes = 2 #max_name_runes = 100 #min_message_runes = 10 #max_message_runes = 5000 # Subject prefix, rendered as "[nuntius/contact]" in every mail subject; an # empty string drops the bracket segment. Default "nuntius". #subject_prefix = "nuntius" # Footer brand, rendered as "Delivered by nuntius"; an empty string drops # the footer. Default "nuntius". #email_brand = "nuntius" [forms.smtp] host = "smtp.example.com" port = 587 user = "contact@example.com" password = "${NUNTIUS_SMTP_PASSWORD}" # Whole SMTP conversation bound in seconds (default 20). #timeout_seconds = 20 [[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"] # Double opt-in lifetime in seconds (default 259200, i.e. 72 hours). #pending_ttl_seconds = 259200 [forms.smtp] host = "smtp.example.com" port = 587 user = "contact@example.com" password = "${NUNTIUS_SMTP_PASSWORD}" `)