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
Assisted-by: GLM 5.3 Flash
851 lines
30 KiB
Go
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}"
|
|
`)
|