# Nuntius A small contact form backend for Linux and FreeBSD servers, made to run behind [Caddy](https://caddyserver.com/) on a modest server: Caddy ends the TLS and serves the form from the same origin, nuntius carries the messages. Latin *nuntius* means "messenger": the service carries messages from web visitors to your inbox. It serves multiple JSON form endpoints (contact, feedback, newsletter, generic) from a single static binary, delivers submissions via SMTP, and optionally persists newsletter signups to an append-only JSONL log. ## Features - **Single static binary**: roughly 6 MB stripped; no Node, no Python, no Redis, no Postgres, and no third-party Go module beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres) TOML parser. - **Four form kinds**: `contact`, `feedback`, `newsletter`, and `generic`. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field. - **No-JavaScript forms**: endpoints also accept plain `urlencoded` and `multipart` posts and can answer `303 See Other` to a thank-you page, so an ordinary HTML `
` works with no script at all. - **SMTP delivery**: Go stdlib `net/smtp` with `PLAIN` auth over STARTTLS (upgraded automatically when the server advertises it, credentials never sent in plaintext), implicit TLS on port `465`, and an optional `require_tls` policy that aborts delivery against servers without TLS. - **Newsletter double opt-in**: a signup stays pending until the subscriber clicks the confirmation link mailed to them; only confirmed addresses land in the JSONL log, unconfirmed ones expire, and repeat signups are silently skipped. - **Optional submission archive**: a per-form switch logs every accepted submission to an append-only JSONL file before the mail goes out, so a failed SMTP round-trip loses nothing. - **Optional submitter receipt**: `auto_reply` mails the sender a short automated acknowledgement with the owner's address as `Reply-To`. - **Telegram notifications**: a per-form `[forms.telegram]` channel posts the summary into your chat beside the mail; the submission counts as delivered when either channel gets through, so an SMTP outage does not silence the bell. - **Server-side validation**: every field is checked before anything is delivered: name, email (RFC 5322), an allow-listed optional `service` field, and message length. Every limit is a configuration key, and the server builds each form's policy from `config.toml`. - **Token-bucket rate limiting**: per IP, per form, configurable submissions per hour, capped in memory, persisted across graceful restarts. - **Honeypot field**: invisible to humans, required by bots. Silently accepts and drops spam submissions. - **CORS allowlists**: per form, explicit origins only; disallowed origins receive `403 Forbidden`. - **Bounded requests**: every timeout, the body cap and the graceful shutdown deadline are configuration keys; oversized bodies get `413`. - **Operational endpoints**: `GET /health` and `GET /metrics` with lifetime counters per form, optionally guarded by a bearer token. - **Structured logging**: `log/slog` with JSON output to stdout; credentials are never logged. - **TOML configuration**: single file, parsed by the first-party `interpres` library. Unknown fields and unknown form types are rejected at startup so typos fail loudly. - **Environment variable expansion**: `${VAR_NAME}` and `$VAR_NAME` in the TOML file keep SMTP credentials out of version control. - **IPv6-first**: binds to `[::]` by default, with automatic IPv4 compatibility via dual-stack sockets. - **Cross-platform**: pre-built binaries for Linux (amd64, arm64, loong64, riscv64) and FreeBSD (amd64, arm64) on every release. The FreeBSD binaries ship as cross compiles and are runtime untested. ## Install Prebuilt binaries for Linux and FreeBSD are on the [releases page](https://sourcedock.dev/petrbalvin/nuntius/releases). From source: ```sh git clone https://sourcedock.dev/petrbalvin/nuntius.git cd nuntius just build # produces bin/nuntius ``` ## Quick start ```sh # 1. Point the server at a writable config path and satisfy the secret # referenced by the starter template. export NUNTIUS_CONFIG=./config.toml export NUNTIUS_SMTP_PASSWORD=admin # 2. Build and run; the first start writes a three-form starter config. just build just run # listens on [::]:8080 # 3. Verify. curl http://127.0.0.1:8080/health ``` Edit the generated `./config.toml` with real SMTP credentials and recipient addresses before exposing the service. In production the default location is `/etc/nuntius/config.toml`; see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for the full setup. The full schema lives in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). ## Usage Run the server: ```sh export NUNTIUS_CONFIG=./config.toml # omitted: /etc/nuntius/config.toml bin/nuntius # serves until SIGINT or SIGTERM ``` Validate a configuration without listening, as a systemd `ExecStartPre` would: ```sh NUNTIUS_CONFIG=./config.toml bin/nuntius --check-config ``` Post a submission: ```sh curl -X POST http://127.0.0.1:8080/api/nuntius/contact \ -H "Content-Type: application/json" \ -H "Origin: https://example.com" \ -d '{"name":"Jane Doe","email":"jane@example.com","message":"Hello"}' # → 200 {"ok": true}; the mail lands in the form's recipient address ``` A plain HTML form posts the same fields urlencoded and, with `redirect_url` set, receives `303 See Other` to its thank-you page. The complete endpoint reference is [`docs/API.md`](docs/API.md); the flags and exit codes are in [`docs/CLI.md`](docs/CLI.md). Nuntius speaks plain HTTP by design: put Caddy in front of it for TLS, a same-origin form endpoint and client IPs you can trust. Two lines in the Caddyfile are enough: ```caddyfile handle_path /api/nuntius/* { reverse_proxy 127.0.0.1:8080 } ``` Caddy overwrites `X-Forwarded-For` for untrusted clients, so with `server.trust_proxy_headers = true` the rate limiter and the logs see the real visitor. The full production setup, including the systemd unit, is in [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). ## Development ```sh just build # build just test # the test suite with the coverage floor just fmt # format ``` See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute. ## Documentation - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow - [docs/API.md](docs/API.md): the API reference - [docs/CONFIGURATION.md](docs/CONFIGURATION.md): every configuration key - [docs/CLI.md](docs/CLI.md): flags, exit codes, and the manual page - [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): production setup, Caddy, updates - [SECURITY.md](SECURITY.md): how to report a vulnerability ## Licence MIT, see [LICENSE](LICENSE). Copyright © 2026 [Petr Balvín](https://petrbalvin.org)