Files
nuntius/CHANGELOG.md
T
petrbalvin 3a38f00dc0
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

6.8 KiB

Changelog

All notable changes to nuntius are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[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 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.