All runtime configuration for nuntius lives in a single TOML file. The default path is `/etc/nuntius/config.toml`; if the file does not exist when the server starts, nuntius writes a three-form template there. The parser rejects unknown fields and unknown form types so typos fail loudly at startup instead of silently disabling a form.
## Quick Reference
```toml
data_dir="./data"
[server]
port=8080
[[forms]]
name="contact"
type="contact"
path="/api/nuntius/contact"
to="you@example.com"
from="noreply@example.com"
rate_limit_per_hour=10
honeypot_field="website"
allowed_origins=["https://example.com"]
[forms.smtp]
host="smtp.example.com"
port=587
user="noreply@example.com"
password="${NUNTIUS_SMTP_PASSWORD}"
```
## Top-Level Fields
| Field | Type | Required | Default | Description |
| `server.port` | int | | `8080` | HTTP listen port |
| `data_dir` | string | | `"./data"` | Directory for newsletter JSONL logs; created on first write |
| `forms` | array | ✅ | — | One or more form definitions (must contain at least one) |
> **Note:** Path resolution for `data_dir` is relative to the process's current working directory. The systemd unit installed in `docs/deployment.md` sets `WorkingDirectory=/var/lib/nuntius`, so the default `data_dir: "./data"` resolves to `/var/lib/nuntius/data/` in production.
## Form Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `name` | string | ✅ | — | Internal identifier. Appears in logs and in `data_dir/newsletter-<name>.jsonl`. |
| `path` | string | ✅ | — | HTTP path the form is served on (e.g. `/api/nuntius/contact`). Must start with `/`. Must be unique across all forms. |
| `type` | string | | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`. See [Form types](#form-types). |
| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/<name>] Contact form submission`, or `[nuntius/<name>][<service>] ...` if `service` is set | The default. `service` is restricted to an allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`. |
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. |
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | Also writes the email to disk as a JSONL line in `data_dir/newsletter-<name>.jsonl`. No double opt-in. |
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | Escape hatch for one-off forms that do not fit the other three. |
All four types are rate-limited, CORS-checked, and honeypot-protected **independently** — each form has its own per-IP bucket, allowlist, and honeypot field. `newsletter` is the only type that writes to disk; the other three only send mail.
### Service allow-list (for `contact`)
The `service` field on `contact` is restricted to a fixed list (defined in `pkg/contactform/validate.go`):
An empty string is also valid (skip the field). Anything else triggers a `400 validation` error with `details[0].field = "service"`. To add a new value, edit `validServices` in `pkg/contactform/validate.go` and update this table.
## Validation Rules
The `pkg/contactform.Validate` function enforces the following limits (in runes, not bytes):
| `email` | Trimmed, lowercased-on-output by your downstream tool — nuntius does not normalize |
| `ip` | Client IP as seen by nuntius (honours `X-Forwarded-For` / `X-Real-IP`) |
| `form` | The `name` of the form the signup hit |
| `created_at` | UTC timestamp set by nuntius at `Append` time |
The log is **append-only** and **survives restarts**. It is **not rotated automatically** — add a `logrotate` unit (or equivalent) if it grows. Malformed lines are skipped on read, so a partial write can never brick the file.
The TOML file supports `${VAR_NAME}` and `$VAR_NAME` expansion **before parsing**, so secrets can stay out of the file. Example:
```toml
[forms.smtp]
host="smtp.example.com"
port=587
user="noreply@example.com"
password="${NUNTIUS_SMTP_PASSWORD}"
```
The `NUNTIUS_SMTP_PASSWORD` env var is read at startup and substituted into the TOML before it is parsed. If the env var is unset or empty, an empty string is substituted — so make sure the variable is set in the process environment (systemd `EnvironmentFile=`, container secrets, etc.). SMTP credentials are never logged.
Setting `rate_limit_per_hour` to `0` explicitly disables rate limiting for that form (use with care — there is no global safety net).
## Validation Errors at Startup
If the config file is malformed, nuntius exits 1 and writes a single JSON line to stderr. Examples:
```text
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"parse /etc/nuntius/config.toml: interpres: unknown field \"smtp_ost\""}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": type \"contatc\" is not supported (must be one of: contact, feedback, newsletter, generic)"}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": path must start with /"}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"feedback\": duplicate path \"/api/nuntius/contact\" (also used by \"contact\")"}
```
Fix the reported line and re-run; the server will not start with a broken config.