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:
+188
@@ -0,0 +1,188 @@
|
||||
# API
|
||||
|
||||
The HTTP surface of nuntius. Every form defined in `config.toml` exposes
|
||||
the same verbs on its configured `path`; the server adds two global
|
||||
endpoints. There is no authentication on the form endpoints: the browser
|
||||
contract is the per-form CORS allowlist, and `GET /metrics` optionally
|
||||
carries a bearer token.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `<form path>` | validate, honeypot-check, deliver (mail plus the optional Telegram notification); newsletter forms start the double opt-in |
|
||||
| `OPTIONS` | `<form path>` | CORS preflight |
|
||||
| `GET` | `<form path>/confirm` | redeem a newsletter double opt-in token |
|
||||
| `GET` | `/health` | listener liveness and the form count |
|
||||
| `GET` | `/metrics` | lifetime counters per form and in total |
|
||||
|
||||
Any other method on a form path answers `405 Method Not Allowed`.
|
||||
|
||||
### `POST <form path>`
|
||||
|
||||
Two body shapes carry the same fields. The JSON contract is one JSON
|
||||
object per request. The plain HTML form contract accepts
|
||||
`application/x-www-form-urlencoded` and `multipart/form-data` posts under
|
||||
the fixed field names `name`, `email`, `service` and `message` plus the
|
||||
configured honeypot field, so an ordinary `<form method="post">` works
|
||||
without JavaScript; file parts are ignored.
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `name` | `contact`, `feedback`, `generic` presets; tunable with `require_name` | 2 to 100 runes by default |
|
||||
| `email` | always | RFC 5322, never length-limited |
|
||||
| `service` | optional | checked against the form's allow-list |
|
||||
| `message` | `contact`, `feedback`, `generic` presets; tunable with `require_message` | 10 to 5,000 runes by default |
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:8080/api/nuntius/contact \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://example.com" \
|
||||
-d '{"name":"Jane Doe","email":"jane@example.com","service":"architecture","message":"Hello, I would like to discuss an engagement."}'
|
||||
# → 200 {"ok": true}
|
||||
```
|
||||
|
||||
```sh
|
||||
curl -i -X POST http://localhost:8080/api/nuntius/contact \
|
||||
-d 'name=Jane Doe&email=jane@example.com&message=Hello, I would like to discuss an engagement.'
|
||||
# → 303 See Other, Location: https://example.com/thanks (when redirect_url is set)
|
||||
```
|
||||
|
||||
| Status | Body | When |
|
||||
|---|---|---|
|
||||
| `200` | `{"ok": true}` | Submission accepted and delivered. A form with `forms.telegram` counts as delivered when either the mail or the chat message gets through |
|
||||
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) |
|
||||
| `303` | _redirect_ | A form with `redirect_url` set answers every accepted submission, honeypot hits and duplicate signups included, with `303 See Other` to the configured page |
|
||||
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
|
||||
| `400` | `{"error": "invalid_body", "message": "..."}` | The body is unparsable: not valid JSON, or a broken form body |
|
||||
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
|
||||
| `413` | `{"error": "body_too_large", "message": "..."}` | Body exceeds `server.max_body_bytes` |
|
||||
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
|
||||
| `500` | `{"error": "send_failed", "message": "..."}` | Both delivery channels failed: the SMTP round-trip and, where configured, the Telegram notification |
|
||||
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter`, or any form with `archive` set: could not write the JSONL line |
|
||||
|
||||
The error bodies are one shape: `error` is a machine-readable code
|
||||
(`validation`, `invalid_body`, `origin_not_allowed`, `rate_limited`,
|
||||
`send_failed`, `storage_failed`), `message` a human-readable summary, and
|
||||
`details[]` carries the per-field validation failures:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "validation",
|
||||
"details": [
|
||||
{ "field": "name", "message": "name must be at least 2 characters" },
|
||||
{ "field": "email", "message": "email is invalid" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rate limiting is a per-IP token bucket per form: the bucket size equals
|
||||
`rate_limit_per_hour`, it refills at `perHour / 3600` tokens per second,
|
||||
and `rate_limit_per_hour: 0` disables it. State is per-process and survives
|
||||
graceful restarts via the snapshot file under `data_dir/`; scaled
|
||||
horizontally, each replica has its own bucket. The IP is the connection
|
||||
peer address unless `server.trust_proxy_headers` opts in to proxy headers.
|
||||
|
||||
#### CORS
|
||||
|
||||
A browser preflight is an `OPTIONS` request with `Origin` and
|
||||
`Access-Control-Request-Method`; an allowed origin gets `204` with
|
||||
`Access-Control-Allow-Origin` echoed, `POST, OPTIONS` methods,
|
||||
`Content-Type` allowed and `Vary: Origin`. A plain HTML form post carries
|
||||
an `Origin` header like any browser POST, so a same-origin `<form>` lists
|
||||
its own origin in `allowed_origins` too. An absent `Origin` header is
|
||||
allowed, which makes `curl` work out of the box; a present origin that is
|
||||
not allowlisted gets `403`. Wildcard `*` is not supported.
|
||||
|
||||
### `GET <form path>/confirm`
|
||||
|
||||
Newsletter forms expose this endpoint next to their POST route; the link
|
||||
inside every confirmation mail points here, and the response is a small
|
||||
HTML page.
|
||||
|
||||
| Outcome | Status | Page |
|
||||
|---|---|---|
|
||||
| Valid, unexpired token | `200` | "Subscription confirmed"; the address moves into the subscriber log and the owner is notified |
|
||||
| Unknown, already redeemed or expired token | `410` | "Link expired", inviting a fresh signup |
|
||||
| Storage failure while saving the record | `500` | "Almost there", the link stays usable for a retry |
|
||||
|
||||
Tokens are single-use 256-bit random values; only their SHA-256 hashes are
|
||||
stored server-side.
|
||||
|
||||
#### Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Subscriber
|
||||
participant Nuntius
|
||||
participant Mailbox as Subscriber's inbox
|
||||
Subscriber->>Nuntius: POST <form path> (email)
|
||||
Nuntius->>Nuntius: store pending (SHA-256 token)
|
||||
Nuntius-->>Subscriber: 200 ok
|
||||
Nuntius->>Mailbox: confirmation link
|
||||
Subscriber->>Nuntius: GET <form path>/confirm?token=...
|
||||
Nuntius->>Nuntius: append subscriber, notify owner
|
||||
Nuntius-->>Subscriber: 200 confirmed page
|
||||
```
|
||||
|
||||
### `GET /health`
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:8080/health
|
||||
# → {"status":"ok","forms":3}
|
||||
```
|
||||
|
||||
`status` is always `"ok"` while the process is up; there is no deep health
|
||||
check. `forms` is the number of forms loaded from `config.toml`. The
|
||||
endpoint is not rate-limited and is not CORS-checked.
|
||||
|
||||
### `GET /metrics`
|
||||
|
||||
```sh
|
||||
curl -s -H "Authorization: Bearer $NUNTIUS_METRICS_TOKEN" http://localhost:8080/metrics
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"totals": { "received": 42, "honeypot_blocked": 7, "rate_limited": 3, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 2, "validation_failed": 5, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 1, "sent": 19, "auto_reply_failed": 0, "telegram_failed": 0 },
|
||||
"forms": {
|
||||
"/api/nuntius/contact": { "received": 30, "honeypot_blocked": 5, "rate_limited": 2, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 1, "validation_failed": 3, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 0, "sent": 15, "auto_reply_failed": 0, "telegram_failed": 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Counter | Meaning |
|
||||
|---|---|
|
||||
| `received` | POST requests that passed the origin check |
|
||||
| `honeypot_blocked` | Bot submissions dropped by the honeypot field |
|
||||
| `rate_limited` | Requests rejected with `429 rate_limited` |
|
||||
| `origin_blocked` | Requests rejected with `403 origin_not_allowed` |
|
||||
| `body_too_large` | Requests rejected with `413 body_too_large` |
|
||||
| `invalid_body` | Requests rejected because the body was unreadable or unparsable, whichever shape it claimed |
|
||||
| `validation_failed` | Requests rejected with field-level `400 validation` errors |
|
||||
| `send_failed` | Deliveries where neither channel got through |
|
||||
| `persist_failed` | Newsletter submissions or archive writes whose disk append failed |
|
||||
| `duplicate_signup` | Newsletter signups skipped because the address is already recorded |
|
||||
| `confirmation_sent` | Newsletter signups whose confirmation link was mailed |
|
||||
| `confirmed` | Confirmation links successfully redeemed |
|
||||
| `confirmation_failed` | Confirmations that could not be saved, or clicked after expiry or unknown |
|
||||
| `sent` | Completed owner notifications: the submission mail for non-newsletter forms |
|
||||
| `auto_reply_failed` | Receipts to the submitter that failed after an accepted submission |
|
||||
| `telegram_failed` | Telegram notifications that failed; the submission stays delivered when the mail went out |
|
||||
|
||||
Counters are lifetime values held in memory; they reset on restart, the
|
||||
same trade-off as the rate-limit buckets. The endpoint is not
|
||||
rate-limited and sends no CORS headers, so third-party pages cannot read
|
||||
submission volumes. With `server.metrics_token` set it requires that
|
||||
token as a bearer credential and answers `401` with a
|
||||
`WWW-Authenticate: Bearer` challenge otherwise; without the key it is
|
||||
open, protected at the reverse proxy like any operational surface.
|
||||
|
||||
## Notes
|
||||
|
||||
- Request bodies are bounded by `server.max_body_bytes` (1 MiB by default)
|
||||
and read fully into memory; for a 5,000-rune message this is negligible.
|
||||
- Logs are one JSON line per request (`method`, `path`, `status`,
|
||||
`duration_ms`, `ip`) via `log/slog`, plus one line per sent or failed
|
||||
message and one per honeypot hit. No PII beyond the client IP, and no
|
||||
credentials anywhere.
|
||||
@@ -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.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Command line
|
||||
|
||||
The reference below is taken from the program's own `--help`. If the two
|
||||
disagree, the program is right and this file is a defect.
|
||||
|
||||
## Synopsis
|
||||
|
||||
```sh
|
||||
nuntius [--version] [--check-config]
|
||||
```
|
||||
|
||||
With no flags, nuntius loads the configuration, serves the form endpoints,
|
||||
and shuts down gracefully on SIGINT or SIGTERM.
|
||||
|
||||
## Global flags
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--help`, `-h` | prints the usage |
|
||||
| `--version` | prints the release and exits: the tag at a tag, a pseudo-version naming the commit otherwise, `(devel)` outside version control, `+dirty` appended on a dirty tree |
|
||||
| `--check-config` | loads and validates the configuration, then exits without listening; prints `configuration OK: <path>` on success, names the problem and exits nonzero on any error, so it runs as a systemd `ExecStartPre` |
|
||||
|
||||
Every flag is long-only, and Go's `flag` package accepts it with one or two
|
||||
leading hyphens, so `-version` and `--version` are the same flag.
|
||||
|
||||
## Environment and files
|
||||
|
||||
`NUNTIUS_CONFIG` moves the configuration file away from
|
||||
`/etc/nuntius/config.toml`. The full schema lives in
|
||||
[`CONFIGURATION.md`](CONFIGURATION.md).
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | the version was printed, the configuration is valid, or the server shut down cleanly |
|
||||
| `1` | the configuration is missing, invalid or fails validation, or the server failed to serve |
|
||||
|
||||
## Examples
|
||||
|
||||
Print the release:
|
||||
|
||||
```sh
|
||||
nuntius --version
|
||||
# nuntius v1.0.0
|
||||
```
|
||||
|
||||
Validate a configuration without listening, as a deploy pipeline would:
|
||||
|
||||
```sh
|
||||
NUNTIUS_CONFIG=./config.toml nuntius --check-config
|
||||
# configuration OK: ./config.toml
|
||||
```
|
||||
|
||||
## Manual page
|
||||
|
||||
A roff copy of this reference ships as `man/nuntius.1` in the repository
|
||||
and installs with the binary; read it with:
|
||||
|
||||
```sh
|
||||
man ./man/nuntius.1
|
||||
```
|
||||
@@ -0,0 +1,214 @@
|
||||
# Configuration
|
||||
|
||||
nuntius reads its configuration from one TOML file, `/etc/nuntius/config.toml`
|
||||
by default. The `NUNTIUS_CONFIG` environment variable points the server at
|
||||
any other path. When the file does not exist, the first start writes a
|
||||
three-form starter template there.
|
||||
|
||||
## File
|
||||
|
||||
A complete example with every key present at its default value, so the file
|
||||
documents itself:
|
||||
|
||||
```toml
|
||||
data_dir = "./data"
|
||||
|
||||
[server]
|
||||
port = 8080
|
||||
bind = "::"
|
||||
read_header_timeout_seconds = 10
|
||||
read_timeout_seconds = 15
|
||||
write_timeout_seconds = 30
|
||||
idle_timeout_seconds = 60
|
||||
shutdown_timeout_seconds = 15
|
||||
max_body_bytes = 1048576
|
||||
rate_limit_max_buckets = 32768
|
||||
rate_limit_cleanup_seconds = 3600
|
||||
rate_limit_max_bucket_age_seconds = 7200
|
||||
trust_proxy_headers = false
|
||||
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"
|
||||
|
||||
[[forms]]
|
||||
name = "contact"
|
||||
path = "/api/nuntius/contact"
|
||||
type = "contact"
|
||||
redirect_url = ""
|
||||
archive = false
|
||||
auto_reply = false
|
||||
to = "you@example.com"
|
||||
from = "noreply@example.com"
|
||||
rate_limit_per_hour = 10
|
||||
honeypot_field = "website"
|
||||
allowed_origins = ["https://example.com"]
|
||||
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
|
||||
require_name = true
|
||||
require_message = true
|
||||
min_name_runes = 2
|
||||
max_name_runes = 100
|
||||
min_message_runes = 10
|
||||
max_message_runes = 5000
|
||||
subject_prefix = "nuntius"
|
||||
email_brand = "nuntius"
|
||||
|
||||
[forms.smtp]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
user = "noreply@example.com"
|
||||
password = "${NUNTIUS_SMTP_PASSWORD}"
|
||||
require_tls = false
|
||||
timeout_seconds = 20
|
||||
|
||||
[forms.telegram]
|
||||
bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
|
||||
chat_id = "123456789"
|
||||
timeout_seconds = 10
|
||||
|
||||
[[forms]]
|
||||
name = "newsletter"
|
||||
path = "/api/nuntius/newsletter"
|
||||
type = "newsletter"
|
||||
to = "you@example.com"
|
||||
from = "noreply@example.com"
|
||||
rate_limit_per_hour = 100
|
||||
honeypot_field = "bot_email"
|
||||
allowed_origins = ["https://example.com"]
|
||||
pending_ttl_seconds = 259200
|
||||
|
||||
[forms.smtp]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
user = "noreply@example.com"
|
||||
password = "${NUNTIUS_SMTP_PASSWORD}"
|
||||
```
|
||||
|
||||
The newsletter form shows the keys that apply to it; `archive`,
|
||||
`auto_reply` and `telegram` do not apply to newsletter forms and are
|
||||
rejected at startup.
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Type | Default | Effect |
|
||||
|---|---|---|---|
|
||||
| `data_dir` | string | `"./data"` | Directory for the JSONL logs; created on first write. Relative to the working directory. |
|
||||
| `server.port` | int | `8080` | HTTP listen port. Must be 1 to 65535. |
|
||||
| `server.bind` | string | `"::"` | Host part of the listen address; `"::"` is the dual-stack wildcard, an address binds that one only. Must not contain whitespace. |
|
||||
| `server.read_header_timeout_seconds` | int | `10` | Budget for reading the request headers (Slowloris defence). `0` switches it off. |
|
||||
| `server.read_timeout_seconds` | int | `15` | Budget for reading the request body. `0` switches it off. |
|
||||
| `server.write_timeout_seconds` | int | `30` | Budget for writing the response. `0` switches it off. |
|
||||
| `server.idle_timeout_seconds` | int | `60` | Keep-alive idle budget. `0` switches it off. |
|
||||
| `server.shutdown_timeout_seconds` | int | `15` | Graceful shutdown budget before in-flight connections are cut. |
|
||||
| `server.max_body_bytes` | int | `1048576` | Request body cap in bytes; oversized bodies get `413`. Must be at least 1. |
|
||||
| `server.rate_limit_max_buckets` | int | `32768` | Per-form cap on distinct IP buckets; once full, unknown IPs are denied until cleanup frees stale entries. Must be at least 1. |
|
||||
| `server.rate_limit_cleanup_seconds` | int | `3600` | Interval between bucket cleanup sweeps. Must be at least 1. |
|
||||
| `server.rate_limit_max_bucket_age_seconds` | int | `7200` | Age at which an idle bucket is dropped. Must be at least 1. |
|
||||
| `server.trust_proxy_headers` | bool | `false` | Read client IPs from `X-Forwarded-For` / `X-Real-IP` instead of the connection peer address. Enable only behind a proxy that overwrites these headers (Caddy 2.5+ does by default). |
|
||||
| `server.metrics_token` | string | none | Bearer token guarding `GET /metrics`. Empty keeps the endpoint open. Supports `${VAR}` expansion. |
|
||||
| `forms[].name` | string | yes | Internal identifier; appears in logs and file names. Letters, digits, hyphens and underscores only. |
|
||||
| `forms[].path` | string | yes | HTTP path the form is served on. Must start with `/`, be unique, and be a static pattern with no empty segments. |
|
||||
| `forms[].type` | string | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`; selects the behaviour bundle, the mail template and the default validation policy. |
|
||||
| `forms[].smtp` | table | yes | SMTP connection settings: `host`, `port`, `user`, `password`, `require_tls`, `timeout_seconds` (default `20`). Port `465` speaks implicit TLS; `587` upgrades via STARTTLS, which `require_tls = true` makes mandatory. |
|
||||
| `forms[].to` | string | yes | Recipient address (`To:` header). |
|
||||
| `forms[].from` | string | `smtp.user` | Sender address (`From:` header). |
|
||||
| `forms[].redirect_url` | string | none | Turns the form into a plain HTML form target: accepted submissions answer `303 See Other` with this location. Failures stay JSON. Must not contain whitespace. |
|
||||
| `forms[].archive` | bool | `false` | Persist every accepted submission to `data_dir/archive-<name>.jsonl` before the mail is attempted. Not valid on `newsletter` forms. |
|
||||
| `forms[].auto_reply` | bool | `false` | Mail the submitter a short automated receipt with `Reply-To` set to `to`. Best effort; failures land in the `auto_reply_failed` counter. Not valid on `newsletter` forms. |
|
||||
| `forms[].telegram` | table | none | Telegram notification channel: `bot_token` (supports `${VAR}`), `chat_id` (numeric id or `@channelusername`), `timeout_seconds` (default `10`). Not valid on `newsletter` forms. |
|
||||
| `forms[].rate_limit_per_hour` | int | `10` | Submissions per IP per hour. `0` disables. |
|
||||
| `forms[].honeypot_field` | string | `"website"` | Name of the invisible form field. Empty string disables the honeypot. |
|
||||
| `forms[].allowed_origins` | []string | `[]` | CORS allowlist, one origin per entry; a same-origin HTML form lists its own origin too. |
|
||||
| `forms[].services` | []string | type default | Allow-list for the optional `service` payload field, on any form type. |
|
||||
| `forms[].require_name` | bool | type default | Switch the name field into the validation. |
|
||||
| `forms[].require_message` | bool | type default | Switch the message field into the validation. |
|
||||
| `forms[].min_name_runes` | int | `2` | Minimum name length in runes; 0 or more. |
|
||||
| `forms[].max_name_runes` | int | `100` | Maximum name length in runes; at least `min_name_runes` and 1 or more. |
|
||||
| `forms[].min_message_runes` | int | `10` | Minimum message length in runes; 0 or more. |
|
||||
| `forms[].max_message_runes` | int | `5000` | Maximum message length in runes; at least `min_message_runes` and 1 or more. |
|
||||
| `forms[].pending_ttl_seconds` | int | `259200` | Double opt-in lifetime for newsletter forms (72 hours). Must be at least 1. |
|
||||
| `forms[].subject_prefix` | string | `"nuntius"` | The `[<prefix>/<name>]` segment of every mail subject. Empty string drops the segment. |
|
||||
| `forms[].email_brand` | string | `"nuntius"` | The `Delivered by <brand>` footer in every mail. Empty string drops the footer. |
|
||||
|
||||
The email address is always required and always checked against RFC 5322:
|
||||
every form delivers mail and needs a reply-to address, so there is no key
|
||||
to switch that check off.
|
||||
|
||||
### Form types
|
||||
|
||||
The four types are behaviour bundles. Each bundles a mail template, a
|
||||
subject text, and a default validation policy; every part of that policy
|
||||
can be overridden per form with the keys above, so a form whose needs
|
||||
differ is a configuration matter, never a code change.
|
||||
|
||||
| Type | Default required fields | Email subject | Notes |
|
||||
|---|---|---|---|
|
||||
| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/<name>] Contact form submission`, or `[nuntius/<name>][<service>] ...` if `service` is set | The default. `service` is checked against the allow-list: the built-in list by default, or the form's own `services` key. |
|
||||
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. A `services` key opts the field into the validation and the mail. |
|
||||
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | Double opt-in: the address waits in `data_dir/newsletter-<name>-pending.json` until the emailed link (`GET <form path>/confirm?token=...`) is redeemed. |
|
||||
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | Escape hatch for one-off forms; pair it with the per-form policy keys to shape it freely. |
|
||||
|
||||
### The service allow-list
|
||||
|
||||
The `service` payload field is optional everywhere and the empty value
|
||||
always passes, so a frontend that never sends it needs no configuration at
|
||||
all. When a value is sent, it is checked against the form's allow-list:
|
||||
|
||||
| Configuration | Accepted service values |
|
||||
|---|---|
|
||||
| key omitted | `contact`: the built-in list below. Other types: the field is not validated at all. |
|
||||
| `services = ["consulting", "support"]` | only the listed values (plus the empty value), on any form type |
|
||||
| `services = ["*"]` | any value |
|
||||
| `services = []` | only the empty value |
|
||||
|
||||
The built-in list for `contact`: `architecture`, `ai`, `infrastructure`,
|
||||
`software`, `unix`, `other`. Anything else triggers a `400 validation`
|
||||
error. Entries must not be empty and must not carry surrounding
|
||||
whitespace; such a config is rejected at startup.
|
||||
|
||||
### The newsletter log
|
||||
|
||||
A confirmed signup is one JSON line in
|
||||
`data_dir/newsletter-<name>.jsonl`:
|
||||
|
||||
```json
|
||||
{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}
|
||||
```
|
||||
|
||||
`email` is the trimmed address, `ip` the client IP as seen by nuntius, and
|
||||
`created_at` a UTC timestamp set at append time. The log is append-only,
|
||||
survives restarts, and is never rotated automatically: add a `logrotate`
|
||||
unit if it grows. Malformed lines are skipped on read, so a partial write
|
||||
can never brick the file.
|
||||
|
||||
## Precedence
|
||||
|
||||
The configuration is one file plus the environment, in this order:
|
||||
|
||||
1. `NUNTIUS_CONFIG` chooses the file; without it the path is
|
||||
`/etc/nuntius/config.toml`.
|
||||
2. `${VAR_NAME}` and `$VAR_NAME` references in the raw text expand from the
|
||||
process environment before parsing. Expansion is strict: a referenced
|
||||
variable that is not set aborts startup with an error naming it, while a
|
||||
set-but-empty value expands to the empty string and a dollar sign with no
|
||||
variable name stays literal. Comment lines are never expanded.
|
||||
3. Every key omitted from the file falls back to the default in the table
|
||||
above; an explicit value, including a disabling zero, always wins.
|
||||
|
||||
Secrets belong in the environment, not the file: reference them as
|
||||
`${NUNTIUS_SMTP_PASSWORD}` and provide them through the systemd
|
||||
`EnvironmentFile=` (`/etc/nuntius/.env`, mode `0600`, which
|
||||
`scripts/install.pl` sets up). Credentials are never logged.
|
||||
|
||||
## Validation
|
||||
|
||||
A wrong value stops the program at startup with a message naming the key;
|
||||
nothing is silently ignored and nothing falls back silently. The loader
|
||||
rejects unknown TOML fields and unknown form types, ports outside 1 to
|
||||
65535, negative timeouts and limits, form paths that are not static route
|
||||
patterns, form names with characters outside `[a-zA-Z0-9_-]`, policy
|
||||
windows where the maximum sits below the minimum, service entries that are
|
||||
empty or carry surrounding whitespace, `redirect_url` with whitespace,
|
||||
`archive`, `auto_reply` or `telegram` on newsletter forms, and a Telegram
|
||||
channel without `bot_token` or `chat_id`. At request time the payload is
|
||||
validated against the form's policy: limits are counted in runes after
|
||||
whitespace is trimmed, the email address must parse as RFC 5322, and the
|
||||
error messages quote the configured numbers. See [`API.md`](API.md) for the
|
||||
response shapes.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Deployment
|
||||
|
||||
How nuntius runs in production: a single static binary behind Caddy, under
|
||||
systemd, with its state in one data directory.
|
||||
|
||||
## Topology
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Visitor's browser] --> Caddy[Caddy :443]
|
||||
Caddy -->|reverse_proxy /api/nuntius/*| Nuntius[nuntius :8080]
|
||||
Systemd[Systemd unit] -.->|manages| Nuntius
|
||||
Nuntius -->|SMTP PLAIN| SMTP[(SMTP provider)]
|
||||
Nuntius -->|append JSONL| Disk[(/var/lib/nuntius/data)]
|
||||
```
|
||||
|
||||
nuntius binds `[::]:8080` by default: the dual-stack wildcard that accepts
|
||||
both IPv4 and IPv6 connections. The host part is the `server.bind` key in
|
||||
`config.toml`; set it to `127.0.0.1` for a loopback-only listener, which is
|
||||
the recommended shape behind a proxy.
|
||||
|
||||
### Reverse proxy
|
||||
|
||||
Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`):
|
||||
|
||||
```caddyfile
|
||||
handle_path /api/nuntius/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Then validate and reload:
|
||||
|
||||
```sh
|
||||
sudo caddy validate
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Caddy overwrites `X-Forwarded-For` for untrusted clients, which is what the
|
||||
`server.trust_proxy_headers` opt-in expects. Frontends on the same site
|
||||
post to the same origin and need no extra URL; the per-form
|
||||
`allowed_origins` list carries every origin that may post.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Linux or FreeBSD host with systemd and a C compiler-free runtime: the
|
||||
binary is static, nothing else ships with it.
|
||||
- The `nuntius` system user and group, the data directory
|
||||
`/var/lib/nuntius`, the configuration at `/etc/nuntius/config.toml`, and
|
||||
the environment file `/etc/nuntius/.env` (all created by the installer or
|
||||
the manual steps below).
|
||||
- Port 8080 locally, 443 publicly through Caddy.
|
||||
- An SMTP account (any standards-compliant provider, such as Proton Mail or
|
||||
Thundermail).
|
||||
|
||||
| File | Owner | Mode | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary |
|
||||
| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory and data root |
|
||||
| `/var/lib/nuntius/data/` | `nuntius` | `0750` | JSONL logs and the rate-limit snapshot (created at runtime) |
|
||||
| `/etc/nuntius/config.toml` | `root` | `0644` | The configuration (auto-generated, then edited) |
|
||||
| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets, loaded via `EnvironmentFile=` |
|
||||
| `/etc/systemd/system/nuntius.service` | `root` | `0644` | The systemd unit |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
just build
|
||||
```
|
||||
|
||||
Copy the artefacts to the server:
|
||||
|
||||
```sh
|
||||
rsync -avz bin/nuntius nuntius.service .env.example scripts/install.pl user@your-server:/tmp/nuntius/
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
The installer does the whole sequence and is idempotent; re-running is
|
||||
safe and it never starts the service, so you review the configuration
|
||||
first:
|
||||
|
||||
```sh
|
||||
ssh user@your-server
|
||||
cd /tmp/nuntius
|
||||
sudo perl install.pl
|
||||
```
|
||||
|
||||
It creates the `nuntius` system user, `/var/lib/nuntius`, installs the
|
||||
binary and the unit, generates the starter configuration, seeds
|
||||
`/etc/nuntius/.env` from `.env.example`, and enables the service. Then
|
||||
review and start:
|
||||
|
||||
```sh
|
||||
sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD
|
||||
sudo $EDITOR /etc/nuntius/config.toml # real SMTP settings and allowed origins
|
||||
sudo systemctl start nuntius
|
||||
sudo journalctl -u nuntius -f
|
||||
```
|
||||
|
||||
Without the installer, the same steps by hand: create the user and
|
||||
directory as above, `install -m 0755` the binary, `install -m 0644` the
|
||||
unit, start the binary once to generate `/etc/nuntius/config.toml`,
|
||||
`systemctl daemon-reload && systemctl enable --now nuntius`.
|
||||
|
||||
## Service unit
|
||||
|
||||
The unit lives at `nuntius.service` in the repository root and installs to
|
||||
`/etc/systemd/system/nuntius.service`; the copy in this document would
|
||||
drift, the pointer does not. It runs as the `nuntius` user with
|
||||
`WorkingDirectory=/var/lib/nuntius` (so the default `data_dir = "./data"`
|
||||
resolves to `/var/lib/nuntius/data`), loads `/etc/nuntius/.env`, validates
|
||||
the configuration through `ExecStartPre=/usr/local/bin/nuntius
|
||||
--check-config`, and restarts on failure. The service is enabled, not
|
||||
started, after installation.
|
||||
|
||||
## Production configuration
|
||||
|
||||
The keys that differ from the defaults in production: `allowed_origins`
|
||||
carries the real frontend origins, the `[forms.smtp]` block carries the
|
||||
real host and credentials, `server.metrics_token` guards the metrics
|
||||
endpoint when it is exposed, and `server.bind` stays `::` or moves to a
|
||||
loopback address behind the proxy. Secrets live in `/etc/nuntius/.env` and
|
||||
reach the configuration through `${VAR}` references; no secret value
|
||||
belongs in `config.toml` or in this repository.
|
||||
|
||||
## Upgrade
|
||||
|
||||
```sh
|
||||
cd nuntius
|
||||
just build
|
||||
rsync -avz bin/nuntius user@your-server:/tmp/nuntius/
|
||||
ssh user@your-server 'sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius && sudo systemctl restart nuntius'
|
||||
```
|
||||
|
||||
The service does not reload `config.toml`; after editing it, restart. A
|
||||
broken configuration exits 1 and systemd does not keep it up, so fix the
|
||||
reported key and start again. `just gates` before the new binary ships.
|
||||
|
||||
## Rollback
|
||||
|
||||
Reinstall the previous release's binary from the releases page and
|
||||
restart; the configuration, the JSONL logs and the rate-limit snapshot
|
||||
carry over untouched. Rehearsed exactly this far, and no further: there is
|
||||
no automated rollback path. The reverse of the whole installation is
|
||||
`systemctl disable --now nuntius`, removing the unit, the binary,
|
||||
`/etc/nuntius` and `/var/lib/nuntius`, and deleting the user.
|
||||
|
||||
## Monitoring
|
||||
|
||||
A healthy instance answers the health endpoint and writes one JSON request
|
||||
line per submission:
|
||||
|
||||
```sh
|
||||
systemctl status nuntius
|
||||
/usr/local/bin/nuntius --version
|
||||
curl -s http://127.0.0.1:8080/health
|
||||
journalctl -u nuntius -n 50 --no-pager
|
||||
curl -i https://your-domain.example/api/nuntius/health
|
||||
```
|
||||
|
||||
An end-to-end check through the proxy:
|
||||
|
||||
```sh
|
||||
curl -X POST https://your-domain.example/api/nuntius/contact \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://your-domain.example" \
|
||||
-d '{"name":"Test","email":"test@example.com","message":"hello there, this is a test message"}'
|
||||
```
|
||||
|
||||
`{"ok": true}` plus a `request` line with `status=200` in the journal means
|
||||
the deploy is live. Subscriber counts come from the log:
|
||||
|
||||
```sh
|
||||
wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl
|
||||
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u
|
||||
```
|
||||
|
||||
Back up `/etc/nuntius/config.toml`, `/etc/nuntius/.env` and
|
||||
`/var/lib/nuntius/data/*.jsonl` with the usual jobs; the log is
|
||||
append-only and trivially archivable.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Development
|
||||
|
||||
How to work on nuntius.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.27.1, the version recorded in `go.mod`, the newest stable release.
|
||||
- [just](https://github.com/casey/just) for the recipes.
|
||||
- gcc for `just gates`: the race detector needs cgo.
|
||||
|
||||
Nothing else. There is no Node, no Python, and no third-party Go module
|
||||
beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres)
|
||||
TOML parser.
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
git clone https://sourcedock.dev/petrbalvin/nuntius.git
|
||||
cd nuntius
|
||||
just build
|
||||
```
|
||||
|
||||
Configuration lives in TOML, read from `/etc/nuntius/config.toml` by
|
||||
default; the `NUNTIUS_CONFIG` environment variable points the server at any
|
||||
file you like, which keeps development free of root-owned paths:
|
||||
|
||||
```sh
|
||||
export NUNTIUS_CONFIG=./config.toml
|
||||
export NUNTIUS_SMTP_PASSWORD=admin # the placeholder the template references
|
||||
just run # first start writes the starter config
|
||||
```
|
||||
|
||||
The starter forms point at example.com addresses, so submissions return
|
||||
`500 send_failed` until `config.toml` carries real SMTP settings; strict
|
||||
environment expansion means every `${VAR}` the config references must be set
|
||||
before startup.
|
||||
|
||||
## Recipes
|
||||
|
||||
Every recipe in the project's file, taken from the file itself:
|
||||
|
||||
| Recipe | What it does |
|
||||
|--------|--------------|
|
||||
| `just default` | prints the recipe list |
|
||||
| `just build` | `CGO_ENABLED=0 go build -trimpath -buildvcs=true -ldflags "-s -w"` into `bin/nuntius` |
|
||||
| `just test` | the suite with no test cache, then the 80 % coverage floor over `./internal/...` |
|
||||
| `just race` | the same suite under the race detector |
|
||||
| `just unit` | fast scoped run for iterating: cached, no race, no coverage |
|
||||
| `just fuzz` | time-boxed fuzz of one target in one package |
|
||||
| `just bench` | benchmarks |
|
||||
| `just fmt` | `gofmt -w .` |
|
||||
| `just fmt-check` | zero gofmt diff; prints nothing when everything is formatted |
|
||||
| `just vet` | `go vet ./...` and `go fix -diff ./...` |
|
||||
| `just gates` | build, fmt-check, vet, test, race: the definition of done, once per task |
|
||||
| `just clean` | removes `bin/` and `coverage.out` |
|
||||
| `just install` | builds, then copies the binary into `~/.local/bin` (override with `BINDIR`) |
|
||||
| `just uninstall` | removes the installed binary |
|
||||
| `just run` | `go run -buildvcs=true ./cmd/server` |
|
||||
| `just dev` | the same as `run`: nuntius carries no watch or reload tool |
|
||||
| `just coverage-html` | HTML coverage report from the gate's profile; an extension, not a gate |
|
||||
|
||||
The test, race, unit and fuzz recipes run under a cgroup memory fence, so a
|
||||
runaway test dies at the ceiling instead of eating the machine.
|
||||
|
||||
## Running a single test
|
||||
|
||||
```sh
|
||||
go test -run TestName ./internal/handler/
|
||||
```
|
||||
|
||||
Add `-v` for the sub-test names, and `-race` when the change touches
|
||||
concurrency. `-count=1` defeats the test cache when a result looks stale;
|
||||
`just unit` keeps the cache on purpose, because a scoped iterating run wants
|
||||
to be instant.
|
||||
|
||||
## Coverage
|
||||
|
||||
```sh
|
||||
just test
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
The `total:` line is the number that matters, and it stays at 80 percent or
|
||||
more. Coverage is measured over the logic packages only; `cmd/server` is
|
||||
thin glue around them. For the HTML map:
|
||||
|
||||
```sh
|
||||
just coverage-html
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
```sh
|
||||
just bench
|
||||
```
|
||||
|
||||
Benchmark on an idle machine, and compare only runs made in one process
|
||||
against each other: runs in separate processes, or on a loaded machine,
|
||||
differ by more than the effects being measured.
|
||||
|
||||
## Fuzzing
|
||||
|
||||
Two fuzz targets exist: `FuzzValidate` in `internal/contactform` and
|
||||
`FuzzLoadConfig` in `internal/config`. They are exploration, never a gate;
|
||||
time-box one explicitly:
|
||||
|
||||
```sh
|
||||
just fuzz FuzzValidate ./internal/contactform 30s
|
||||
```
|
||||
|
||||
## Debugging the build
|
||||
|
||||
```sh
|
||||
go build -gcflags='-m' ./... # inlining decisions
|
||||
go build -gcflags='-S' ./... # what the compiler generated
|
||||
```
|
||||
|
||||
## Continuous integration
|
||||
|
||||
Workflows live in `.gitea/workflows/` and run on the project's own runners.
|
||||
They are written by hand rather than through `just`, but they enforce the
|
||||
same set of gates, so a green `just gates` locally is the fastest way to a
|
||||
green pipeline. The per-workflow table is in
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
## Releases
|
||||
|
||||
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`.
|
||||
The tag drives the release workflow, which builds the assets and publishes
|
||||
the notes it extracted from `CHANGELOG.md`.
|
||||
Reference in New Issue
Block a user