feat: contact form backend for linux and freebsd servers
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

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
# Architecture
How nuntius is put together. Every node, package and arrow below exists in the
source tree; nothing is aspirational.
## Overview
```mermaid
flowchart TD
Browser[Browser / Frontend] --> Caddy[Caddy reverse proxy]
Caddy --> CLI[cmd/server/main.go]
CLI --> Config[internal/config]
CLI --> Handler[internal/handler]
Handler --> Validate[internal/contactform]
Handler --> Email[internal/email]
Handler --> Telegram[internal/telegram]
Handler --> Storage[internal/storage]
Email -->|SMTP PLAIN| SMTP[(SMTP server)]
Telegram -->|Bot API| Chat[(Telegram chat)]
Storage -->|append JSONL| Disk[(data_dir)]
```
One process serves many forms. The entry point loads the configuration and
builds a `ContactHandler`; every request then moves through one pipeline:
CORS, rate limit, parse, honeypot, validate, deliver, persist. The layers
below the handler know nothing about HTTP, and the handler knows nothing
about SMTP or the Bot API beyond two small interfaces.
## Packages
| Package | Responsibility |
|---|---|
| `cmd/server` | Orchestration only: slog setup, config load, handler build, route registration, request-log middleware, the `http.Server` with its configured timeouts, and the graceful shutdown on SIGINT or SIGTERM. No business logic. |
| `internal/config` | The gatekeeper for everything that varies per deployment. `${VAR}` expansion runs on the raw text before `interpres` parses it, unknown fields are rejected, and `validate()` enforces required fields, unique static paths, known form types and sane policy values. `(*Form).Policy()` resolves a form's validation policy: the built-in preset of its type with the configured `services`, `require_*` and rune-limit keys applied on top. Optional keys are pointers, so an omitted key falls back to its default while an explicit zero keeps its documented meaning. |
| `internal/contactform` | The request types and the one validator in the codebase, `Validate(req, policy)`, plus `Preset(formType)` and the `NormalizeAndValidate` preset wrapper. No awareness of HTTP, SMTP, the filesystem, or the platform. |
| `internal/handler` | The HTTP pipeline: per-form routes, CORS, rate limiting, honeypot, delivery fan-out, double opt-in confirmation, metrics registry, and the state persistence across restarts. It owns no delivery or storage logic; it calls the interfaces below. |
| `internal/email` | MIME composition (multipart/alternative, per-type templates, sanitised headers, randomised boundary) and deadline-bounded SMTP delivery with per-form credentials. STARTTLS, implicit TLS on port 465 and the `require_tls` policy are the operator's transport choices, enforced in code. |
| `internal/telegram` | One-way submission summaries through the Bot API, plain text, the bot token redacted from every error. |
| `internal/storage` | The append-only JSONL stores: newsletter subscribers with a dedupe index, pending double opt-in tokens, and the optional submission archive. Malformed lines are skipped on read, never fatal. |
| `internal/version` | The release identity read from the build information; nothing is injected. |
A new form type is a preset in `contactform.Preset`, a subject and a
`compose` branch in `internal/email`, and the wiring in `internal/config`;
every other layer is type-agnostic.
## Data flow
```mermaid
sequenceDiagram
participant Client
participant Handler as ContactHandler
participant Limiter as rateLimiter
participant Validate as contactform.Validate
participant Sender as email.FormSender
participant Bell as telegram.Notifier
participant Store as storage
Client->>Handler: POST <form path>
Handler->>Handler: CORS check
Handler->>Limiter: allow(ip)
Handler->>Handler: parse body (JSON, urlencoded or multipart)
Handler->>Handler: honeypot non-empty?
Handler->>Validate: Validate(req, form.Policy())
Handler->>Store: archive append (when enabled)
Handler->>Sender: Send(req)
Handler->>Bell: Notify(form, req) (when configured)
Handler-->>Client: 200 ok / 303 redirect / 4xx / 5xx
```
The pipeline is short-circuiting, and each step turns its failure into the
response the caller sees: a disallowed origin into `403 origin_not_allowed`,
an empty bucket into `429 rate_limited`, an oversized or unparsable body
into `413 body_too_large` or `400 invalid_body`, failed validation into
`400 validation` with per-field details, a failed archive append into
`500 storage_failed` before anything is sent, and a submission that reached
neither the mail nor the configured Telegram channel into `500 send_failed`.
Delivery itself is dual-channel: the mail is the record and the Telegram
notification the bell, so the submission counts as delivered when either
gets through. The honeypot turns a bot into an indistinguishable success,
and a newsletter form swaps the send for the double opt-in: a pending entry
keyed by the SHA-256 hash of a single-use token, a mailed confirmation
link, and the owner notification only after the link is redeemed.
The client identity for rate limiting and logging is the connection peer
address unless `server.trust_proxy_headers` opts in to `X-Forwarded-For`
first hop, then `X-Real-IP`; one `ClientIP` implementation serves both the
limiter and the request log.
## State and lifetime
- Long-lived: the `ContactHandler`, one `rateLimiter` per form with its
cleanup goroutine, one pending store and one subscriber store per
newsletter form, one archive store per archiving form, and the metrics
registry. The binary holds no other state and opens no persistent
connections; every mail and every Telegram call dials fresh.
- The rate limiter is a per-IP token bucket refilling at
`perHour/3600` tokens per second, capped at
`server.rate_limit_max_buckets` buckets per form, cleaned every
`server.rate_limit_cleanup_seconds` of buckets idle longer than
`server.rate_limit_max_bucket_age_seconds`. On shutdown the buckets
snapshot to `data_dir/ratelimit-snapshot.json` (atomic write, mode
`0600`) and startup restores them, dropping stale entries.
- The JSONL stores append and close on every write; the subscriber log is
the record of confirmed addresses, the pending file holds hashed tokens
that expire with `pending_ttl_seconds`, and the archive holds full
submissions written before the send. There is no rotation: a `logrotate`
unit is the operator's move when a file grows.
- The metrics counters are lifetime values in memory and reset on restart;
the same trade-off as the rate-limit buckets.
- A single mutex guards each store and the limiter map, and the registry
snapshots under lock; all request-path state is safe for concurrent use.
## Dependencies
The one non-stdlib module is
[`interpres`](https://sourcedock.dev/petrbalvin/interpres), the first-party
TOML parser, chosen for the strict decoding that turns configuration typos
into startup failures. Everything else is the standard library: `net/http`
with its method-and-pattern `ServeMux`, `net/smtp` with `crypto/tls`,
`net/mail`, `log/slog`, `crypto/rand`, and `runtime/debug` for the recorded
version. The supply chain outside the forge is empty, which keeps the build
reproducible and the binary small.