Assisted-by: GLM 5.3 Flash
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) vialog/slog, plus one line per sent or failed message and one per honeypot hit. No PII beyond the client IP, and no credentials anywhere.