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
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:
@@ -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.
|
||||
Reference in New Issue
Block a user