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

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 interpres TOML parser.
  • Four form kinds: contact, feedback, newsletter, and generic. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field.
  • No-JavaScript forms: endpoints also accept plain urlencoded and multipart posts and can answer 303 See Other to a thank-you page, so an ordinary HTML <form> works with no script at all.
  • SMTP delivery: Go stdlib net/smtp with PLAIN auth over STARTTLS (upgraded automatically when the server advertises it, credentials never sent in plaintext), implicit TLS on port 465, and an optional require_tls policy 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_reply mails the sender a short automated acknowledgement with the owner's address as Reply-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 service field, and message length. Every limit is a configuration key, and the server builds each form's policy from config.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 /health and GET /metrics with lifetime counters per form, optionally guarded by a bearer token.
  • Structured logging: log/slog with JSON output to stdout; credentials are never logged.
  • TOML configuration: single file, parsed by the first-party interpres library. Unknown fields and unknown form types are rejected at startup so typos fail loudly.
  • Environment variable expansion: ${VAR_NAME} and $VAR_NAME in 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

Licence

MIT, see LICENSE.

Copyright © 2026 Petr Balvín

S
Description
A small contact form backend for Linux and FreeBSD servers.
Readme MIT
159 KiB
v0.1.0
Latest
2026-09-28 22:33:07 +00:00
Languages
Go 96.3%
Perl 2.2%
Just 1.5%