# HTTP API Reference Every form defined in `config.toml` exposes the same three HTTP verbs on its configured `path`. The server also exposes one global endpoint (`GET /health`). ## Per-Form Endpoints For each form in the config: | Method | Behaviour | |---|---| | `POST` | Validate → honeypot → email send → (newsletter only) persist to disk | | `OPTIONS` | CORS preflight. Returns `204 No Content` if the origin is allowed; `403` otherwise. | | _other_ | `405 Method Not Allowed` (Go 1.22+ `http.ServeMux` semantics) | ### Request shape All four form types accept a single JSON object as the request body. Required fields depend on the form's `type`: | Type | Required | Optional | |---|---|---| | `contact` | `name`, `email`, `message` | `service`, `honeypot` | | `feedback` | `name`, `email`, `message` | `honeypot` | | `newsletter` | `email` | `honeypot` | | `generic` | `name`, `email`, `message` | `honeypot` | The `honeypot` field name in the request body is configured per form (`honeypot_field` in `config.toml`, default `"website"`). The handler reads it into `contactform.Request.Honeypot` (which is `json:"-"`, so it does not appear in the public Go API) and drops the request silently if it is non-empty. #### Example: contact ```http POST /api/nuntius/contact HTTP/1.1 Host: example.com Content-Type: application/json Origin: https://example.com { "name": "Jane Doe", "email": "jane@example.com", "service": "architecture", "message": "Hello, I would like to discuss an engagement." } ``` #### Example: feedback ```http POST /api/nuntius/feedback HTTP/1.1 Content-Type: application/json Origin: https://example.com { "name": "Jane Doe", "email": "jane@example.com", "message": "I love how the site uses Vue.js with zero runtime deps." } ``` #### Example: newsletter ```http POST /api/nuntius/newsletter HTTP/1.1 Content-Type: application/json Origin: https://example.com { "email": "jane@example.com" } ``` ## Response Status Codes | Status | Body | When | |---|---|---| | `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) | | `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) | | `204` | _empty_ | CORS preflight succeeded | | `400` | `{"error": "invalid_json", "message": "..."}` | Body is not valid JSON | | `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid | | `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` | | `405` | _empty_ | Method not allowed (e.g. `GET` on a form path) | | `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form | | `500` | `{"error": "send_failed", "message": "..."}` | SMTP round-trip failed | | `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter` only — could not write the JSONL line | ## Response Bodies ### Success ```json { "ok": true } ``` ### Validation error ```json { "error": "validation", "details": [ { "field": "name", "message": "name must be at least 2 characters" }, { "field": "email", "message": "email is invalid" }, { "field": "message", "message": "message must be at least 10 characters" } ] } ``` `details[]` always has at least one entry when `error == "validation"`. Each entry has `field` and `message` strings. The `message` is human-readable and safe to surface in the UI. ### JSON parse error ```json { "error": "invalid_json", "message": "Could not parse JSON body." } ``` ### CORS rejection ```json { "error": "origin_not_allowed" } ``` ### Rate limit ```json { "error": "rate_limited", "message": "Too many requests, please try again later." } ``` ### SMTP failure ```json { "error": "send_failed", "message": "Could not send email." } ``` ### Storage failure (newsletter only) ```json { "error": "storage_failed", "message": "Could not record subscription." } ``` ## CORS CORS is enforced **per form**. The flow: 1. Browser sends a preflight `OPTIONS` request with `Origin` and `Access-Control-Request-Method`. 2. nuntius checks `Origin` against the form's `allowed_origins`. 3. If allowed, the response carries: ```http Access-Control-Allow-Origin: Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Headers: Content-Type Vary: Origin ``` 4. If not allowed, the response is `403` with `{"error": "origin_not_allowed"}`. For the actual `POST`: - An absent `Origin` header is allowed (the form is treated as a non-browser client). This makes `curl` work out of the box. - A present `Origin` that is **not** in `allowed_origins` returns `403`. - A present `Origin` that **is** in `allowed_origins` gets the same CORS headers as the preflight. Wildcard `*` is not supported. List every origin explicitly. ## Rate Limiting Each form has its own in-memory token bucket. The bucket size equals `rate_limit_per_hour` and refills at `perHour / 3600` tokens per second. The first request from a new IP creates a full bucket. To disable rate limiting on a form, set `rate_limit_per_hour: 0` explicitly. There is no global safety net — use with care. Rate limit state is **per-process** and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is `replicas × per_form_limit`. The IP used for rate limiting is taken from the same precedence chain as the log line: 1. `X-Forwarded-For` (first hop, before any comma) 2. `X-Real-IP` 3. `RemoteAddr` (with the port stripped) Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` so the bucket sees the real client IP. ## Health Check ### `GET /health` ```http GET /health HTTP/1.1 ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "status": "ok", "forms": 3 } ``` | Field | Type | Description | |---|---|---| | `status` | string | Always `"ok"` while the process is up. There is no deep health check; the endpoint is suitable for a load balancer that wants to know "is the listener alive". | | `forms` | int | Number of forms loaded from `config.toml` at startup. | This endpoint is not rate-limited and is not CORS-checked. ## Curl Recipes ### POST a contact form ```bash curl -X POST http://localhost:8080/api/nuntius/contact \ -H "Content-Type: application/json" \ -H "Origin: https://petrbalvin.org" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "service": "architecture", "message": "Hello, I would like to discuss an engagement." }' ``` ### POST a feedback form ```bash curl -X POST http://localhost:8080/api/nuntius/feedback \ -H "Content-Type: application/json" \ -H "Origin: https://petrbalvin.org" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "message": "I love how the site uses Vue.js with zero runtime deps." }' ``` ### POST a newsletter signup ```bash curl -X POST http://localhost:8080/api/nuntius/newsletter \ -H "Content-Type: application/json" \ -H "Origin: https://petrbalvin.org" \ -d '{ "email": "jane@example.com" }' ``` ### Trigger the honeypot (bot) ```bash curl -X POST http://localhost:8080/api/nuntius/contact \ -H "Content-Type: application/json" \ -d '{ "name": "Bot", "email": "bot@spam.example", "message": "buy cheap viagra click here", "website": "https://spam.example" }' # → 200 {"ok": true}, no email sent, no log line about message sent ``` ### CORS preflight ```bash curl -i -X OPTIONS http://localhost:8080/api/nuntius/contact \ -H "Origin: https://petrbalvin.org" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" ``` ### Health check ```bash curl -s http://localhost:8080/health | jq . # → { "status": "ok", "forms": 3 } ``` ## Notes - nuntius does not currently expose per-form metrics, request counters, or Prometheus endpoints. A future release may add `GET /metrics`; for now, count rows in the JSONL log and grep `journalctl -u nuntius` for request lines. - All request bodies are bounded by `ReadTimeout` (15 s) and `WriteTimeout` (30 s). The full request body is read into memory by `json.NewDecoder(r.Body).Decode(&req)`; for a 5,000-rune message this is negligible. - Logs are emitted to stdout in JSON form via `log/slog`. One line per request (`method`, `path`, `status`, `duration_ms`, `ip`) plus one line per sent / failed message and one line per honeypot hit. There is no PII in the log line beyond the client IP.