215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
# 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.
|