Files
nuntius/internal/config/config.go
T
petrbalvin 3a38f00dc0
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

851 lines
30 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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-<name>.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/<name>]" segment of every mail
// subject; an explicit empty string drops the segment.
SubjectPrefix *string `toml:"subject_prefix"`
// EmailBrand carries the "Delivered by <brand>" 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 "[<prefix>/<form name>]" 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 <brand>" 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-<name>.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}"
`)