Files
nuntius/docs/ARCHITECTURE.md
petrbalvin 3a38f00dc0
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
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

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