Files
nuntius/docs/API.md
T
petrbalvin 3a38f00dc0
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
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

9.1 KiB

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
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}
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:

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

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

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

curl -s -H "Authorization: Bearer $NUNTIUS_METRICS_TOKEN" http://localhost:8080/metrics
{
  "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.