Files
nuntius/docs/CONFIGURATION.md
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

12 KiB

Configuration

nuntius reads its configuration from one TOML file, /etc/nuntius/config.toml by default. The NUNTIUS_CONFIG environment variable points the server at any other path. When the file does not exist, the first start writes a three-form starter template there.

File

A complete example with every key present at its default value, so the file documents itself:

data_dir = "./data"

[server]
port = 8080
bind = "::"
read_header_timeout_seconds = 10
read_timeout_seconds = 15
write_timeout_seconds = 30
idle_timeout_seconds = 60
shutdown_timeout_seconds = 15
max_body_bytes = 1048576
rate_limit_max_buckets = 32768
rate_limit_cleanup_seconds = 3600
rate_limit_max_bucket_age_seconds = 7200
trust_proxy_headers = false
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"

[[forms]]
name = "contact"
path = "/api/nuntius/contact"
type = "contact"
redirect_url = ""
archive = false
auto_reply = false
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
require_name = true
require_message = true
min_name_runes = 2
max_name_runes = 100
min_message_runes = 10
max_message_runes = 5000
subject_prefix = "nuntius"
email_brand = "nuntius"

  [forms.smtp]
  host = "smtp.example.com"
  port = 587
  user = "noreply@example.com"
  password = "${NUNTIUS_SMTP_PASSWORD}"
  require_tls = false
  timeout_seconds = 20

  [forms.telegram]
  bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
  chat_id = "123456789"
  timeout_seconds = 10

[[forms]]
name = "newsletter"
path = "/api/nuntius/newsletter"
type = "newsletter"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
pending_ttl_seconds = 259200

  [forms.smtp]
  host = "smtp.example.com"
  port = 587
  user = "noreply@example.com"
  password = "${NUNTIUS_SMTP_PASSWORD}"

The newsletter form shows the keys that apply to it; archive, auto_reply and telegram do not apply to newsletter forms and are rejected at startup.

Keys

Key Type Default Effect
data_dir string "./data" Directory for the JSONL logs; created on first write. Relative to the working directory.
server.port int 8080 HTTP listen port. Must be 1 to 65535.
server.bind string "::" Host part of the listen address; "::" is the dual-stack wildcard, an address binds that one only. Must not contain whitespace.
server.read_header_timeout_seconds int 10 Budget for reading the request headers (Slowloris defence). 0 switches it off.
server.read_timeout_seconds int 15 Budget for reading the request body. 0 switches it off.
server.write_timeout_seconds int 30 Budget for writing the response. 0 switches it off.
server.idle_timeout_seconds int 60 Keep-alive idle budget. 0 switches it off.
server.shutdown_timeout_seconds int 15 Graceful shutdown budget before in-flight connections are cut.
server.max_body_bytes int 1048576 Request body cap in bytes; oversized bodies get 413. Must be at least 1.
server.rate_limit_max_buckets int 32768 Per-form cap on distinct IP buckets; once full, unknown IPs are denied until cleanup frees stale entries. Must be at least 1.
server.rate_limit_cleanup_seconds int 3600 Interval between bucket cleanup sweeps. Must be at least 1.
server.rate_limit_max_bucket_age_seconds int 7200 Age at which an idle bucket is dropped. Must be at least 1.
server.trust_proxy_headers bool false Read client IPs from X-Forwarded-For / X-Real-IP instead of the connection peer address. Enable only behind a proxy that overwrites these headers (Caddy 2.5+ does by default).
server.metrics_token string none Bearer token guarding GET /metrics. Empty keeps the endpoint open. Supports ${VAR} expansion.
forms[].name string yes Internal identifier; appears in logs and file names. Letters, digits, hyphens and underscores only.
forms[].path string yes HTTP path the form is served on. Must start with /, be unique, and be a static pattern with no empty segments.
forms[].type string "contact" One of contact, feedback, newsletter, generic; selects the behaviour bundle, the mail template and the default validation policy.
forms[].smtp table yes SMTP connection settings: host, port, user, password, require_tls, timeout_seconds (default 20). Port 465 speaks implicit TLS; 587 upgrades via STARTTLS, which require_tls = true makes mandatory.
forms[].to string yes Recipient address (To: header).
forms[].from string smtp.user Sender address (From: header).
forms[].redirect_url string none Turns the form into a plain HTML form target: accepted submissions answer 303 See Other with this location. Failures stay JSON. Must not contain whitespace.
forms[].archive bool false Persist every accepted submission to data_dir/archive-<name>.jsonl before the mail is attempted. Not valid on newsletter forms.
forms[].auto_reply bool false Mail the submitter a short automated receipt with Reply-To set to to. Best effort; failures land in the auto_reply_failed counter. Not valid on newsletter forms.
forms[].telegram table none Telegram notification channel: bot_token (supports ${VAR}), chat_id (numeric id or @channelusername), timeout_seconds (default 10). Not valid on newsletter forms.
forms[].rate_limit_per_hour int 10 Submissions per IP per hour. 0 disables.
forms[].honeypot_field string "website" Name of the invisible form field. Empty string disables the honeypot.
forms[].allowed_origins []string [] CORS allowlist, one origin per entry; a same-origin HTML form lists its own origin too.
forms[].services []string type default Allow-list for the optional service payload field, on any form type.
forms[].require_name bool type default Switch the name field into the validation.
forms[].require_message bool type default Switch the message field into the validation.
forms[].min_name_runes int 2 Minimum name length in runes; 0 or more.
forms[].max_name_runes int 100 Maximum name length in runes; at least min_name_runes and 1 or more.
forms[].min_message_runes int 10 Minimum message length in runes; 0 or more.
forms[].max_message_runes int 5000 Maximum message length in runes; at least min_message_runes and 1 or more.
forms[].pending_ttl_seconds int 259200 Double opt-in lifetime for newsletter forms (72 hours). Must be at least 1.
forms[].subject_prefix string "nuntius" The [<prefix>/<name>] segment of every mail subject. Empty string drops the segment.
forms[].email_brand string "nuntius" The Delivered by <brand> footer in every mail. Empty string drops the footer.

