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. |
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/contactHTTP/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/feedbackHTTP/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/newsletterHTTP/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": "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)
| `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
- 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.