# Changelog All notable changes to **nuntius** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.0] - 2026-09-29 ### Added - **A contact form backend in one static binary.** nuntius serves JSON form endpoints, delivers the submissions by SMTP and writes structured JSON logs. 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. - **Plain HTML form posts, no JavaScript required.** Every endpoint accepts `application/x-www-form-urlencoded` and `multipart/form-data` under the fixed field names `name`, `email`, `service` and `message` beside the JSON contract, and a form with `redirect_url` set answers every accepted submission, honeypot hits included, with `303 See Other` to that page. Failures stay machine-readable JSON. Body-parse rejections report `invalid_body` across every content type. - **Four form types**: `contact`, `feedback`, `newsletter` and `generic`. Each form has its own endpoint, rate limit, CORS allowlist, honeypot field and SMTP credentials, so one process serves many independent forms. - **Declarative validation policy.** A form's rules are configuration: `services` puts an allow-list on the optional `service` payload field of any form type (the empty value always passes, the `*` entry accepts any value, an explicit empty list accepts only the empty value), `require_name` and `require_message` move the free-text fields in and out of the checks, and `min_name_runes`, `max_name_runes`, `min_message_runes`, `max_message_runes` size them. A new form shape is a matter of `config.toml`, never of Go code. - **Newsletter double opt-in.** A signup stays pending and the subscriber receives a confirmation mail with a single-use link (`GET
/confirm?token=...`); only a redeemed link writes the address into the JSONL log and notifies the owner. Unconfirmed entries expire after `pending_ttl_seconds` (72 hours by default), tokens are 256-bit random values stored as SHA-256 hashes, and replaying a used link returns `410 Gone`. A repeat signup of an already recorded address gets the same `200 ok` response with no second mail and no duplicate record. - **Abuse defence without CAPTCHA.** A per-IP token bucket bounds each form (capped at `server.rate_limit_max_buckets` distinct IPs, unknown IPs denied while full), a configurable honeypot field silently drops bot submissions, per-form CORS closes cross-site posts, and proxy headers are trusted only with the explicit `server.trust_proxy_headers` opt-in, so a direct caller cannot rotate identities to dodge the per-IP limit. - **Rate-limit state survives graceful restarts.** On shutdown the per-IP buckets are snapshotted to `data_dir/ratelimit-snapshot.json` (atomic write, mode `0600`) and restored at startup; stale entries are dropped and a missing or corrupt file behaves like an empty one. - **Optional submission archive.** A form with `archive = true` writes every accepted submission to `data_dir/archive-.jsonl` before the mail is attempted, so a failed SMTP round-trip loses nothing; a failed append blocks the send, so a retry cannot split the mail from its record. Newsletter forms reject the key because they persist through the double opt-in log already. - **Optional automated receipt to the submitter.** A form with `auto_reply = true` mails the submitter a short confirmation with the owner's address as `Reply-To`; the receipt is best effort and its failure never turns an accepted submission into an error. Newsletter forms reject the key because their subscribers already receive the confirmation mail. - **Telegram notifications.** A per-form `[forms.telegram]` channel (`bot_token`, `chat_id`, optional `timeout_seconds`) posts the submission summary into the owner's chat beside the mail. The submission counts as delivered when either channel gets through: an SMTP outage does not silence the bell, and only when both fail does the caller see `send_failed`. The notification is one-way; newsletter forms reject the key because their double opt-in flow is mail-native. - **Operational surface.** `GET /health` reports the listener and the form count, `GET /metrics` reports lifetime counters per form and in total (optionally guarded by a `server.metrics_token` bearer credential), `--check-config` validates the configuration file and exits nonzero on any error (so a systemd `ExecStartPre` can refuse to start on a broken config), and the `NUNTIUS_CONFIG` environment variable moves the configuration file out of `/etc/nuntius/` for development. - **Every operational limit is a configuration key.** `server.bind`, `server.read_header_timeout_seconds`, `server.read_timeout_seconds`, `server.write_timeout_seconds`, `server.idle_timeout_seconds`, `server.shutdown_timeout_seconds`, `server.max_body_bytes`, `server.rate_limit_max_buckets`, `server.rate_limit_cleanup_seconds`, `server.rate_limit_max_bucket_age_seconds`, and per form `pending_ttl_seconds` plus `smtp.timeout_seconds`. Every key is optional with its default documented in `docs/CONFIGURATION.md`. - **SMTP delivery with a TLS policy.** PLAIN auth over STARTTLS, implicit TLS on port `465` (encrypted from the first byte), the `smtp.require_tls` switch that aborts delivery when a STARTTLS port never upgrades, and a configurable conversation timeout. Secrets stay out of `config.toml` through `${VAR}` expansion, and an undefined variable aborts startup instead of surfacing later as a failed SMTP auth. - **Messages are hardened at composition.** CR/LF sequences reaching `From`, `To`, `Subject` or `Reply-To` are replaced with spaces inside the SMTP builder itself, and the MIME boundary is random per message with a failure aborting the send rather than falling back to a fixed boundary. - **Configuration fails loudly at startup.** Unknown TOML fields and unknown form types are rejected, ports and limits are range-checked, form `path` values are validated as static route patterns so a mistyped path cannot widen into a wildcard, and form names are restricted to `[a-zA-Z0-9_-]` so a crafted name cannot escape the data directory. - **The release identity comes from the toolchain.** `--version` prints the tag the binary was built at, a pseudo-version naming the commit otherwise, and `(devel)` outside version control, with `+dirty` appended on a dirty tree. Nothing is injected at build time. - **Binaries for Linux and FreeBSD.** Every release ships `linux/amd64`, `linux/arm64`, `linux/loong64` and `linux/riscv64`, plus `freebsd/amd64` and `freebsd/arm64` as cross compiles; the FreeBSD binaries are runtime untested.