2026-09-29 00:32:56 +02:00
|
|
|
# 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).
|
|
|
|
|
|
2026-09-29 00:52:01 +02:00
|
|
|
## [development]
|
|
|
|
|
|
|
|
|
|
### Added
|
|
|
|
|
|
|
|
|
|
-
|
|
|
|
|
|
2026-09-29 00:32:56 +02:00
|
|
|
## [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 <form path>/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-<name>.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.
|