Files

215 lines
12 KiB
Markdown
Raw Permalink Normal View History

# 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-<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`:
```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.