171 lines
6.8 KiB
Markdown
171 lines
6.8 KiB
Markdown
# Nuntius
|
|||
|
|
|
||
|
|
A small contact form backend for Linux and FreeBSD servers, made to run
|
||
|
|
behind [Caddy](https://caddyserver.com/) 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`](https://sourcedock.dev/petrbalvin/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](https://sourcedock.dev/petrbalvin/nuntius/releases). From
|
||
|
|
source:
|
||
|
|
|
||
|
|
```sh
|
||
|
|
git clone https://sourcedock.dev/petrbalvin/nuntius.git
|
||
|
|
cd nuntius
|
||
|
|
just build # produces bin/nuntius
|
||
|
|
```
|
||
|
|
|
||
|
|
## Quick start
|
||
|
|
|
||
|
|
```sh
|
||
|
|
# 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`](docs/DEPLOYMENT.md)
|
||
|
|
for the full setup. The full schema lives in
|
||
|
|
[`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).
|
||
|
|
|
||
|
|
## Usage
|
||
|
|
|
||
|
|
Run the server:
|
||
|
|
|
||
|
|
```sh
|
||
|
|
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:
|
||
|
|
|
||
|
|
```sh
|
||
|
|
NUNTIUS_CONFIG=./config.toml bin/nuntius --check-config
|
||
|
|
```
|
||
|
|
|
||
|
|
Post a submission:
|
||
|
|
|
||
|
|
```sh
|
||
|
|
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`](docs/API.md); the flags and exit codes are in
|
||
|
|
[`docs/CLI.md`](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:
|
||
|
|
|
||
|
|
```caddyfile
|
||
|
|
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`](docs/DEPLOYMENT.md).
|
||
|
|
|
||
|
|
## Development
|
||
|
|
|
||
|
|
```sh
|
||
|
|
just build # build
|
||
|
|
just test # the test suite with the coverage floor
|
||
|
|
just fmt # format
|
||
|
|
```
|
||
|
|
|
||
|
|
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
|
||
|
|
[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute.
|
||
|
|
|
||
|
|
## Documentation
|
||
|
|
|
||
|
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
|
||
|
|
- [docs/API.md](docs/API.md): the API reference
|
||
|
|
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md): every configuration key
|
||
|
|
- [docs/CLI.md](docs/CLI.md): flags, exit codes, and the manual page
|
||
|
|
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): production setup, Caddy, updates
|
||
|
|
- [SECURITY.md](SECURITY.md): how to report a vulnerability
|
||
|
|
|
||
|
|
## Licence
|
||
|
|
|
||
|
|
MIT, see [LICENSE](LICENSE).
|
||
|
|
|
||
|
|
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
|