petrbalvin 603e1e344b
Release / build (amd64, freebsd) (push) Successful in 56s
Release / build (amd64, linux) (push) Successful in 58s
Release / build (arm64, freebsd) (push) Successful in 58s
Release / build (arm64, linux) (push) Successful in 58s
Release / build (loong64, linux) (push) Successful in 1m3s
Release / build (riscv64, freebsd) (push) Successful in 1m1s
Release / build (riscv64, linux) (push) Successful in 1m1s
Release / release (push) Successful in 20s
fix(ci): use single-line JSON for curl and add missing GITEA_REF_NAME
2026-07-26 21:48:17 +02:00
2026-07-26 20:44:28 +02:00

Nuntius — A Small, Opinionated Contact Form Backend

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 Caddy on a modest Linux or FreeBSD server.

Module sourcedock.dev/petrbalvin/nuntius — Go 1.26, zero third-party dependencies. The only external module is the first-party interpres TOML parser. Stripped binary is roughly 6 MB.

Quick Start

# 1. Clone
git clone https://sourcedock.dev/petrbalvin/nuntius.git
cd nuntius

# 2. Build
just build
# → ./bin/nuntius

# 3. Run (writes a three-form config template to /etc/nuntius/config.toml on first start)
sudo mkdir -p /etc/nuntius
just run
# → nuntius starting  addr=[::]:8080  forms=3

# 4. Edit the generated config with your SMTP credentials and recipient address
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 to the internet.

The full schema lives in docs/configuration.md; the production deploy guide is in docs/deployment.md.

Features

  • Single static binarygo build -ldflags="-s -w" produces roughly 6 MB; no Node, no Python, no Redis, no Postgres, no third-party Go packages.
  • Four form kindscontact, 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 over STARTTLS or implicit TLS. Works with any standards-compliant SMTP provider.
  • 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 valid signup.
  • Server-side validationpkg/contactform.Validate is reusable; validates name, email (RFC 5322), service allow-list (for contact), and message length.
  • Reusable Go packagepkg/contactform exports typed Request, Response, and ErrorResponse structs plus the Validate function you can import into any Go project.
  • Structured logginglog/slog with JSON output to stdout; plays well with journald, Loki, Datadog, and similar.
  • Graceful shutdown — SIGINT and SIGTERM close in-flight requests within a configurable deadline (default 15 s).
  • Token-bucket rate limiting — per IP, per form, configurable submissions per hour; in-memory, no external store required.
  • Honeypot field — invisible to humans, required by bots. Silently accepts and drops spam submissions.
  • 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, riscv64, loong64) and FreeBSD (amd64, arm64, riscv64) on every release.

Configuration

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}"

Secrets are injected via environment variables using ${VAR_NAME} syntax. The full schema, form types, validation rules, and newsletter log format are documented in docs/configuration.md.

Form Types

Type Required fields Email subject Stores to disk
contact name, email, message; optional service [nuntius/<name>] Contact form submission
feedback name, email, message [nuntius/<name>] New feedback
newsletter email [nuntius/<name>] New newsletter subscriber
generic name, email, message [nuntius/<name>] New submission

All four types are rate-limited, CORS-checked, and honeypot-protected independently — each form has its own per-IP bucket, allowlist, and honeypot field. newsletter is the only type that writes to disk; the other three only send email.

The contact form restricts the service field to an allow-list: architecture, ai, infrastructure, software, unix, other, or empty.

Full form type documentation: docs/configuration.md#form-types.

Security

Every form endpoint is protected independently:

  • Honeypot — a configurable invisible form field silently drops submissions that contain it. Enabled per form via honeypot_field.
  • CORS — per-form allowed_origins list. Requests from disallowed origins receive a 403 Forbidden.
  • Rate limiting — token-bucket algorithm, per IP, per form, configurable submissions per hour. In-memory; no Redis required. Set rate_limit_per_hour to 0 to disable for a given form.
  • Server timeoutsReadHeaderTimeout prevents slow-loris attacks; graceful shutdown deadline ensures in-flight requests are drained before exit.
  • Secret hygiene — SMTP credentials are never logged; TOML values use ${VAR_NAME} expansion so passwords stay in the environment, not on disk.
  • Config validation — unknown TOML fields and unknown form types cause a startup failure, preventing typo-driven misconfigurations from reaching production.
  • Input validation — server-side validation of all fields with configurable length limits; trimmed whitespace; RFC 5322 email parsing.

For a full threat model and detailed security discussion, see docs/security.md. To report a vulnerability privately, email opensource@petrbalvin.org.

Production Deployment

Build the binary on your workstation, upload it with the systemd unit and environment template to your server, then run python3 install.py as root. The script is idempotent and sets up:

  • A dedicated nuntius system user and group
  • The binary at /usr/local/bin/nuntius
  • Config auto-generation at /etc/nuntius/config.toml
  • Environment file at /etc/nuntius/.env (mode 0600)
  • A systemd service unit at /etc/systemd/system/nuntius.service
  • Data directory at /var/lib/nuntius

The service is enabled but not started — you review the config and environment file first, then start it manually.

Complete deployment instructions including Caddy reverse-proxy configuration, TLS setup, log rotation, and updates are in docs/deployment.md.

Library Usage

The pkg/contactform package is a small, dependency-free Go library 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, I would like to discuss an engagement.",
}

if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
    // handle validation errors
}

The package exports typed Request, Response, and ErrorResponse structs, the Validate function, and all form kind constants. A full standalone example is in docs/library-usage.md.

Development

just install   # go mod tidy + go mod download
just build     # → ./bin/nuntius (stripped, -s -w)
just test      # go test -race ./...; enforces ≥80% coverage on internal + pkg
just fmt       # gofmt -w cmd internal pkg
just run       # go run ./cmd/server

All just recipes produce zero errors and zero warnings. Tests include the race detector and enforce an 80% coverage floor on the internal and pkg packages.

The CI pipeline (Gitea Actions, .gitea/workflows/) runs the full test suite and performs a multi-platform build matrix on every release tag (linux/amd64, linux/arm64, linux/riscv64, linux/loong64, freebsd/amd64, freebsd/arm64, freebsd/riscv64).

Requirements

  • Go 1.26+
  • An SMTP account (any standards-compliant provider — Postfix, Yandex 360, Mailgun, Postmark, etc.)
  • No other runtime dependencies. No Node, no Python, no Redis, no Postgres. Config is TOML parsed by the first-party interpres library; newsletter storage is an append-only JSONL file via bufio; rate limiting is in-memory.

Documentation

Contributing

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

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%