Assisted-by: GLM 5.3 Flash
Nuntius
A small contact form backend for Linux and FreeBSD servers, made to run behind Caddy 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
interpresTOML parser. - Four form kinds:
contact,feedback,newsletter, andgeneric. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field. - No-JavaScript forms: endpoints also accept plain
urlencodedandmultipartposts and can answer303 See Otherto a thank-you page, so an ordinary HTML<form>works with no script at all. - SMTP delivery: Go stdlib
net/smtpwithPLAINauth over STARTTLS (upgraded automatically when the server advertises it, credentials never sent in plaintext), implicit TLS on port465, and an optionalrequire_tlspolicy 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_replymails the sender a short automated acknowledgement with the owner's address asReply-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
servicefield, and message length. Every limit is a configuration key, and the server builds each form's policy fromconfig.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 /healthandGET /metricswith lifetime counters per form, optionally guarded by a bearer token. - Structured logging:
log/slogwith JSON output to stdout; credentials are never logged. - TOML configuration: single file, parsed by the first-party
interpreslibrary. Unknown fields and unknown form types are rejected at startup so typos fail loudly. - Environment variable expansion:
${VAR_NAME}and$VAR_NAMEin 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. From source:
git clone https://sourcedock.dev/petrbalvin/nuntius.git
cd nuntius
just build # produces bin/nuntius
Quick start
# 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
for the full setup. The full schema lives in
docs/CONFIGURATION.md.
Usage
Run the server:
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:
NUNTIUS_CONFIG=./config.toml bin/nuntius --check-config
Post a submission:
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; the flags and exit codes are in
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:
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.
Development
just build # build
just test # the test suite with the coverage floor
just fmt # format
See docs/DEVELOPMENT.md for the full workflow, and CONTRIBUTING.md for how to contribute.
Documentation
- docs/ARCHITECTURE.md: components and data flow
- docs/API.md: the API reference
- docs/CONFIGURATION.md: every configuration key
- docs/CLI.md: flags, exit codes, and the manual page
- docs/DEPLOYMENT.md: production setup, Caddy, updates
- SECURITY.md: how to report a vulnerability
Licence
MIT, see LICENSE.
Copyright © 2026 Petr Balvín