# 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
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.