The email address is always required and always checked against RFC 5322: every form delivers mail and needs a reply-to address, so there is no key to switch that check off.

Form types

The four types are behaviour bundles. Each bundles a mail template, a subject text, and a default validation policy; every part of that policy can be overridden per form with the keys above, so a form whose needs differ is a configuration matter, never a code change.

Type Default required fields Email subject Notes
contact name, email, message, optional service [nuntius/<name>] Contact form submission, or [nuntius/<name>][<service>] ... if service is set The default. service is checked against the allow-list: the built-in list by default, or the form's own services key.
feedback name, email, message [nuntius/<name>] New feedback Same shape as contact minus the service field. Distinct HTML template. A services key opts the field into the validation and the mail.
newsletter email [nuntius/<name>] New newsletter subscriber Double opt-in: the address waits in data_dir/newsletter-<name>-pending.json until the emailed link (GET <form path>/confirm?token=...) is redeemed.
generic name, email, message [nuntius/<name>] New submission Escape hatch for one-off forms; pair it with the per-form policy keys to shape it freely.

The service allow-list

The service payload field is optional everywhere and the empty value always passes, so a frontend that never sends it needs no configuration at all. When a value is sent, it is checked against the form's allow-list:

Configuration Accepted service values
key omitted contact: the built-in list below. Other types: the field is not validated at all.
services = ["consulting", "support"] only the listed values (plus the empty value), on any form type
services = ["*"] any value
services = [] only the empty value

The built-in list for contact: architecture, ai, infrastructure, software, unix, other. Anything else triggers a 400 validation error. Entries must not be empty and must not carry surrounding whitespace; such a config is rejected at startup.

The newsletter log

A confirmed signup is one JSON line in data_dir/newsletter-<name>.jsonl:

{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}

email is the trimmed address, ip the client IP as seen by nuntius, and created_at a UTC timestamp set at append time. The log is append-only, survives restarts, and is never rotated automatically: add a logrotate unit if it grows. Malformed lines are skipped on read, so a partial write can never brick the file.

Precedence

The configuration is one file plus the environment, in this order:

  1. NUNTIUS_CONFIG chooses the file; without it the path is /etc/nuntius/config.toml.
  2. ${VAR_NAME} and $VAR_NAME references in the raw text expand from the process environment before parsing. Expansion is strict: a referenced variable that is not set aborts startup with an error naming it, while a set-but-empty value expands to the empty string and a dollar sign with no variable name stays literal. Comment lines are never expanded.
  3. Every key omitted from the file falls back to the default in the table above; an explicit value, including a disabling zero, always wins.

Secrets belong in the environment, not the file: reference them as ${NUNTIUS_SMTP_PASSWORD} and provide them through the systemd EnvironmentFile= (/etc/nuntius/.env, mode 0600, which scripts/install.pl sets up). Credentials are never logged.

Validation

A wrong value stops the program at startup with a message naming the key; nothing is silently ignored and nothing falls back silently. The loader rejects unknown TOML fields and unknown form types, ports outside 1 to 65535, negative timeouts and limits, form paths that are not static route patterns, form names with characters outside [a-zA-Z0-9_-], policy windows where the maximum sits below the minimum, service entries that are empty or carry surrounding whitespace, redirect_url with whitespace, archive, auto_reply or telegram on newsletter forms, and a Telegram channel without bot_token or chat_id. At request time the payload is validated against the form's policy: limits are counted in runes after whitespace is trimmed, the email address must parse as RFC 5322, and the error messages quote the configured numbers. See API.md for the response shapes.