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
Assisted-by: GLM 5.3 Flash
6.8 KiB
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
interpresTOML parser. - Plain HTML form posts, no JavaScript required. Every endpoint accepts
application/x-www-form-urlencodedandmultipart/form-dataunder the fixed field namesname,email,serviceandmessagebeside the JSON contract, and a form withredirect_urlset answers every accepted submission, honeypot hits included, with303 See Otherto that page. Failures stay machine-readable JSON. Body-parse rejections reportinvalid_bodyacross every content type. - Four form types:
contact,feedback,newsletterandgeneric. 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:
servicesputs an allow-list on the optionalservicepayload 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_nameandrequire_messagemove the free-text fields in and out of the checks, andmin_name_runes,max_name_runes,min_message_runes,max_message_runessize them. A new form shape is a matter ofconfig.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 afterpending_ttl_seconds(72 hours by default), tokens are 256-bit random values stored as SHA-256 hashes, and replaying a used link returns410 Gone. A repeat signup of an already recorded address gets the same200 okresponse 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_bucketsdistinct 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 explicitserver.trust_proxy_headersopt-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, mode0600) 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 = truewrites every accepted submission todata_dir/archive-<name>.jsonlbefore 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 = truemails the submitter a short confirmation with the owner's address asReply-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, optionaltimeout_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 seesend_failed. The notification is one-way; newsletter forms reject the key because their double opt-in flow is mail-native. - Operational surface.
GET /healthreports the listener and the form count,GET /metricsreports lifetime counters per form and in total (optionally guarded by aserver.metrics_tokenbearer credential),--check-configvalidates the configuration file and exits nonzero on any error (so a systemdExecStartPrecan refuse to start on a broken config), and theNUNTIUS_CONFIGenvironment 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 formpending_ttl_secondsplussmtp.timeout_seconds. Every key is optional with its default documented indocs/CONFIGURATION.md. - SMTP delivery with a TLS policy. PLAIN auth over STARTTLS, implicit TLS
on port
465(encrypted from the first byte), thesmtp.require_tlsswitch that aborts delivery when a STARTTLS port never upgrades, and a configurable conversation timeout. Secrets stay out ofconfig.tomlthrough${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,SubjectorReply-Toare 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
pathvalues 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.
--versionprints the tag the binary was built at, a pseudo-version naming the commit otherwise, and(devel)outside version control, with+dirtyappended on a dirty tree. Nothing is injected at build time. - Binaries for Linux and FreeBSD. Every release ships
linux/amd64,linux/arm64,linux/loong64andlinux/riscv64, plusfreebsd/amd64andfreebsd/arm64as cross compiles; the FreeBSD binaries are runtime untested.