feat: contact form backend for linux and freebsd servers
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

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
# 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.
+122
View File
@@ -0,0 +1,122 @@
# Architecture
How nuntius is put together. Every node, package and arrow below exists in the
source tree; nothing is aspirational.
## Overview
```mermaid
flowchart TD
Browser[Browser / Frontend] --> Caddy[Caddy reverse proxy]
Caddy --> CLI[cmd/server/main.go]
CLI --> Config[internal/config]
CLI --> Handler[internal/handler]
Handler --> Validate[internal/contactform]
Handler --> Email[internal/email]
Handler --> Telegram[internal/telegram]
Handler --> Storage[internal/storage]
Email -->|SMTP PLAIN| SMTP[(SMTP server)]
Telegram -->|Bot API| Chat[(Telegram chat)]
Storage -->|append JSONL| Disk[(data_dir)]
```
One process serves many forms. The entry point loads the configuration and
builds a `ContactHandler`; every request then moves through one pipeline:
CORS, rate limit, parse, honeypot, validate, deliver, persist. The layers
below the handler know nothing about HTTP, and the handler knows nothing
about SMTP or the Bot API beyond two small interfaces.
## Packages
| Package | Responsibility |
|---|---|
| `cmd/server` | Orchestration only: slog setup, config load, handler build, route registration, request-log middleware, the `http.Server` with its configured timeouts, and the graceful shutdown on SIGINT or SIGTERM. No business logic. |
| `internal/config` | The gatekeeper for everything that varies per deployment. `${VAR}` expansion runs on the raw text before `interpres` parses it, unknown fields are rejected, and `validate()` enforces required fields, unique static paths, known form types and sane policy values. `(*Form).Policy()` resolves a form's validation policy: the built-in preset of its type with the configured `services`, `require_*` and rune-limit keys applied on top. Optional keys are pointers, so an omitted key falls back to its default while an explicit zero keeps its documented meaning. |
| `internal/contactform` | The request types and the one validator in the codebase, `Validate(req, policy)`, plus `Preset(formType)` and the `NormalizeAndValidate` preset wrapper. No awareness of HTTP, SMTP, the filesystem, or the platform. |
| `internal/handler` | The HTTP pipeline: per-form routes, CORS, rate limiting, honeypot, delivery fan-out, double opt-in confirmation, metrics registry, and the state persistence across restarts. It owns no delivery or storage logic; it calls the interfaces below. |
| `internal/email` | MIME composition (multipart/alternative, per-type templates, sanitised headers, randomised boundary) and deadline-bounded SMTP delivery with per-form credentials. STARTTLS, implicit TLS on port 465 and the `require_tls` policy are the operator's transport choices, enforced in code. |
| `internal/telegram` | One-way submission summaries through the Bot API, plain text, the bot token redacted from every error. |
| `internal/storage` | The append-only JSONL stores: newsletter subscribers with a dedupe index, pending double opt-in tokens, and the optional submission archive. Malformed lines are skipped on read, never fatal. |
| `internal/version` | The release identity read from the build information; nothing is injected. |
A new form type is a preset in `contactform.Preset`, a subject and a
`compose` branch in `internal/email`, and the wiring in `internal/config`;
every other layer is type-agnostic.
## Data flow
```mermaid
sequenceDiagram
participant Client
participant Handler as ContactHandler
participant Limiter as rateLimiter
participant Validate as contactform.Validate
participant Sender as email.FormSender
participant Bell as telegram.Notifier
participant Store as storage
Client->>Handler: POST <form path>
Handler->>Handler: CORS check
Handler->>Limiter: allow(ip)
Handler->>Handler: parse body (JSON, urlencoded or multipart)
Handler->>Handler: honeypot non-empty?
Handler->>Validate: Validate(req, form.Policy())
Handler->>Store: archive append (when enabled)
Handler->>Sender: Send(req)
Handler->>Bell: Notify(form, req) (when configured)
Handler-->>Client: 200 ok / 303 redirect / 4xx / 5xx
```
The pipeline is short-circuiting, and each step turns its failure into the
response the caller sees: a disallowed origin into `403 origin_not_allowed`,
an empty bucket into `429 rate_limited`, an oversized or unparsable body
into `413 body_too_large` or `400 invalid_body`, failed validation into
`400 validation` with per-field details, a failed archive append into
`500 storage_failed` before anything is sent, and a submission that reached
neither the mail nor the configured Telegram channel into `500 send_failed`.
Delivery itself is dual-channel: the mail is the record and the Telegram
notification the bell, so the submission counts as delivered when either
gets through. The honeypot turns a bot into an indistinguishable success,
and a newsletter form swaps the send for the double opt-in: a pending entry
keyed by the SHA-256 hash of a single-use token, a mailed confirmation
link, and the owner notification only after the link is redeemed.
The client identity for rate limiting and logging is the connection peer
address unless `server.trust_proxy_headers` opts in to `X-Forwarded-For`
first hop, then `X-Real-IP`; one `ClientIP` implementation serves both the
limiter and the request log.
## State and lifetime
- Long-lived: the `ContactHandler`, one `rateLimiter` per form with its
cleanup goroutine, one pending store and one subscriber store per
newsletter form, one archive store per archiving form, and the metrics
registry. The binary holds no other state and opens no persistent
connections; every mail and every Telegram call dials fresh.
- The rate limiter is a per-IP token bucket refilling at
`perHour/3600` tokens per second, capped at
`server.rate_limit_max_buckets` buckets per form, cleaned every
`server.rate_limit_cleanup_seconds` of buckets idle longer than
`server.rate_limit_max_bucket_age_seconds`. On shutdown the buckets
snapshot to `data_dir/ratelimit-snapshot.json` (atomic write, mode
`0600`) and startup restores them, dropping stale entries.
- The JSONL stores append and close on every write; the subscriber log is
the record of confirmed addresses, the pending file holds hashed tokens
that expire with `pending_ttl_seconds`, and the archive holds full
submissions written before the send. There is no rotation: a `logrotate`
unit is the operator's move when a file grows.
- The metrics counters are lifetime values in memory and reset on restart;
the same trade-off as the rate-limit buckets.
- A single mutex guards each store and the limiter map, and the registry
snapshots under lock; all request-path state is safe for concurrent use.
## Dependencies
The one non-stdlib module is
[`interpres`](https://sourcedock.dev/petrbalvin/interpres), the first-party
TOML parser, chosen for the strict decoding that turns configuration typos
into startup failures. Everything else is the standard library: `net/http`
with its method-and-pattern `ServeMux`, `net/smtp` with `crypto/tls`,
`net/mail`, `log/slog`, `crypto/rand`, and `runtime/debug` for the recorded
version. The supply chain outside the forge is empty, which keeps the build
reproducible and the binary small.
+62
View File
@@ -0,0 +1,62 @@
# Command line
The reference below is taken from the program's own `--help`. If the two
disagree, the program is right and this file is a defect.
## Synopsis
```sh
nuntius [--version] [--check-config]
```
With no flags, nuntius loads the configuration, serves the form endpoints,
and shuts down gracefully on SIGINT or SIGTERM.
## Global flags
| Flag | Effect |
|---|---|
| `--help`, `-h` | prints the usage |
| `--version` | prints the release and exits: the tag at a tag, a pseudo-version naming the commit otherwise, `(devel)` outside version control, `+dirty` appended on a dirty tree |
| `--check-config` | loads and validates the configuration, then exits without listening; prints `configuration OK: <path>` on success, names the problem and exits nonzero on any error, so it runs as a systemd `ExecStartPre` |
Every flag is long-only, and Go's `flag` package accepts it with one or two
leading hyphens, so `-version` and `--version` are the same flag.
## Environment and files
`NUNTIUS_CONFIG` moves the configuration file away from
`/etc/nuntius/config.toml`. The full schema lives in
[`CONFIGURATION.md`](CONFIGURATION.md).
## Exit codes
| Code | Meaning |
|---|---|
| `0` | the version was printed, the configuration is valid, or the server shut down cleanly |
| `1` | the configuration is missing, invalid or fails validation, or the server failed to serve |
## Examples
Print the release:
```sh
nuntius --version
# nuntius v1.0.0
```
Validate a configuration without listening, as a deploy pipeline would:
```sh
NUNTIUS_CONFIG=./config.toml nuntius --check-config
# configuration OK: ./config.toml
```
## Manual page
A roff copy of this reference ships as `man/nuntius.1` in the repository
and installs with the binary; read it with:
```sh
man ./man/nuntius.1
```
+214
View File
@@ -0,0 +1,214 @@
# Configuration
nuntius reads its configuration from one TOML file, `/etc/nuntius/config.toml`
by default. The `NUNTIUS_CONFIG` environment variable points the server at
any other path. When the file does not exist, the first start writes a
three-form starter template there.
## File
A complete example with every key present at its default value, so the file
documents itself:
```toml
data_dir = "./data"
[server]
port = 8080
bind = "::"
read_header_timeout_seconds = 10
read_timeout_seconds = 15
write_timeout_seconds = 30
idle_timeout_seconds = 60
shutdown_timeout_seconds = 15
max_body_bytes = 1048576
rate_limit_max_buckets = 32768
rate_limit_cleanup_seconds = 3600
rate_limit_max_bucket_age_seconds = 7200
trust_proxy_headers = false
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"
[[forms]]
name = "contact"
path = "/api/nuntius/contact"
type = "contact"
redirect_url = ""
archive = false
auto_reply = false
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
require_name = true
require_message = true
min_name_runes = 2
max_name_runes = 100
min_message_runes = 10
max_message_runes = 5000
subject_prefix = "nuntius"
email_brand = "nuntius"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
require_tls = false
timeout_seconds = 20
[forms.telegram]
bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
chat_id = "123456789"
timeout_seconds = 10
[[forms]]
name = "newsletter"
path = "/api/nuntius/newsletter"
type = "newsletter"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
pending_ttl_seconds = 259200
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
```
The newsletter form shows the keys that apply to it; `archive`,
`auto_reply` and `telegram` do not apply to newsletter forms and are
rejected at startup.
## Keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `data_dir` | string | `"./data"` | Directory for the JSONL logs; created on first write. Relative to the working directory. |
| `server.port` | int | `8080` | HTTP listen port. Must be 1 to 65535. |
| `server.bind` | string | `"::"` | Host part of the listen address; `"::"` is the dual-stack wildcard, an address binds that one only. Must not contain whitespace. |
| `server.read_header_timeout_seconds` | int | `10` | Budget for reading the request headers (Slowloris defence). `0` switches it off. |
| `server.read_timeout_seconds` | int | `15` | Budget for reading the request body. `0` switches it off. |
| `server.write_timeout_seconds` | int | `30` | Budget for writing the response. `0` switches it off. |
| `server.idle_timeout_seconds` | int | `60` | Keep-alive idle budget. `0` switches it off. |
| `server.shutdown_timeout_seconds` | int | `15` | Graceful shutdown budget before in-flight connections are cut. |
| `server.max_body_bytes` | int | `1048576` | Request body cap in bytes; oversized bodies get `413`. Must be at least 1. |
| `server.rate_limit_max_buckets` | int | `32768` | Per-form cap on distinct IP buckets; once full, unknown IPs are denied until cleanup frees stale entries. Must be at least 1. |
| `server.rate_limit_cleanup_seconds` | int | `3600` | Interval between bucket cleanup sweeps. Must be at least 1. |
| `server.rate_limit_max_bucket_age_seconds` | int | `7200` | Age at which an idle bucket is dropped. Must be at least 1. |
| `server.trust_proxy_headers` | bool | `false` | Read client IPs from `X-Forwarded-For` / `X-Real-IP` instead of the connection peer address. Enable only behind a proxy that overwrites these headers (Caddy 2.5+ does by default). |
| `server.metrics_token` | string | none | Bearer token guarding `GET /metrics`. Empty keeps the endpoint open. Supports `${VAR}` expansion. |
| `forms[].name` | string | yes | Internal identifier; appears in logs and file names. Letters, digits, hyphens and underscores only. |
| `forms[].path` | string | yes | HTTP path the form is served on. Must start with `/`, be unique, and be a static pattern with no empty segments. |
| `forms[].type` | string | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`; selects the behaviour bundle, the mail template and the default validation policy. |
| `forms[].smtp` | table | yes | SMTP connection settings: `host`, `port`, `user`, `password`, `require_tls`, `timeout_seconds` (default `20`). Port `465` speaks implicit TLS; `587` upgrades via STARTTLS, which `require_tls = true` makes mandatory. |
| `forms[].to` | string | yes | Recipient address (`To:` header). |
| `forms[].from` | string | `smtp.user` | Sender address (`From:` header). |
| `forms[].redirect_url` | string | none | Turns the form into a plain HTML form target: accepted submissions answer `303 See Other` with this location. Failures stay JSON. Must not contain whitespace. |
| `forms[].archive` | bool | `false` | Persist every accepted submission to `data_dir/archive-<name>.jsonl` before the mail is attempted. Not valid on `newsletter` forms. |
| `forms[].auto_reply` | bool | `false` | Mail the submitter a short automated receipt with `Reply-To` set to `to`. Best effort; failures land in the `auto_reply_failed` counter. Not valid on `newsletter` forms. |
| `forms[].telegram` | table | none | Telegram notification channel: `bot_token` (supports `${VAR}`), `chat_id` (numeric id or `@channelusername`), `timeout_seconds` (default `10`). Not valid on `newsletter` forms. |
| `forms[].rate_limit_per_hour` | int | `10` | Submissions per IP per hour. `0` disables. |
| `forms[].honeypot_field` | string | `"website"` | Name of the invisible form field. Empty string disables the honeypot. |
| `forms[].allowed_origins` | []string | `[]` | CORS allowlist, one origin per entry; a same-origin HTML form lists its own origin too. |
| `forms[].services` | []string | type default | Allow-list for the optional `service` payload field, on any form type. |
| `forms[].require_name` | bool | type default | Switch the name field into the validation. |
| `forms[].require_message` | bool | type default | Switch the message field into the validation. |
| `forms[].min_name_runes` | int | `2` | Minimum name length in runes; 0 or more. |
| `forms[].max_name_runes` | int | `100` | Maximum name length in runes; at least `min_name_runes` and 1 or more. |
| `forms[].min_message_runes` | int | `10` | Minimum message length in runes; 0 or more. |
| `forms[].max_message_runes` | int | `5000` | Maximum message length in runes; at least `min_message_runes` and 1 or more. |
| `forms[].pending_ttl_seconds` | int | `259200` | Double opt-in lifetime for newsletter forms (72 hours). Must be at least 1. |
| `forms[].subject_prefix` | string | `"nuntius"` | The `[<prefix>/<name>]` segment of every mail subject. Empty string drops the segment. |
| `forms[].email_brand` | string | `"nuntius"` | The `Delivered by <brand>` footer in every mail. Empty string drops the footer. |
The email address is always required and always checked against RFC 5322:
every form delivers mail and needs a reply-to address, so there is no key
to switch that check off.
### Form types
The four types are behaviour bundles. Each bundles a mail template, a
subject text, and a default validation policy; every part of that policy
can be overridden per form with the keys above, so a form whose needs
differ is a configuration matter, never a code change.
| Type | Default required fields | Email subject | Notes |
|---|---|---|---|
| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/<name>] Contact form submission`, or `[nuntius/<name>][<service>] ...` if `service` is set | The default. `service` is checked against the allow-list: the built-in list by default, or the form's own `services` key. |
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. A `services` key opts the field into the validation and the mail. |
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | Double opt-in: the address waits in `data_dir/newsletter-<name>-pending.json` until the emailed link (`GET <form path>/confirm?token=...`) is redeemed. |
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | Escape hatch for one-off forms; pair it with the per-form policy keys to shape it freely. |
### The service allow-list
The `service` payload field is optional everywhere and the empty value
always passes, so a frontend that never sends it needs no configuration at
all. When a value is sent, it is checked against the form's allow-list:
| Configuration | Accepted service values |
|---|---|
| key omitted | `contact`: the built-in list below. Other types: the field is not validated at all. |
| `services = ["consulting", "support"]` | only the listed values (plus the empty value), on any form type |
| `services = ["*"]` | any value |
| `services = []` | only the empty value |
The built-in list for `contact`: `architecture`, `ai`, `infrastructure`,
`software`, `unix`, `other`. Anything else triggers a `400 validation`
error. Entries must not be empty and must not carry surrounding
whitespace; such a config is rejected at startup.
### The newsletter log
A confirmed signup is one JSON line in
`data_dir/newsletter-<name>.jsonl`:
```json
{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}
```
`email` is the trimmed address, `ip` the client IP as seen by nuntius, and
`created_at` a UTC timestamp set at append time. The log is append-only,
survives restarts, and is never rotated automatically: add a `logrotate`
unit if it grows. Malformed lines are skipped on read, so a partial write
can never brick the file.
## Precedence
The configuration is one file plus the environment, in this order:
1. `NUNTIUS_CONFIG` chooses the file; without it the path is
`/etc/nuntius/config.toml`.
2. `${VAR_NAME}` and `$VAR_NAME` references in the raw text expand from the
process environment before parsing. Expansion is strict: a referenced
variable that is not set aborts startup with an error naming it, while a
set-but-empty value expands to the empty string and a dollar sign with no
variable name stays literal. Comment lines are never expanded.
3. Every key omitted from the file falls back to the default in the table
above; an explicit value, including a disabling zero, always wins.
Secrets belong in the environment, not the file: reference them as
`${NUNTIUS_SMTP_PASSWORD}` and provide them through the systemd
`EnvironmentFile=` (`/etc/nuntius/.env`, mode `0600`, which
`scripts/install.pl` sets up). Credentials are never logged.
## Validation
A wrong value stops the program at startup with a message naming the key;
nothing is silently ignored and nothing falls back silently. The loader
rejects unknown TOML fields and unknown form types, ports outside 1 to
65535, negative timeouts and limits, form paths that are not static route
patterns, form names with characters outside `[a-zA-Z0-9_-]`, policy
windows where the maximum sits below the minimum, service entries that are
empty or carry surrounding whitespace, `redirect_url` with whitespace,
`archive`, `auto_reply` or `telegram` on newsletter forms, and a Telegram
channel without `bot_token` or `chat_id`. At request time the payload is
validated against the form's policy: limits are counted in runes after
whitespace is trimmed, the email address must parse as RFC 5322, and the
error messages quote the configured numbers. See [`API.md`](API.md) for the
response shapes.
+181
View File
@@ -0,0 +1,181 @@
# Deployment
How nuntius runs in production: a single static binary behind Caddy, under
systemd, with its state in one data directory.
## Topology
```mermaid
flowchart LR
Browser[Visitor's browser] --> Caddy[Caddy :443]
Caddy -->|reverse_proxy /api/nuntius/*| Nuntius[nuntius :8080]
Systemd[Systemd unit] -.->|manages| Nuntius
Nuntius -->|SMTP PLAIN| SMTP[(SMTP provider)]
Nuntius -->|append JSONL| Disk[(/var/lib/nuntius/data)]
```
nuntius binds `[::]:8080` by default: the dual-stack wildcard that accepts
both IPv4 and IPv6 connections. The host part is the `server.bind` key in
`config.toml`; set it to `127.0.0.1` for a loopback-only listener, which is
the recommended shape behind a proxy.
### Reverse proxy
Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`):
```caddyfile
handle_path /api/nuntius/* {
reverse_proxy 127.0.0.1:8080
}
```
Then validate and reload:
```sh
sudo caddy validate
sudo systemctl reload caddy
```
Caddy overwrites `X-Forwarded-For` for untrusted clients, which is what the
`server.trust_proxy_headers` opt-in expects. Frontends on the same site
post to the same origin and need no extra URL; the per-form
`allowed_origins` list carries every origin that may post.
## Requirements
- A Linux or FreeBSD host with systemd and a C compiler-free runtime: the
binary is static, nothing else ships with it.
- The `nuntius` system user and group, the data directory
`/var/lib/nuntius`, the configuration at `/etc/nuntius/config.toml`, and
the environment file `/etc/nuntius/.env` (all created by the installer or
the manual steps below).
- Port 8080 locally, 443 publicly through Caddy.
- An SMTP account (any standards-compliant provider, such as Proton Mail or
Thundermail).
| File | Owner | Mode | Purpose |
|---|---|---|---|
| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary |
| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory and data root |
| `/var/lib/nuntius/data/` | `nuntius` | `0750` | JSONL logs and the rate-limit snapshot (created at runtime) |
| `/etc/nuntius/config.toml` | `root` | `0644` | The configuration (auto-generated, then edited) |
| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets, loaded via `EnvironmentFile=` |
| `/etc/systemd/system/nuntius.service` | `root` | `0644` | The systemd unit |
## Build
```sh
just build
```
Copy the artefacts to the server:
```sh
rsync -avz bin/nuntius nuntius.service .env.example scripts/install.pl user@your-server:/tmp/nuntius/
```
## Run
The installer does the whole sequence and is idempotent; re-running is
safe and it never starts the service, so you review the configuration
first:
```sh
ssh user@your-server
cd /tmp/nuntius
sudo perl install.pl
```
It creates the `nuntius` system user, `/var/lib/nuntius`, installs the
binary and the unit, generates the starter configuration, seeds
`/etc/nuntius/.env` from `.env.example`, and enables the service. Then
review and start:
```sh
sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD
sudo $EDITOR /etc/nuntius/config.toml # real SMTP settings and allowed origins
sudo systemctl start nuntius
sudo journalctl -u nuntius -f
```
Without the installer, the same steps by hand: create the user and
directory as above, `install -m 0755` the binary, `install -m 0644` the
unit, start the binary once to generate `/etc/nuntius/config.toml`,
`systemctl daemon-reload && systemctl enable --now nuntius`.
## Service unit
The unit lives at `nuntius.service` in the repository root and installs to
`/etc/systemd/system/nuntius.service`; the copy in this document would
drift, the pointer does not. It runs as the `nuntius` user with
`WorkingDirectory=/var/lib/nuntius` (so the default `data_dir = "./data"`
resolves to `/var/lib/nuntius/data`), loads `/etc/nuntius/.env`, validates
the configuration through `ExecStartPre=/usr/local/bin/nuntius
--check-config`, and restarts on failure. The service is enabled, not
started, after installation.
## Production configuration
The keys that differ from the defaults in production: `allowed_origins`
carries the real frontend origins, the `[forms.smtp]` block carries the
real host and credentials, `server.metrics_token` guards the metrics
endpoint when it is exposed, and `server.bind` stays `::` or moves to a
loopback address behind the proxy. Secrets live in `/etc/nuntius/.env` and
reach the configuration through `${VAR}` references; no secret value
belongs in `config.toml` or in this repository.
## Upgrade
```sh
cd nuntius
just build
rsync -avz bin/nuntius user@your-server:/tmp/nuntius/
ssh user@your-server 'sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius && sudo systemctl restart nuntius'
```
The service does not reload `config.toml`; after editing it, restart. A
broken configuration exits 1 and systemd does not keep it up, so fix the
reported key and start again. `just gates` before the new binary ships.
## Rollback
Reinstall the previous release's binary from the releases page and
restart; the configuration, the JSONL logs and the rate-limit snapshot
carry over untouched. Rehearsed exactly this far, and no further: there is
no automated rollback path. The reverse of the whole installation is
`systemctl disable --now nuntius`, removing the unit, the binary,
`/etc/nuntius` and `/var/lib/nuntius`, and deleting the user.
## Monitoring
A healthy instance answers the health endpoint and writes one JSON request
line per submission:
```sh
systemctl status nuntius
/usr/local/bin/nuntius --version
curl -s http://127.0.0.1:8080/health
journalctl -u nuntius -n 50 --no-pager
curl -i https://your-domain.example/api/nuntius/health
```
An end-to-end check through the proxy:
```sh
curl -X POST https://your-domain.example/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://your-domain.example" \
-d '{"name":"Test","email":"test@example.com","message":"hello there, this is a test message"}'
```
`{"ok": true}` plus a `request` line with `status=200` in the journal means
the deploy is live. Subscriber counts come from the log:
```sh
wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u
```
Back up `/etc/nuntius/config.toml`, `/etc/nuntius/.env` and
`/var/lib/nuntius/data/*.jsonl` with the usual jobs; the log is
append-only and trivially archivable.
+130
View File
@@ -0,0 +1,130 @@
# Development
How to work on nuntius.
## Prerequisites
- Go 1.27.1, the version recorded in `go.mod`, the newest stable release.
- [just](https://github.com/casey/just) for the recipes.
- gcc for `just gates`: the race detector needs cgo.
Nothing else. There is no Node, no Python, and no third-party Go module
beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres)
TOML parser.
## Setup
```sh
git clone https://sourcedock.dev/petrbalvin/nuntius.git
cd nuntius
just build
```
Configuration lives in TOML, read from `/etc/nuntius/config.toml` by
default; the `NUNTIUS_CONFIG` environment variable points the server at any
file you like, which keeps development free of root-owned paths:
```sh
export NUNTIUS_CONFIG=./config.toml
export NUNTIUS_SMTP_PASSWORD=admin # the placeholder the template references
just run # first start writes the starter config
```
The starter forms point at example.com addresses, so submissions return
`500 send_failed` until `config.toml` carries real SMTP settings; strict
environment expansion means every `${VAR}` the config references must be set
before startup.
## Recipes
Every recipe in the project's file, taken from the file itself:
| Recipe | What it does |
|--------|--------------|
| `just default` | prints the recipe list |
| `just build` | `CGO_ENABLED=0 go build -trimpath -buildvcs=true -ldflags "-s -w"` into `bin/nuntius` |
| `just test` | the suite with no test cache, then the 80 % coverage floor over `./internal/...` |
| `just race` | the same suite under the race detector |
| `just unit` | fast scoped run for iterating: cached, no race, no coverage |
| `just fuzz` | time-boxed fuzz of one target in one package |
| `just bench` | benchmarks |
| `just fmt` | `gofmt -w .` |
| `just fmt-check` | zero gofmt diff; prints nothing when everything is formatted |
| `just vet` | `go vet ./...` and `go fix -diff ./...` |
| `just gates` | build, fmt-check, vet, test, race: the definition of done, once per task |
| `just clean` | removes `bin/` and `coverage.out` |
| `just install` | builds, then copies the binary into `~/.local/bin` (override with `BINDIR`) |
| `just uninstall` | removes the installed binary |
| `just run` | `go run -buildvcs=true ./cmd/server` |
| `just dev` | the same as `run`: nuntius carries no watch or reload tool |
| `just coverage-html` | HTML coverage report from the gate's profile; an extension, not a gate |
The test, race, unit and fuzz recipes run under a cgroup memory fence, so a
runaway test dies at the ceiling instead of eating the machine.
## Running a single test
```sh
go test -run TestName ./internal/handler/
```
Add `-v` for the sub-test names, and `-race` when the change touches
concurrency. `-count=1` defeats the test cache when a result looks stale;
`just unit` keeps the cache on purpose, because a scoped iterating run wants
to be instant.
## Coverage
```sh
just test
go tool cover -func=coverage.out
```
The `total:` line is the number that matters, and it stays at 80 percent or
more. Coverage is measured over the logic packages only; `cmd/server` is
thin glue around them. For the HTML map:
```sh
just coverage-html
```
## Benchmarks
```sh
just bench
```
Benchmark on an idle machine, and compare only runs made in one process
against each other: runs in separate processes, or on a loaded machine,
differ by more than the effects being measured.
## Fuzzing
Two fuzz targets exist: `FuzzValidate` in `internal/contactform` and
`FuzzLoadConfig` in `internal/config`. They are exploration, never a gate;
time-box one explicitly:
```sh
just fuzz FuzzValidate ./internal/contactform 30s
```
## Debugging the build
```sh
go build -gcflags='-m' ./... # inlining decisions
go build -gcflags='-S' ./... # what the compiler generated
```
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners.
They are written by hand rather than through `just`, but they enforce the
same set of gates, so a green `just gates` locally is the fastest way to a
green pipeline. The per-workflow table is in
[CONTRIBUTING.md](../CONTRIBUTING.md).
## Releases
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`.
The tag drives the release workflow, which builds the assets and publishes
the notes it extracted from `CHANGELOG.md`.