2026-07-26 20:44:28 +02:00

Nuntius — A Small, Opinionated Contact Form Backend

License Go Linux FreeBSD

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 binarygo 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 boxcontact, 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 validationpkg/contactform.Validate is reusable; name, email (RFC 5322), service allow-list (for contact), and message length.
  • Reusable packagepkg/contactform exports typed Request / Response / ErrorResponse plus a Validate function you can drop into any Go project.
  • Structured logginglog/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

# 1. Clone
git clone https://sourcedock.dev/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; the deploy guide is in docs/deployment.md.

Usage

Test the endpoints

# 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):

{ "ok": true }

Validation error (e.g. newsletter with bad email):

{
  "error": "validation",
  "details": [
    { "field": "email", "message": "email is invalid" }
  ]
}

Frontend integration

If you proxy /api/nuntius/* through nginx (recommended, see 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:

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:

import "sourcedock.dev/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. Full HTTP API: docs/api-reference.md.

Configuration (TL;DR)

Default path: /etc/nuntius/config.toml. A starter file is written automatically on first start.

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. 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 library; newsletter storage is an append-only JSONL file via bufio.

Development

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

Contributing

See CONTRIBUTING.md for development setup, code style, commit conventions, and the pull request flow.

Security

For a full threat model, see 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.
Copyright © 2026 Petr Balvín

S
Description
A small, opinionated contact form backend written in Go — serves multiple JSON endpoints, delivers submissions via SMTP, single static binary.
Readme MIT
210 KiB
v1.1.0
Latest
2026-07-26 19:48:22 +00:00
Languages
Go 89.7%
Python 9.3%
Just 1%