# Nuntius — A Small, Opinionated Contact Form Backend [![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE) [![Go](https://img.shields.io/badge/Go-00ADD8?labelColor=00ADD8&logo=go&logoColor=fff)](https://go.dev) [![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=000)](https://kernel.org) [![FreeBSD](https://img.shields.io/badge/FreeBSD-AB2B28?logo=freebsd&logoColor=fff)](https://freebsd.org) > Latin *nuntius* = "messenger" — the service that carries messages from visitors to you. **nuntius** is a small, modular contact form backend written in Go. It serves multiple JSON endpoints (contact, feedback, newsletter, generic) over a single static binary, delivers submissions via SMTP, optionally persists newsletter signups to a JSONL log, and is designed to sit behind nginx on a tiny Linux server. Only the first-party `interpres` TOML module, no external third-party Go modules, ~6 MB binary. ## Features - **Single static binary** — `go build -ldflags="-s -w"` produces ≈ 6 MB; no Node, no Python, no Redis, no Postgres, no third-party Go packages. - **Four form kinds out of the box** — `contact`, `feedback`, `newsletter`, and `generic`. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field. - **SMTP delivery** — Go stdlib `net/smtp` with `PLAIN` auth. Works with any provider that exposes SMTP. - **Newsletter subscribers on disk** — append-only JSONL log under `data_dir/`. No database, no double opt-in. One `{email, ip, form, created_at}` line per signup. - **Server-side validation** — `pkg/contactform.Validate` is reusable; name, email (RFC 5322), service allow-list (for `contact`), and message length. - **Reusable package** — `pkg/contactform` exports typed `Request` / `Response` / `ErrorResponse` plus a `Validate` function you can drop into any Go project. - **Structured logging** — `log/slog` with JSON output to stdout; plays well with journald, Loki, Datadog… - **Graceful shutdown** — SIGINT/SIGTERM close in-flight requests within 15 s. - **Rate limiting** — token-bucket per IP, configurable per hour, in-memory (no Redis required). - **Honeypot field** — invisible to humans, required for spam bots. Silently accepts and drops. - **Config validation** — unknown fields and unknown form types are rejected at startup, so typos fail loudly instead of silently disabling a form. - **Env-var secrets** — `${VAR_NAME}` / `$VAR_NAME` expansion in the TOML file keeps SMTP passwords out of disk. ## Quick Start ```bash # 1. Clone git clone https://codeberg.org/petrbalvin/nuntius.git cd nuntius # 2. Build just build # → ./bin/nuntius # 3. Run (auto-creates /etc/nuntius/config.toml with a 3-form template on first start) sudo mkdir -p /etc/nuntius just run # → "nuntius starting" addr=:8080 forms=3 # 4. Edit the generated config to point at your SMTP and recipient sudo $EDITOR /etc/nuntius/config.toml just run # restart to pick up changes ``` A default `config.toml` is auto-generated at `/etc/nuntius/config.toml` on first run with three sample forms (contact, feedback, newsletter). Edit it before exposing the service. The full schema lives in [`docs/configuration.md`](docs/configuration.md); the deploy guide is in [`docs/deployment.md`](docs/deployment.md). ## Usage ### Test the endpoints ```bash # Contact form curl -X POST http://localhost:8080/api/nuntius/contact \ -H "Content-Type: application/json" \ -H "Origin: https://petrbalvin.org" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "service": "architecture", "message": "Hello, I would like to discuss an engagement." }' # Feedback (no service field) curl -X POST http://localhost:8080/api/nuntius/feedback \ -H "Content-Type: application/json" \ -H "Origin: https://petrbalvin.org" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "message": "I love how the site uses Vue.js with zero runtime deps." }' # Newsletter (email only) curl -X POST http://localhost:8080/api/nuntius/newsletter \ -H "Content-Type: application/json" \ -H "Origin: https://petrbalvin.org" \ -d '{ "email": "jane@example.com" }' ``` Successful response (all three): ```json { "ok": true } ``` Validation error (e.g. newsletter with bad email): ```json { "error": "validation", "details": [ { "field": "email", "message": "email is invalid" } ] } ``` ### Frontend integration If you proxy `/api/nuntius/*` through nginx (recommended, see [`docs/deployment.md`](docs/deployment.md)), no frontend config is needed — the form posts to the same origin as the page. Otherwise, set `VITE_NUNTIUS_URL` to the upstream origin: ```js await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/contact`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value, service: service.value, message: message.value, }), }); ``` ### Use the package in your own project `pkg/contactform` is a small, dependency-free Go package that you can import into any project: ```go import "codeberg.org/petrbalvin/nuntius/pkg/contactform" req := &contactform.Request{ Name: "Jane", Email: "jane@example.com", Service: "architecture", Message: "Hello!", } if errs := contactform.Validate(req, "contact"); len(errs) > 0 { // handle validation errors } ``` Full library example: [`docs/library-usage.md`](docs/library-usage.md). Full HTTP API: [`docs/api-reference.md`](docs/api-reference.md). ## Configuration (TL;DR) Default path: `/etc/nuntius/config.toml`. A starter file is written automatically on first start. ```toml data_dir = "./data" [server] port = 8080 [[forms]] name = "contact" type = "contact" path = "/api/nuntius/contact" to = "you@example.com" from = "noreply@example.com" rate_limit_per_hour = 10 honeypot_field = "website" allowed_origins = ["https://example.com"] [forms.smtp] host = "smtp.example.com" port = 587 user = "noreply@example.com" password = "${NUNTIUS_SMTP_PASSWORD}" ``` Form types and full schema: [`docs/configuration.md`](docs/configuration.md). Secrets via env vars, CORS, honeypot, rate limit, and validation rules are all documented there. ## Requirements - **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`) - An **SMTP** account (Yandex, Mailgun, Postmark, your own Postfix, …) - **No other runtime dependencies.** No Node, no Python, no Redis, no Postgres. Configuration is TOML parsed via the first-party [`interpres`](https://codeberg.org/petrbalvin/interpres) library; newsletter storage is an append-only JSONL file via `bufio`. ## Development ```bash just install # go mod tidy + go mod download (no-op for this project, kept for symmetry) just build # → ./bin/nuntius (with -s -w) just test # go test ./... just run # go run ./cmd/server just uninstall # rm -rf ./bin ``` All four `just` recipes are mandatory per the project conventions and produce zero errors and zero warnings. ## Documentation - [`docs/architecture.md`](docs/architecture.md) — layers, request flow, design decisions - [`docs/configuration.md`](docs/configuration.md) — full config schema, form types, secrets, newsletter log - [`docs/api-reference.md`](docs/api-reference.md) — HTTP verbs, status codes, `GET /health` - [`docs/deployment.md`](docs/deployment.md) — production systemd, nginx, updates - [`docs/security.md`](docs/security.md) — threat model, CORS, rate limiting, secret handling - [`docs/library-usage.md`](docs/library-usage.md) — `pkg/contactform` API and standalone use ## Contributing See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit conventions, and the pull request flow. ## Security For a full threat model, see [`docs/security.md`](docs/security.md). The short version: every form has its own per-IP rate limit, CORS allowlist, and honeypot field; SMTP credentials never touch the log; unknown config fields are rejected at startup. If you find a security issue, please email **opensource@petrbalvin.org** rather than opening a public issue. ## License MIT — see [LICENSE](./LICENSE). Copyright © 2026 [Petr Balvín](https://petrbalvin.org)