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

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+188
View File
@@ -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.