189 lines
9.1 KiB
Markdown
189 lines
9.1 KiB
Markdown
# 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 |
|
||
|
|
|
||
|
|
```sh
|
||
|
|
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}
|
||
|
|
```
|
||
|
|
|
||
|
|
```sh
|
||
|
|
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:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"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
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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`
|
||
|
|
|
||
|
|
```sh
|
||
|
|
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`
|
||
|
|
|
||
|
|
```sh
|
||
|
|
curl -s -H "Authorization: Bearer $NUNTIUS_METRICS_TOKEN" http://localhost:8080/metrics
|
||
|
|
```
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"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.
|