# 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: ```toml 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-.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 `[/]` segment of every mail subject. Empty string drops the segment. | | `forms[].email_brand` | string | `"nuntius"` | The `Delivered by ` 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/] Contact form submission`, or `[nuntius/][] ...` 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/] 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/] New newsletter subscriber` | Double opt-in: the address waits in `data_dir/newsletter--pending.json` until the emailed link (`GET
/confirm?token=...`) is redeemed. | | `generic` | `name`, `email`, `message` | `[nuntius/] 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-.jsonl`: ```json {"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`](API.md) for the response shapes.