Files
nuntius/docs/api-reference.md
T

272 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <echoed 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)
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
## 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.