Files
nuntius/docs/api-reference.md

8.3 KiB
Raw Permalink Blame History

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

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

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

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

{ "ok": true }

Validation error

{
  "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

{ "error": "invalid_json", "message": "Could not parse JSON body." }

CORS rejection

{ "error": "origin_not_allowed" }

Rate limit

{ "error": "rate_limited", "message": "Too many requests, please try again later." }

SMTP failure

{ "error": "send_failed", "message": "Could not send email." }

Storage failure (newsletter only)

{ "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:

    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

GET /health HTTP/1.1
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

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

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

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)

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

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

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.