Assisted-by: GLM 5.3 Flash
7.1 KiB
Architecture
How nuntius is put together. Every node, package and arrow below exists in the source tree; nothing is aspirational.
Overview
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
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, onerateLimiterper 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/3600tokens per second, capped atserver.rate_limit_max_bucketsbuckets per form, cleaned everyserver.rate_limit_cleanup_secondsof buckets idle longer thanserver.rate_limit_max_bucket_age_seconds. On shutdown the buckets snapshot todata_dir/ratelimit-snapshot.json(atomic write, mode0600) 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: alogrotateunit 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, 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.