243 lines
9.8 KiB
Markdown
243 lines
9.8 KiB
Markdown
# 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`](https://sourcedock.dev/petrbalvin/interpres) TOML parser. Stripped
|
|
binary is roughly 6 MB.
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
# 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`](docs/configuration.md); the
|
|
production deploy guide is in [`docs/deployment.md`](docs/deployment.md).
|
|
|
|
## Features
|
|
|
|
- **Single static binary** — `go build -ldflags="-s -w"` produces roughly 6 MB;
|
|
no Node, no Python, no Redis, no Postgres, no third-party Go packages.
|
|
- **Four form kinds** — `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 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 validation** — `pkg/contactform.Validate` is reusable; validates
|
|
name, email (RFC 5322), service allow-list (for `contact`), and message length.
|
|
- **Reusable Go package** — `pkg/contactform` exports typed `Request`, `Response`,
|
|
and `ErrorResponse` structs plus the `Validate` function you can import into
|
|
any Go project.
|
|
- **Structured logging** — `log/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.
|
|
|
|
```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}"
|
|
```
|
|
|
|
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`](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`](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 timeouts** — `ReadHeaderTimeout` 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`](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`](docs/deployment.md).
|
|
|
|
## Library Usage
|
|
|
|
The `pkg/contactform` package is a small, dependency-free Go library you can
|
|
import into any project:
|
|
|
|
```go
|
|
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`](docs/library-usage.md).
|
|
|
|
## Development
|
|
|
|
```bash
|
|
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`](https://sourcedock.dev/petrbalvin/interpres) library; newsletter
|
|
storage is an append-only JSONL file via `bufio`; rate limiting is in-memory.
|
|
|
|
## 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, Caddy, 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 usage
|
|
|
|
## Contributing
|
|
|
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit
|
|
conventions, and the pull request flow.
|
|
|
|
## License
|
|
|
|
MIT — see [LICENSE](./LICENSE).
|
|
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
|