commit 1b700f7975dd331a15e238e8c7044e3b463ce13b Author: Petr Date: Sat Jun 20 20:48:09 2026 +0200 feat: initialize nuntius project with full server implementation diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8310d60 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,43 @@ +# EditorConfig: https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{rb,erb,gemspec}] +indent_style = space +indent_size = 2 + +[{Gemfile,Rakefile}] +indent_style = space +indent_size = 2 + +[*.{js,html,css,vue}] +indent_style = space +indent_size = 2 + +[*.{yml,yaml,json,toml}] +indent_style = space +indent_size = 2 + +[*.{rs,cj}] +indent_style = space +indent_size = 4 + +[*.go] +indent_style = tab +indent_size = 4 + +[justfile] +indent_style = tab + +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a27a70f --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# nuntius — environment variables for runtime secrets. +# +# Copy this file to /etc/nuntius/.env on the server and fill in real values. +# Permissions: chmod 600 (readable only by root / the nuntius user). +# +# The systemd service (deploy/nuntius.service) loads this file via +# EnvironmentFile= and exports every variable to nuntius at startup. +# The JSON config substitutes ${VAR_NAME} references from this file +# before parsing, so secrets can stay out of config.json. + +# Required: SMTP password used by every form. Reference in config.json as +# ${NUNTIUS_SMTP_PASSWORD}. +NUNTIUS_SMTP_PASSWORD=change-me-to-your-smtp-password + +# Optional: override the listen port. Config.json `server.port` takes +# precedence; this is only useful if you want to flip the port without +# editing JSON. +# NUNTIUS_PORT=8080 + +# Optional: override the data directory for newsletter subscribers. +# Config.json `data_dir` takes precedence; this is only useful if you +# want to point storage elsewhere (e.g. a dedicated volume). +# NUNTIUS_DATA_DIR=/var/lib/nuntius/data diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..43c6870 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,167 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: codeberg-small + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: freebsd + goarch: amd64 + - goos: freebsd + goarch: arm64 + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - uses: https://data.forgejo.org/actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: Build + id: build + env: + REF: ${{ forgejo.ref }} + + run: | + set -euo pipefail + + VERSION="${REF##*/}" + + if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then + echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')" + exit 1 + fi + + VERSION_NO_V="${VERSION#v}" + echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT" + + LDFLAGS="-s -w -X codeberg.org/petrbalvin/nuntius/internal/version.Version=${VERSION}" + mkdir -p bin + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ + go build -ldflags "$LDFLAGS" \ + -o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \ + ./cmd/server + + - name: Upload artifact + uses: https://data.forgejo.org/actions/upload-artifact@v3 + with: + name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} + if-no-files-found: error + + release: + runs-on: codeberg-tiny + needs: build + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Download all artifacts + uses: https://data.forgejo.org/actions/download-artifact@v3 + with: + path: dist + + - name: Extract CHANGELOG section + env: + VERSION: ${{ forgejo.ref_name }} + run: | + set -euo pipefail + VERSION_NO_V="${VERSION#v}" + echo "Looking for CHANGELOG section: ${VERSION_NO_V}" + + sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \ + | sed '$d' \ + | tail -n +2 \ + > release-body.md + + if [ ! -s release-body.md ]; then + echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}" + echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD" + exit 1 + fi + + echo "Release body preview:" + head -n 20 release-body.md + + - name: Create release + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + run: | + set -euo pipefail + # JSON-escape the release body with sed: backslashes first, then + # quotes, tabs, and carriage returns, and finally fold newlines into + # \n. Wrap the result in quotes to form a JSON string. + BODY=$(sed -e 's/\\/\\\\/g' \ + -e 's/"/\\"/g' \ + -e 's/\t/\\t/g' \ + -e 's/\r//g' \ + release-body.md \ + | sed ':a;N;$!ba;s/\n/\\n/g') + BODY="\"${BODY}\"" + + response=$(curl -sS -w '\n%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST \ + "${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \ + -d "{ + \"tag_name\": \"${FORGEJO_REF_NAME}\", + \"name\": \"${FORGEJO_REF_NAME}\", + \"body\": ${BODY}, + \"draft\": false, + \"prerelease\": false + }") + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + echo "HTTP ${http_code}" + + if [ "$http_code" != "201" ]; then + echo "Failed to create release: ${body}" + exit 1 + fi + + RELEASE_ID=$(echo "$body" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') + echo "Created release ID=${RELEASE_ID}" + printf '%s' "${RELEASE_ID}" > release-id.txt + + - name: Upload assets + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + run: | + set -euo pipefail + RELEASE_ID=$(cat release-id.txt) + echo "Release ID: ${RELEASE_ID}" + + for binary in dist/nuntius-*/nuntius-*; do + [ -f "$binary" ] || continue + fname=$(basename "$binary") + echo "Uploading ${fname}..." + http_code=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + -X POST \ + --data-binary "@${binary}" \ + "${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${fname}") + echo " HTTP ${http_code}" + if [ "$http_code" != "201" ]; then + echo "Failed to upload ${fname}" + exit 1 + fi + done + + echo + echo "Release ${FORGEJO_REF_NAME} is live with the following assets:" + ls -lh dist/nuntius-*/ diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..0e7a31f --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,47 @@ +name: Test + +on: + push: + branches: [development] + pull_request: + branches: [development] + +jobs: + vet: + runs-on: codeberg-small + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - uses: https://data.forgejo.org/actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: gofmt + # `gofmt -l` prints the names of unformatted files. An empty + # output is the only acceptable result. + run: gofmt -l cmd internal pkg + + - name: go vet + run: go vet ./... + + test: + runs-on: codeberg-small + needs: vet + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - uses: https://data.forgejo.org/actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: go test -race + run: go test -race -count=1 ./... + + - name: go test ./examples + run: go build ./examples/... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cde7b9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build artifacts +bin/ +*.test +*.out + +# Local secrets and config +.env* +!.env.example +config.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9f9f486 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to **nuntius** are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] — 2026-06-20 + +First stable release of nuntius: a small, opinionated, modular contact form +backend written in Go. A single static binary, four form kinds out of the box, +SMTP delivery, a JSONL newsletter log, strict TOML configuration, and env-var +secrets. Its only dependency outside the standard library is the first-party +[interpres](https://codeberg.org/petrbalvin/interpres) TOML parser. + +### Added + +- **Server** — `cmd/server`, the HTTP entry point. `http.Server` with explicit + `ReadHeaderTimeout` (10 s), `ReadTimeout` (15 s), `WriteTimeout` (30 s), and + `IdleTimeout` (60 s). Graceful shutdown on `SIGINT` / `SIGTERM` with a 15 s + budget. Structured logging via `log/slog` (JSON handler, INFO level, stdout) + with one log line per request. `GET /health` endpoint. `--version` flag. +- **Four form kinds** — `contact`, `feedback`, `newsletter`, and `generic`, each + with its own endpoint, per-IP rate limit, CORS allowlist, and honeypot field. + One `POST` + one `OPTIONS` handler per form on a fresh `http.ServeMux`. +- **SMTP delivery** — stdlib `net/smtp` with `PLAIN` auth, `multipart/alternative` + HTML + plain text bodies, one dedicated template per form type. +- **Newsletter JSONL log** — append-only `data_dir/newsletter-.jsonl` with + crash-safe `O_APPEND` writes; `Count` and `List` skip malformed lines. +- **`pkg/contactform`** — public Go package with `Request` / `Response` / + `FieldError` / `ErrorResponse` types and a `Validate` function for all four + form types. +- **Per-IP rate limiting** — in-memory token bucket per form, configurable per + hour. +- **Per-form honeypot** — silent 200 on bot fill, no mail, no subscriber line. +- **Per-form CORS** — allowlist enforced on preflight and actual requests, with + echoed `Access-Control-Allow-Origin` + `Vary: Origin`. +- **TOML configuration** — parsed by the first-party `interpres` library, with + strict unknown-field rejection and per-type validation that fails loud on + typos at startup. +- **Env-var secrets** — `${VAR_NAME}` / `$VAR_NAME` expansion before the config + is parsed, so SMTP passwords never live in the file. +- **Auto-generated config** — a three-form TOML template is written to + `/etc/nuntius/config.toml` on first start. +- **systemd unit** — installed inline from `docs/deployment.md` with + `Restart=on-failure` and `EnvironmentFile=`. +- **Install script** — `install.sh`, idempotent, handles user creation, config + generation, and secrets file mode `0600`. +- **docs/** — `architecture.md`, `configuration.md`, `api-reference.md`, + `deployment.md`, `security.md`, `library-usage.md`. + +[1.0.0]: https://codeberg.org/petrbalvin/nuntius/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..34ec387 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,274 @@ +# Contributing + +Thank you for considering contributing to **nuntius** — a small, opinionated, modular contact form backend written in Go, with zero runtime dependencies and only the first-party `interpres` TOML module (no external third-party Go modules). + +## Branches and releases + +- **`main`** holds released code only. It is never committed to directly. +- **`development`** is the integration branch where work lands. + +Open pull requests against `development`. Releases follow +[Semantic Versioning](https://semver.org) and are cut by merging `development` +into `main` and pushing a `vX.Y.Z` tag to `main`; CI does not perform the merge. +See [Cutting a release](#cutting-a-release) for the full procedure. + +## Development Setup + +### Requirements + +- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`) +- [`just`](https://github.com/casey/just) command runner (the `justfile` is the source of truth for build / test / run / install / uninstall) +- Linux, macOS, or FreeBSD +- A local SMTP account for end-to-end testing. Tests in `internal/storage` and `pkg/contactform` do not require SMTP. + +### Quick Start + +```bash +git clone https://codeberg.org/petrbalvin/nuntius.git +cd nuntius +just install # go mod tidy + go mod download (no third-party modules — no-op for this project) +just build # → ./bin/nuntius (with -ldflags="-s -w") +just test # go test ./... +just run # go run ./cmd/server, listens on :8080 +``` + +The first `just run` writes a template `config.toml` to `/etc/nuntius/config.toml`. To run without root, point `config.ConfigPath` at a writable location or copy the template from `internal/config/config.go` and edit it. + +### Running Tests + +```bash +just test +``` + +The suite covers: + +- `pkg/contactform` — validators for all four form types (contact, feedback, newsletter, generic), whitespace trimming, and error message shape. +- `internal/storage` — `Append` / `Count` / `List` semantics, file and directory auto-creation, missing-file tolerance, malformed-line resilience. + +### Linting + +This project intentionally avoids third-party Go modules, so there is no `golangci-lint` step. CI enforces the two checks below — run them locally before opening a pull request: + +```bash +gofmt -l cmd internal pkg # must print nothing +go vet ./... +``` + +## Code Style + +- Follow [Effective Go](https://go.dev/doc/effective_go) and the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments). +- Keep lines under 100 characters where practical. +- Use `camelCase` for variables and functions, `PascalCase` for exported symbols. +- Prefix every file with a one-line comment describing its purpose. +- Use long flags / struct field names — no single-letter aliases. +- Prefer the Go standard library over external dependencies. **This project allows only stdlib plus the first-party `interpres` TOML library** — no other external imports. If you need a feature that is not in stdlib or `interpres`, implement it on top of stdlib or open an issue first. +- Error messages in English, lowercase, no trailing period, always wrapped with context (`fmt.Errorf("read %s: %w", path, err)`). +- Do not log SMTP credentials or full request bodies. + +### Formatting + +```bash +gofmt -w cmd internal pkg +``` + +There is no `make fmt` target because `gofmt` is the canonical formatter and is enforced by CI. + +### Testing + +- Write tests alongside the code in `_test.go` files. +- Use table-driven tests for multiple cases. +- Include both happy path and error path tests. +- Name tests following the Go convention: `TestFunctionName_Scenario`. +- Use `t.TempDir()` for any test that touches the filesystem; never assume a clean working directory. +- Network-dependent tests (if any are added in the future) must be guarded by a build tag or a `-short` check so the default `go test ./...` stays hermetic. + +## Commit Conventions + +We follow [Conventional Commits](https://www.conventionalcommits.org/). + +| Type | Use | +|------------|----------------------------------------------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code restructuring without behavior change | +| `docs` | Documentation changes | +| `test` | Adding or updating tests | +| `chore` | Maintenance, CI, tooling | +| `perf` | Performance improvement | +| `security` | Hardening without a user-visible fix | + +``` +feat: add generic form type with allow-list validation +fix: trim leading/trailing whitespace on every validated field +refactor: split handler.go into per-form files +docs: add architecture overview diagram +test: cover malformed-line resilience in newsletter store +chore: add go 1.26 to go.mod +``` + +- English, lowercase subject, imperative mood (`add`, not `added`). +- No trailing period, max 72 characters. +- No version numbers in commit messages — versions belong to tags. + +## Pull Request Flow + +1. Create a feature branch from `development`. +2. Make your changes with clear commit messages following Conventional Commits. +3. Ensure `gofmt -l cmd internal pkg` prints nothing and `go vet ./...` passes. +4. Ensure `just test` passes (`go test ./...`). +5. Add or update tests for your changes — both `pkg/contactform` and `internal/storage` are the canonical places for new tests; integration coverage belongs in `examples/` or in a new top-level `*_test.go` under `cmd/`. +6. Update documentation if the public API, configuration schema, or behavior changes: + - `README.md` for the high-level overview. + - `docs/configuration.md` for any new / changed config keys. + - `docs/api-reference.md` for any new / changed HTTP behaviour. + - `docs/architecture.md` for any structural change. +7. Open a pull request against `development` describing the change, the test plan, and any migration notes. Releasing is a separate step handled by the maintainer — see [Cutting a release](#cutting-a-release). + +## Project Structure + +``` +nuntius/ +├── cmd/ +│ └── server/ # CLI entry point (main.go): load config, build handler, run HTTP server +├── internal/ +│ ├── config/ # TOML config loading, env-var expansion, schema validation +│ ├── email/ # SMTP message composition (multipart/alternative) and delivery +│ ├── handler/ # HTTP routing: CORS, rate limit, honeypot, validate, send, persist +│ └── storage/ # Append-only JSONL newsletter subscriber store +├── pkg/ +│ └── contactform/ # Public, reusable: Request/Response/ErrorResponse types + Validate +├── examples/ +│ └── minimal/ # Standalone usage of pkg/contactform (no HTTP server) +├── docs/ # Architecture, configuration, API, deployment, security, library usage +├── install.sh # One-shot server installer (idempotent) +├── justfile # install, build, test, run, uninstall +├── go.mod # Go 1.26, no external dependencies +└── LICENSE # MIT +``` + +## Architecture Overview + +```mermaid +graph TD + Browser[Browser / Frontend]:::accent6 + Nginx[nginx reverse proxy]:::accent6 + CLI[cmd/server/main.go]:::accent0 + Config[internal/config/]:::accent7 + Handler[internal/handler/]:::accent0 + Validate[pkg/contactform/Validate]:::accent3 + Email[internal/email/FormSender]:::accent1 + Storage[internal/storage/NewsletterStore]:::accent1 + SMTP[(SMTP server)]:::accent2 + JSONL[(data_dir/newsletter-*.jsonl)]:::accent2 + + Browser -->|POST /api/nuntius/contact| Nginx + Nginx -->|forward with X-Forwarded-For| CLI + CLI --> Config + CLI --> Handler + Handler --> Validate + Handler --> Email + Handler --> Storage + Email -->|SMTP PLAIN| SMTP + Storage -->|append JSONL| JSONL + Handler -->|200 ok / 4xx / 5xx| Nginx + Nginx --> Browser + + classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff + classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff + classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000 + classDef accent3 fill:#A855F7,stroke:#7C3AED,color:#fff + classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff + classDef accent7 fill:#64748B,stroke:#475569,color:#fff +``` + +### Key Design Decisions + +- **One process, many forms** — A single nuntius process serves every form defined in `config.toml`. Each form has its own per-IP rate limit, CORS allowlist, honeypot, and (for `newsletter`) subscriber store. +- **Per-form SMTP credentials** — `email.FormSender` is bound to one form, so credentials never cross tenants inside the same process. +- **In-memory rate limiting** — Token-bucket per IP, per form. No Redis. Resets on restart; that is an acceptable trade-off for a small backend. +- **Honeypot is a positive control** — A bot that fills the hidden field gets a `200 ok` response. Humans are never blocked; bots are silently dropped without sending mail. +- **Append-only JSONL for subscribers** — One JSON object per line under `data_dir/`. Malformed lines are skipped on read so a partial write can never brick the file. No rotation is performed automatically — add logrotate yourself. +- **Env-var expansion before TOML parse** — `${NUNTIUS_SMTP_PASSWORD}` is substituted by `os.Expand` *before* the TOML is parsed by `interpres`, so the password never lands in the parsed struct as a literal — only its expanded value is held in memory. +- **Unknown fields are rejected** — `interpres.DisallowUnknownFields` catches typos at startup rather than silently ignoring them. +- **No global state outside the binary** — Everything that varies per form is owned by `ContactHandler` and the maps it builds in `New()`. There are no package-level singletons beyond the implicit `slog` default. + +## Testing Strategy + +| Layer | Approach | Tools | +|---|---|---| +| Unit tests | Table-driven tests for validators and per-form rate limiters | `testing` stdlib | +| Storage tests | `t.TempDir()`-scoped JSONL files, malformed-line fixtures | `testing` + `os` + `bufio` | +| HTTP layer | Implicit: the per-form `makeHandler` closure is a plain function; tests should be added by calling it with an `httptest.NewRecorder` if/when the surface grows | `net/http/httptest` | +| Race detection | `go test -race` is encouraged locally for changes touching `handler.go` and `storage` | `go test -race` | + +## CI + +This project uses **Forgejo Actions** for continuous integration. The two workflow files live under `.forgejo/workflows/` and are the source of truth for what runs on every push and on every tagged release. + +### Test workflow — `.forgejo/workflows/test.yml` + +Triggered on every push to `development` and on every pull request targeting `development`. `main` is excluded on purpose — it only ever receives release tags, and the release workflow builds the binaries itself. Three jobs run in sequence, all on the `codeberg-small` runner: + +| Job | What it does | Why it exists | +|---|---|---| +| `vet` | `gofmt -l cmd internal pkg` (must print nothing) and `go vet ./...` | Catches formatting drift and static-analysis issues before tests run. | +| `test` | `go test -race -count=1 ./...` plus `go build ./examples/...` | Race detector catches issues in the per-form rate limiter, the storage mutex, and the SMTP send path. `-count=1` disables the result cache. The example build is a smoke test for the public library. | +| `build` | `just build`, `just test`, and `./bin/nuntius --version` smoke test | Confirms the `justfile` build recipe works end-to-end and that `--version` still prints the expected output. | + +Run the same checks locally before opening a pull request: + +```bash +gofmt -l cmd internal pkg # must print nothing +go vet ./... +just test +just build +./bin/nuntius --version # sanity-check the new flag +``` + +### Release workflow — `.forgejo/workflows/release.yml` + +Triggered by pushing a tag that matches `v*.*.*` (e.g. `v1.0.0`, `v1.2.3`) onto `main`. The merge from `development` into `main` is the maintainer's responsibility and happens before the tag is pushed — this workflow does not merge anything. Two jobs: + +1. **`build`** — Runs on a `codeberg-small` runner in a 4-way matrix: `linux/amd64`, `linux/arm64`, `freebsd/amd64`, `freebsd/arm64`. The default `actions/checkout@v4` checks out the tagged commit (the trigger was a tag push), which is exactly the commit on `main` we want to ship. The version is baked in via `-ldflags`: + ```bash + -X codeberg.org/petrbalvin/nuntius/internal/version.Version=${VERSION#v} + ``` + The resulting binary is uploaded as a workflow artifact and smoke-tested with `--version` on the build host. +2. **`release`** — Runs on a `codeberg-tiny` runner (no Go toolchain needed). Downloads all artifacts, extracts the matching section from `CHANGELOG.md` for the tagged version, creates a release via the Forgejo REST API, and uploads each binary as a release asset. The release is **non-draft, non-prerelease**; if the CHANGELOG section is missing, the job fails fast so a release cannot ship without notes. + +### Cutting a release + +1. Bump `Version` in `internal/version/version.go`. +2. Add a new `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`. +3. Make sure CI is green on `development` (pushes and PRs against `development` are what the test workflow runs on). +4. Merge `development` into `main` (e.g. open a PR on Forgejo, or `git checkout main && git merge --no-ff development && git push origin main`). This is the **only** step where `main` is updated outside of a tag push. +5. Tag the `main` tip and push the tag: + ```bash + git tag -a vX.Y.Z -m "nuntius vX.Y.Z" + git push origin main + git push origin vX.Y.Z + ``` +6. The release workflow builds the four platform binaries and creates the release with the CHANGELOG section as the body. No CI step performs the merge — that already happened in step 4. + +If the release job fails, the tag can be deleted and re-pushed; the workflow re-runs on the new tag push. Do not re-tag an already-released commit — the Forgejo API rejects duplicate release tag names. + +## Adding a New Form Type + +The four current types (`contact`, `feedback`, `newsletter`, `generic`) live in `internal/config.ValidFormTypes`. To add one: + +1. Add the new type to `ValidFormTypes` in `internal/config/config.go`. +2. Add a `validateXxx` function in `pkg/contactform/validate.go` and dispatch it from `Validate`. +3. Add an `emailTemplateXxx` and a `compose()` branch in `internal/email/sender.go`. +4. Add tests in `pkg/contactform/validate_test.go`. +5. Update `docs/configuration.md` (form type table) and `docs/api-reference.md` if the public surface changes. + +## Report a Bug + +Open an issue at with: + +- nuntius version (`./bin/nuntius --version`). +- Operating system and architecture. +- The exact `curl` / frontend snippet that reproduces it. +- Expected vs actual behaviour, and the relevant slice of `journalctl -u nuntius`. + +For **security issues**, please email **opensource@petrbalvin.org** rather than opening a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f83dd2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef3ae94 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Nuntius — A Small, Opinionated Contact Form Backend + +[![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE) +[![Go](https://img.shields.io/badge/Go-00ADD8?labelColor=00ADD8&logo=go&logoColor=fff)](https://go.dev) +[![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=000)](https://kernel.org) +[![FreeBSD](https://img.shields.io/badge/FreeBSD-AB2B28?logo=freebsd&logoColor=fff)](https://freebsd.org) + +> Latin *nuntius* = "messenger" — the service that carries messages from visitors to you. + +**nuntius** is a small, modular contact form backend written in Go. It serves multiple JSON endpoints (contact, feedback, newsletter, generic) over a single static binary, delivers submissions via SMTP, optionally persists newsletter signups to a JSONL log, and is designed to sit behind nginx on a tiny Linux server. Only the first-party `interpres` TOML module, no external third-party Go modules, ~6 MB binary. + +## Features + +- **Single static binary** — `go build -ldflags="-s -w"` produces ≈ 6 MB; no Node, no Python, no Redis, no Postgres, no third-party Go packages. +- **Four form kinds out of the box** — `contact`, `feedback`, `newsletter`, and `generic`. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field. +- **SMTP delivery** — Go stdlib `net/smtp` with `PLAIN` auth. Works with any provider that exposes SMTP. +- **Newsletter subscribers on disk** — append-only JSONL log under `data_dir/`. No database, no double opt-in. One `{email, ip, form, created_at}` line per signup. +- **Server-side validation** — `pkg/contactform.Validate` is reusable; name, email (RFC 5322), service allow-list (for `contact`), and message length. +- **Reusable package** — `pkg/contactform` exports typed `Request` / `Response` / `ErrorResponse` plus a `Validate` function you can drop into any Go project. +- **Structured logging** — `log/slog` with JSON output to stdout; plays well with journald, Loki, Datadog… +- **Graceful shutdown** — SIGINT/SIGTERM close in-flight requests within 15 s. +- **Rate limiting** — token-bucket per IP, configurable per hour, in-memory (no Redis required). +- **Honeypot field** — invisible to humans, required for spam bots. Silently accepts and drops. +- **Config validation** — unknown fields and unknown form types are rejected at startup, so typos fail loudly instead of silently disabling a form. +- **Env-var secrets** — `${VAR_NAME}` / `$VAR_NAME` expansion in the TOML file keeps SMTP passwords out of disk. + +## Quick Start + +```bash +# 1. Clone +git clone https://codeberg.org/petrbalvin/nuntius.git +cd nuntius + +# 2. Build +just build +# → ./bin/nuntius + +# 3. Run (auto-creates /etc/nuntius/config.toml with a 3-form template on first start) +sudo mkdir -p /etc/nuntius +just run +# → "nuntius starting" addr=:8080 forms=3 + +# 4. Edit the generated config to point at your SMTP and recipient +sudo $EDITOR /etc/nuntius/config.toml +just run # restart to pick up changes +``` + +A default `config.toml` is auto-generated at `/etc/nuntius/config.toml` on first run with three sample forms (contact, feedback, newsletter). Edit it before exposing the service. + +The full schema lives in [`docs/configuration.md`](docs/configuration.md); the deploy guide is in [`docs/deployment.md`](docs/deployment.md). + +## Usage + +### Test the endpoints + +```bash +# 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." + }' + +# Feedback (no service field) +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." + }' + +# Newsletter (email only) +curl -X POST http://localhost:8080/api/nuntius/newsletter \ + -H "Content-Type: application/json" \ + -H "Origin: https://petrbalvin.org" \ + -d '{ "email": "jane@example.com" }' +``` + +Successful response (all three): + +```json +{ "ok": true } +``` + +Validation error (e.g. newsletter with bad email): + +```json +{ + "error": "validation", + "details": [ + { "field": "email", "message": "email is invalid" } + ] +} +``` + +### Frontend integration + +If you proxy `/api/nuntius/*` through nginx (recommended, see [`docs/deployment.md`](docs/deployment.md)), no frontend config is needed — the form posts to the same origin as the page. Otherwise, set `VITE_NUNTIUS_URL` to the upstream origin: + +```js +await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/contact`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.value, + email: email.value, + service: service.value, + message: message.value, + }), +}); +``` + +### Use the package in your own project + +`pkg/contactform` is a small, dependency-free Go package that you can import into any project: + +```go +import "codeberg.org/petrbalvin/nuntius/pkg/contactform" + +req := &contactform.Request{ + Name: "Jane", + Email: "jane@example.com", + Service: "architecture", + Message: "Hello!", +} + +if errs := contactform.Validate(req, "contact"); len(errs) > 0 { + // handle validation errors +} +``` + +Full library example: [`docs/library-usage.md`](docs/library-usage.md). Full HTTP API: [`docs/api-reference.md`](docs/api-reference.md). + +## Configuration (TL;DR) + +Default path: `/etc/nuntius/config.toml`. A starter file is written automatically on first start. + +```toml +data_dir = "./data" + +[server] +port = 8080 + +[[forms]] +name = "contact" +type = "contact" +path = "/api/nuntius/contact" +to = "you@example.com" +from = "noreply@example.com" +rate_limit_per_hour = 10 +honeypot_field = "website" +allowed_origins = ["https://example.com"] + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "noreply@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" +``` + +Form types and full schema: [`docs/configuration.md`](docs/configuration.md). Secrets via env vars, CORS, honeypot, rate limit, and validation rules are all documented there. + +## Requirements + +- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`) +- An **SMTP** account (Yandex, Mailgun, Postmark, your own Postfix, …) +- **No other runtime dependencies.** No Node, no Python, no Redis, no Postgres. Configuration is TOML parsed via the first-party [`interpres`](https://codeberg.org/petrbalvin/interpres) library; newsletter storage is an append-only JSONL file via `bufio`. + +## Development + +```bash +just install # go mod tidy + go mod download (no-op for this project, kept for symmetry) +just build # → ./bin/nuntius (with -s -w) +just test # go test ./... +just run # go run ./cmd/server +just uninstall # rm -rf ./bin +``` + +All four `just` recipes are mandatory per the project conventions and produce zero errors and zero warnings. + +## Documentation + +- [`docs/architecture.md`](docs/architecture.md) — layers, request flow, design decisions +- [`docs/configuration.md`](docs/configuration.md) — full config schema, form types, secrets, newsletter log +- [`docs/api-reference.md`](docs/api-reference.md) — HTTP verbs, status codes, `GET /health` +- [`docs/deployment.md`](docs/deployment.md) — production systemd, nginx, updates +- [`docs/security.md`](docs/security.md) — threat model, CORS, rate limiting, secret handling +- [`docs/library-usage.md`](docs/library-usage.md) — `pkg/contactform` API and standalone use + +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit conventions, and the pull request flow. + +## Security + +For a full threat model, see [`docs/security.md`](docs/security.md). The short version: every form has its own per-IP rate limit, CORS allowlist, and honeypot field; SMTP credentials never touch the log; unknown config fields are rejected at startup. + +If you find a security issue, please email **opensource@petrbalvin.org** rather than opening a public issue. + +## License + +MIT — see [LICENSE](./LICENSE). +Copyright © 2026 [Petr Balvín](https://petrbalvin.org) diff --git a/assets/nuntius-icon.svg b/assets/nuntius-icon.svg new file mode 100644 index 0000000..f96c049 --- /dev/null +++ b/assets/nuntius-icon.svg @@ -0,0 +1,188 @@ + + + nuntius + A circular messenger seal: gold rim with engraved dots, deep blue starfield, and a cream envelope sealed with red wax bearing the letter N. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + N + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nuntius-logo.svg b/assets/nuntius-logo.svg new file mode 100644 index 0000000..53e3481 --- /dev/null +++ b/assets/nuntius-logo.svg @@ -0,0 +1,96 @@ + + + nuntius + Horizontal logo: a small circular seal with a sealed envelope on the left, the wordmark "nuntius" and the tagline "messenger" on the right. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + N + + + + + + + + nuntius + + + + + + + + messenger + + diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..e16be72 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,160 @@ +//go:build linux || freebsd + +// Command nuntius runs the contact form backend server. +// +// Configuration is loaded from a TOML file (default: /etc/nuntius/config.toml). +// The TOML file may reference environment variables for secrets using +// ${VAR_NAME} or $VAR_NAME syntax. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "codeberg.org/petrbalvin/nuntius/internal/config" + "codeberg.org/petrbalvin/nuntius/internal/handler" + "codeberg.org/petrbalvin/nuntius/internal/version" +) + +func main() { + showVersion := flag.Bool("version", false, "Print version and exit") + flag.Parse() + if *showVersion { + fmt.Printf("%s version %s\n", version.Name, version.Version) + return + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + cfgPath := config.ConfigPath() + cfg, err := config.Load(cfgPath) + if err != nil { + logger.Error("config load failed", "path", cfgPath, "err", err) + os.Exit(1) + } + + h := handler.New(cfg) + + mux := http.NewServeMux() + h.Register(mux) + + srv := &http.Server{ + Addr: ":" + itoa(cfg.Server.Port), + Handler: withRequestLog(mux, logger), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + logger.Info("nuntius starting", + "version", version.Name+" "+version.Version, + "addr", srv.Addr, + "config", cfgPath, + "forms", len(cfg.Forms), + ) + for _, f := range cfg.Forms { + logger.Info("form registered", + "name", f.Name, + "path", f.Path, + "to", f.To, + "smtp", f.SMTP.Host, + ) + } + + // Graceful shutdown. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + logger.Info("shutdown signal received") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown error", "err", err) + os.Exit(1) + } + logger.Info("nuntius stopped cleanly") +} + +// withRequestLog logs each HTTP request method, path, status, and duration. +func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r) + logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rw.status, + "duration_ms", time.Since(start).Milliseconds(), + "ip", clientIP(r), + ) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func clientIP(r *http.Request) string { + host := r.RemoteAddr + if i := last(host, ':'); i >= 0 { + return host[:i] + } + return host +} + +func last(s string, c byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == c { + return i + } + } + return -1 +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..085f38a --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,271 @@ +# 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 + +```http +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 + +```http +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 + +```http +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 + +```json +{ "ok": true } +``` + +### Validation error + +```json +{ + "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 + +```json +{ "error": "invalid_json", "message": "Could not parse JSON body." } +``` + +### CORS rejection + +```json +{ "error": "origin_not_allowed" } +``` + +### Rate limit + +```json +{ "error": "rate_limited", "message": "Too many requests, please try again later." } +``` + +### SMTP failure + +```json +{ "error": "send_failed", "message": "Could not send email." } +``` + +### Storage failure (newsletter only) + +```json +{ "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: + + ```http + Access-Control-Allow-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) + +Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` so the bucket sees the real client IP. + +## Health Check + +### `GET /health` + +```http +GET /health HTTP/1.1 +``` + +```http +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 + +```bash +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 + +```bash +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 + +```bash +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) + +```bash +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 + +```bash +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 + +```bash +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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3610c41 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,239 @@ +# Architecture + +**nuntius** is a small HTTP service that accepts JSON form submissions, validates them, sends them via SMTP, and (for `newsletter` forms) appends the email to a JSONL log on disk. This document explains the layers, the request flow, and the design decisions that keep the binary ~6 MB and the dependency graph empty. + +## High-Level Overview + +```mermaid +graph TD + Browser[Browser / Frontend]:::accent6 + Nginx[nginx reverse proxy]:::accent6 + Server[cmd/server/main.go]:::accent0 + Config[internal/config/]:::accent7 + Handler[internal/handler/]:::accent0 + RateLimit[rateLimiter per form]:::accent7 + CORS[CORS allowlist per form]:::accent7 + Honeypot[honeypot_field per form]:::accent7 + Validate[pkg/contactform/Validate]:::accent3 + Sender[internal/email/FormSender]:::accent1 + Storage[internal/storage/NewsletterStore]:::accent1 + SMTP[(SMTP server)]:::accent2 + JSONL[(data_dir/newsletter-*.jsonl)]:::accent2 + + Browser -->|POST + Origin| Nginx + Nginx -->|X-Forwarded-For| Server + Server --> Config + Server --> Handler + Handler --> RateLimit + Handler --> CORS + Handler --> Honeypot + Handler --> Validate + Handler --> Sender + Handler --> Storage + Sender -->|SMTP PLAIN over net/smtp| SMTP + Storage -->|append JSONL| JSONL + Handler -->|200 / 4xx / 5xx| Nginx + Nginx --> Browser + + classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff + classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff + classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000 + classDef accent3 fill:#A855F7,stroke:#7C3AED,color:#fff + classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff + classDef accent7 fill:#64748B,stroke:#475569,color:#fff +``` + +## Layers + +### 1. Entry point — `cmd/server/main.go` + +- Wires up a JSON `slog` handler on stdout. +- Loads the config from `config.ConfigPath()` (default `/etc/nuntius/config.toml`). +- Builds a `*handler.ContactHandler` via `handler.New(cfg)`. +- Registers one `POST` + one `OPTIONS` handler per form on a fresh `http.ServeMux`, plus `GET /health`. +- Wraps the mux in a request-logging middleware that emits a single `slog.Info` per request with method, path, status, duration, and client IP. +- Starts an `http.Server` with explicit read / write / idle timeouts. +- Installs a `signal.NotifyContext` listener for SIGINT / SIGTERM and shuts the server down gracefully within 15 s. + +The main package owns **no business logic** — it only orchestrates the four internal packages. + +### 2. Configuration — `internal/config/` + +The config package is the gatekeeper for everything that varies per deployment. + +| Concern | Where | +|---|---| +| File path | `config.ConfigPath()` returns `/etc/nuntius/config.toml` | +| Default file | `defaultConfig` is a `[]byte` constant embedded in the source; written on first start | +| Env-var expansion | `os.Expand(string(raw), os.Getenv)` runs **before** TOML is parsed by `interpres` | +| Strict decoding | `interpres.NewDecoder(...).DisallowUnknownFields()` rejects typos | +| Schema validation | `(*Config).validate()` enforces required fields, unique paths, valid form types, sensible defaults | +| Defaults | `port = 8080`, `data_dir = "./data"`, `rate_limit_per_hour = 10`, `honeypot_field = "website"`, `from = smtp.user` | + +The `Form` type mirrors the TOML shape one-to-one. `ValidFormTypes` is the authoritative allow-list — adding a form type means editing one map in this package. + +### 3. HTTP handler — `internal/handler/` + +`ContactHandler` is a single struct with four maps keyed by URL path: + +```go +type ContactHandler struct { + forms map[string]*config.Form + senders map[string]*email.FormSender + rateLimits map[string]*rateLimiter + stores map[string]*storage.NewsletterStore +} +``` + +`New(cfg)` builds all four maps in one pass. `Register(mux)` then mounts `POST ` and `OPTIONS ` per form, plus `GET /health`. + +The per-form handler closure (`makeHandler`) runs this pipeline in order: + +```mermaid +sequenceDiagram + participant Client + participant Handler as ContactHandler + participant CORS + participant Limiter as rateLimiter + participant Body + participant Honey as Honeypot + participant Validate as contactform.Validate + participant Sender as email.FormSender + participant Store as storage.NewsletterStore + + Client->>Handler: POST /api/nuntius/contact + alt preflight + Handler->>CORS: formAllowed(origin)? + CORS-->>Handler: yes / no + Handler-->>Client: 204 No Content (or 403) + else actual + Handler->>CORS: formAllowed(origin)? + CORS-->>Handler: yes / no + alt origin present and not allowed + Handler-->>Client: 403 origin_not_allowed + end + Handler->>Limiter: allow(ip) + alt over limit + Handler-->>Client: 429 rate_limited + end + Handler->>Body: json.Decode + alt invalid JSON + Handler-->>Client: 400 invalid_json + end + Handler->>Honey: honeypot field non-empty? + alt bot + Handler-->>Client: 200 ok (silent) + end + Handler->>Validate: contactform.Validate(req, form.Type) + alt errors + Handler-->>Client: 400 validation + details + end + Handler->>Sender: Send(req) + alt SMTP error + Handler-->>Client: 500 send_failed + end + opt form.Type == "newsletter" + Handler->>Store: Append(Subscriber{...}) + alt write error + Handler-->>Client: 500 storage_failed + end + end + Handler-->>Client: 200 ok + end +``` + +Notable behaviors: + +- **CORS is path-aware** — Each form has its own `allowed_origins` list; preflights and actual requests are checked against the form being hit, not the host. +- **Client IP honours reverse proxies** — `clientIP` reads `X-Forwarded-For` (first hop), then `X-Real-IP`, then falls back to `RemoteAddr`. Strip the port by hand because the stdlib does not give us a `netip.AddrPort` for the remote. +- **Rate limiting is in-process** — `rateLimiter` is a per-IP token bucket. The bucket refills at `perHour/3600` tokens per second. The map is guarded by a single `sync.Mutex`; in practice the lock is held for microseconds and the access pattern is dominated by `Allow` lookups, not insert / delete. +- **Honeypot is a positive control** — A bot that fills the invisible field gets a 200 with no email sent and no log line. Humans are never blocked. +- **Validation is the same function the public library uses** — `contactform.Validate` is the only validator in the codebase; the handler just calls it with `form.Type` and forwards the returned `[]FieldError` to the client. + +### 4. Email — `internal/email/` + +Each form gets a `FormSender` constructed once in `handler.New`. `Send(req)` is the only public method: + +1. `smtp.PlainAuth("", user, password, host)` — the empty identity means "use the supplied user as-is", which is what every modern SMTP provider expects. +2. `compose(from, to, req, formName, formType)` builds a `multipart/alternative` message (text/plain + text/html). +3. `smtp.SendMail(addr, auth, from, []string{to}, msg)`. + +`compose` picks one of four HTML templates (`contact`, `feedback`, `newsletter`, `generic`) and four matching plain-text subjects. The subject for `contact` includes the `[]` tag if `req.Service` is set, so inbox filters can route by service interest. + +Credentials are never logged — neither the password nor the full `From` / `To` headers appear in any `slog` line. The handler logs `form`, `path`, `service` (if any), and `ip` only. + +### 5. Storage — `internal/storage/` + +`NewsletterStore` is a thin wrapper over an append-only JSONL file: + +| Method | Semantics | +|---|---| +| `Append(Subscriber)` | Creates the parent dir if missing, opens the file with `O_APPEND \| O_CREATE \| O_WRONLY`, writes one JSON line + `\n`, closes. Sets `CreatedAt = time.Now().UTC()` if zero. | +| `Count()` | Streams the file with a `bufio.Scanner` (1 MB max line), increments on each line that unmarshals into a non-empty `Subscriber`. Malformed lines are skipped. | +| `List()` | Same scan, returns all valid subscribers in insertion order. Reads the entire log into memory — do not call on multi-million-row files. | +| `Path()` | Returns the configured file path. | + +A single `sync.Mutex` guards all three methods. The store is safe for concurrent use from the handler goroutines. + +There is **no rotation**. Add a `logrotate` unit if the file grows beyond what you want to `wc -l`. + +## Request Flow (single form) + +```mermaid +sequenceDiagram + participant Browser + participant Nginx + participant Nuntius as nuntius + participant SMTP + participant Disk + + Browser->>Nginx: POST /api/nuntius/contact + Nginx->>Nuntius: forward (X-Forwarded-For, X-Real-IP) + Nuntius->>Nuntius: CORS check + Nuntius->>Nuntius: rate-limit per IP + Nuntius->>Nuntius: parse JSON + Nuntius->>Nuntius: honeypot check + Nuntius->>Nuntius: contactform.Validate + Nuntius->>SMTP: SMTP PLAIN auth + multipart message + SMTP-->>Nuntius: 250 OK + opt newsletter form + Nuntius->>Disk: append one JSONL line + end + Nuntius-->>Nginx: 200 {"ok": true} + Nginx-->>Browser: 200 {"ok": true} +``` + +## Config Merging + +There is no merging in nuntius — the TOML file is the only source of runtime configuration. The file itself goes through three stages at startup: + +1. **Default generation** — If the file does not exist, `os.WriteFile(path, defaultConfig, 0644)` writes the embedded three-form template. +2. **Env-var expansion** — `os.Expand` substitutes `${VAR}` and `$VAR` references **in the raw text** before parsing. The parsed struct never holds a literal like `"${NUNTIUS_SMTP_PASSWORD}"` — it holds the expanded value (or an empty string if the env var was unset). +3. **Strict parsing + validation** — `interpres.NewDecoder(...).DisallowUnknownFields()` rejects typos; `validate()` enforces required fields, unique paths, valid form types, and applies defaults. + +The order matters: expansion before parsing means env-var references work even for values that are not strings (e.g. an integer with a `${PORT}` reference would fail, but you can still reference strings). + +## Error Handling + +Errors are returned as a typed string in the JSON body, with the HTTP status code doing the heavy lifting: + +| Status | Body shape | When | +|---|---|---| +| `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) | +| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail) | +| `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` | +| `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 | + +The `message` field is intentionally generic; field-level details for validation errors live in `details[]`. None of the error bodies leak SMTP credentials, file paths, or stack traces. + +## Why These Choices + +- **stdlib only** — `net/smtp`, `net/http`, `net/mail`, `log/slog`, `bufio`, `os/signal`, `sync` plus the first-party `interpres` TOML library are all that is needed. The build is reproducible, the binary is small, and the external supply chain is empty. +- **No global state outside the binary** — `ContactHandler` owns the maps; `slog` is the only package-level default. Everything else is constructed explicitly in `New(cfg)` so the handler is easy to instantiate from tests. +- **Append-only JSONL** — Cheaper to implement than SQLite, easier to inspect than a custom binary format, easy to back up, and resilient to partial writes. +- **Per-form isolation** — One form's SMTP credentials never travel through another form's code path. A misconfigured `contact` form cannot leak credentials from a `newsletter` form. +- **Strict config parsing** — Catching `forms[0].smtp.port = "587"` (string) or `forms[0].type = "contatc"` (typo) at startup is worth the small extra error-path code. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f5debc4 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,191 @@ +# Configuration Reference + +All runtime configuration for nuntius lives in a single TOML file. The default path is `/etc/nuntius/config.toml`; if the file does not exist when the server starts, nuntius writes a three-form template there. The parser rejects unknown fields and unknown form types so typos fail loudly at startup instead of silently disabling a form. + +## Quick Reference + +```toml +data_dir = "./data" + +[server] +port = 8080 + +[[forms]] +name = "contact" +type = "contact" +path = "/api/nuntius/contact" +to = "you@example.com" +from = "noreply@example.com" +rate_limit_per_hour = 10 +honeypot_field = "website" +allowed_origins = ["https://example.com"] + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "noreply@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" +``` + +## Top-Level Fields + +| Field | Type | Required | Default | Description | +|---|---|:---:|---|---| +| `server` | table | | `{"port": 8080}` | Server-wide settings | +| `server.port` | int | | `8080` | HTTP listen port | +| `data_dir` | string | | `"./data"` | Directory for newsletter JSONL logs; created on first write | +| `forms` | array | ✅ | — | One or more form definitions (must contain at least one) | + +> **Note:** Path resolution for `data_dir` is relative to the process's current working directory. The systemd unit installed in `docs/deployment.md` sets `WorkingDirectory=/var/lib/nuntius`, so the default `data_dir: "./data"` resolves to `/var/lib/nuntius/data/` in production. + +## Form Fields + +| Field | Type | Required | Default | Description | +|---|---|:---:|---|---| +| `name` | string | ✅ | — | Internal identifier. Appears in logs and in `data_dir/newsletter-.jsonl`. | +| `path` | string | ✅ | — | HTTP path the form is served on (e.g. `/api/nuntius/contact`). Must start with `/`. Must be unique across all forms. | +| `type` | string | | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`. See [Form types](#form-types). | +| `smtp` | table | ✅ | — | SMTP connection settings. See [SMTP fields](#smtp-fields). | +| `to` | string | ✅ | — | Recipient address (`To:` header). | +| `from` | string | | `smtp.user` | Sender address (`From:` header). | +| `rate_limit_per_hour` | int | | `10` | Submissions per IP per hour. `0` disables. | +| `honeypot_field` | string | | `"website"` | Name of the invisible form field. Empty string disables the honeypot. | +| `allowed_origins` | []string | | `[]` | CORS allowlist. One entry per origin (e.g. `https://example.com`). | + +## SMTP Fields + +| Field | Type | Required | Default | Description | +|---|---|:---:|---|---| +| `smtp.host` | string | ✅ | — | SMTP server hostname | +| `smtp.port` | int | ✅ | — | SMTP port; `587` for STARTTLS, `465` for implicit TLS (Go's `net/smtp` does both via `SendMail`) | +| `smtp.user` | string | ✅ | — | SMTP auth user (typically the `from` address) | +| `smtp.password` | string | ✅ | — | SMTP auth password. See [Secrets](#secrets-via-environment-variables). | + +## Form Types + +| Type | Required request fields | Email subject | Notes | +|---|---|---|---| +| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/] Contact form submission`, or `[nuntius/][] ...` if `service` is set | The default. `service` is restricted to an allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`. | +| `feedback` | `name`, `email`, `message` | `[nuntius/] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. | +| `newsletter` | `email` | `[nuntius/] New newsletter subscriber` | Also writes the email to disk as a JSONL line in `data_dir/newsletter-.jsonl`. No double opt-in. | +| `generic` | `name`, `email`, `message` | `[nuntius/] New submission` | Escape hatch for one-off forms that do not fit the other three. | + +All four types are rate-limited, CORS-checked, and honeypot-protected **independently** — each form has its own per-IP bucket, allowlist, and honeypot field. `newsletter` is the only type that writes to disk; the other three only send mail. + +### Service allow-list (for `contact`) + +The `service` field on `contact` is restricted to a fixed list (defined in `pkg/contactform/validate.go`): + +| Value | Use | +|---|---| +| `architecture` | Architecture / system design | +| `ai` | AI / ML engagements | +| `infrastructure` | Infrastructure / DevOps / SRE | +| `software` | Software engineering | +| `unix` | Unix / Linux tooling | +| `other` | Anything else | + +An empty string is also valid (skip the field). Anything else triggers a `400 validation` error with `details[0].field = "service"`. To add a new value, edit `validServices` in `pkg/contactform/validate.go` and update this table. + +## Validation Rules + +The `pkg/contactform.Validate` function enforces the following limits (in runes, not bytes): + +| Field | Min | Max | Validator | +|---|:---:|:---:|---| +| `name` | 2 | 100 | `utf8.RuneCountInString` after `strings.TrimSpace` | +| `email` | — | — | `net/mail.ParseAddress` (RFC 5322) after `strings.TrimSpace` | +| `service` | — | — | allow-list (see above) | +| `message` | 10 | 5,000 | `utf8.RuneCountInString` after `strings.TrimSpace` | + +Whitespace is trimmed from `name`, `email`, `service`, and `message` before validation. The trimmed values are what the email body sees. + +## Newsletter Subscribers + +For every accepted `newsletter` submission, nuntius appends one JSON line to `data_dir/newsletter-.jsonl`: + +```json +{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"} +``` + +| Field | Description | +|---|---| +| `email` | Trimmed, lowercased-on-output by your downstream tool — nuntius does not normalize | +| `ip` | Client IP as seen by nuntius (honours `X-Forwarded-For` / `X-Real-IP`) | +| `form` | The `name` of the form the signup hit | +| `created_at` | UTC timestamp set by nuntius at `Append` time | + +The log is **append-only** and **survives restarts**. It is **not rotated automatically** — add a `logrotate` unit (or equivalent) if it grows. Malformed lines are skipped on read, so a partial write can never brick the file. + +Quick shell recipes: + +```bash +# Count subscribers +wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl + +# Extract just the emails +jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl + +# Extract unique emails sorted +jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u + +# Signups per day +jq -r .created_at[:10] /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort | uniq -c +``` + +## Secrets via Environment Variables + +The TOML file supports `${VAR_NAME}` and `$VAR_NAME` expansion **before parsing**, so secrets can stay out of the file. Example: + +```toml +[forms.smtp] +host = "smtp.example.com" +port = 587 +user = "noreply@example.com" +password = "${NUNTIUS_SMTP_PASSWORD}" +``` + +The `NUNTIUS_SMTP_PASSWORD` env var is read at startup and substituted into the TOML before it is parsed. If the env var is unset or empty, an empty string is substituted — so make sure the variable is set in the process environment (systemd `EnvironmentFile=`, container secrets, etc.). SMTP credentials are never logged. + +Recommended production setup: + +```bash +# /etc/nuntius/.env (mode 0600, owner root:nuntius) +NUNTIUS_SMTP_PASSWORD=actual-app-password +``` + +```ini +# /etc/systemd/system/nuntius.service +[Service] +EnvironmentFile=/etc/nuntius/.env +``` + +The `install.sh` helper script sets the file mode automatically. + +## Defaults at a Glance + +When a key is missing from the TOML, nuntius applies the following defaults: + +| Key | Default | Source | +|---|---|---| +| `server.port` | `8080` | `(*Config).validate()` | +| `data_dir` | `"./data"` | `(*Config).validate()` | +| `forms[i].type` | `"contact"` | `(*Config).validate()` | +| `forms[i].from` | `forms[i].smtp.user` | `(*Config).validate()` | +| `forms[i].rate_limit_per_hour` | `10` | `(*Config).validate()` | +| `forms[i].honeypot_field` | `"website"` | `(*Config).validate()` | + +Setting `rate_limit_per_hour` to `0` explicitly disables rate limiting for that form (use with care — there is no global safety net). + +## Validation Errors at Startup + +If the config file is malformed, nuntius exits 1 and writes a single JSON line to stderr. Examples: + +```text +{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"parse /etc/nuntius/config.toml: interpres: unknown field \"smtp_ost\""} +{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": type \"contatc\" is not supported (must be one of: contact, feedback, newsletter, generic)"} +{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": path must start with /"} +{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"feedback\": duplicate path \"/api/nuntius/contact\" (also used by \"contact\")"} +``` + +Fix the reported line and re-run; the server will not start with a broken config. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..cfdb002 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,254 @@ +# Production Deployment + +This guide covers installing nuntius on a Linux server with `nginx`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes. + +## Architecture in Production + +```mermaid +graph LR + Browser[Visitor's browser]:::accent6 + Internet((Internet)):::accent2 + Nginx[nginx :443]:::accent0 + Nuntius[nuntius :8080]:::accent1 + Systemd[systemd unit]:::accent7 + SMTP[(SMTP provider)]:::accent2 + Disk[(/var/lib/nuntius/data/*.jsonl)]:::accent2 + + Browser --> Internet + Internet --> Nginx + Nginx -->|proxy_pass /api/nuntius/| Nuntius + Systemd -.->|manages| Nuntius + Nuntius -->|SMTP PLAIN| SMTP + Nuntius -->|append JSONL| Disk +``` + +nuntius listens on `127.0.0.1:8080` (default). nginx terminates TLS on `:443` and proxies `/api/nuntius/*` to the upstream. systemd restarts the process on failure. + +## 1. Build and Upload (Local Machine) + +```bash +cd nuntius +just build +# → ./bin/nuntius + +rsync -avz \ + bin/nuntius \ + .env.example \ + user@your-server:/tmp/nuntius/ +``` + +> `install.sh` lives in the repository root. The systemd unit is included inline in Option A below (you do not need to rsync it from the repo). `.env.example` is a template for the secrets file. Adjust the `rsync` source list to match what you keep checked in. + +## 2. Install on the Server (SSH Session) + +### Option A — Manual (the README quick start) + +```bash +ssh user@your-server + +# Create the nuntius system user (one time) +sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/nuntius nuntius +sudo mkdir -p /var/lib/nuntius +sudo chown nuntius:nuntius /var/lib/nuntius + +# Install the binary +sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius + +# First run auto-creates /etc/nuntius/config.toml with the 3-form template +sudo /usr/local/bin/nuntius & +sleep 2 +sudo kill %1 + +# Install the systemd service (created inline from this guide) +sudo tee /etc/systemd/system/nuntius.service > /dev/null <<'EOF' +[Unit] +Description=nuntius contact form backend +After=network.target + +[Service] +Type=simple +User=nuntius +Group=nuntius +WorkingDirectory=/var/lib/nuntius +EnvironmentFile=/etc/nuntius/.env +ExecStart=/usr/local/bin/nuntius +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF +sudo systemctl daemon-reload +sudo systemctl enable --now nuntius + +# Set up secrets +sudo cp /tmp/nuntius/.env.example /etc/nuntius/.env +sudo chmod 600 /etc/nuntius/.env +sudo chown root:nuntius /etc/nuntius/.env +sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD + +# Edit the auto-generated config to your real SMTP + allowed origins +sudo $EDITOR /etc/nuntius/config.toml + +# Restart and watch logs +sudo systemctl restart nuntius +sudo journalctl -u nuntius -f +``` + +The service file expects the binary at `/usr/local/bin/nuntius`, the env file at `/etc/nuntius/.env`, and the data dir at `/var/lib/nuntius` (the config's `data_dir: "./data"` resolves to `/var/lib/nuntius/data` because the unit sets `WorkingDirectory=/var/lib/nuntius`). If you'd rather use `/opt/nuntius/...` or any other layout, edit the systemd unit block above before installing. + +### Option B — `install.sh` (idempotent) + +Before running the script, copy the systemd unit from the block in Option A above into `/tmp/nuntius/nuntius.service` (the script reads it from the current working directory): + +```bash +ssh user@your-server +cd /tmp/nuntius +# Paste the [Unit]…[Install] block from Option A into this file: +sudo $EDITOR /tmp/nuntius/nuntius.service +sudo bash install.sh +``` + +The script is idempotent: re-running on an already-installed host is safe. It: + +1. Creates the `nuntius` system user if missing. +2. Creates `/var/lib/nuntius` with the right ownership. +3. Installs the binary to `/usr/local/bin/nuntius`. +4. Installs the systemd unit to `/etc/systemd/system/nuntius.service`. +5. Runs nuntius briefly to auto-generate `/etc/nuntius/config.toml`. +6. Copies `.env.example` to `/etc/nuntius/.env` (mode 0600) if missing. +7. Reloads systemd and `enable`s the service — but does **not** start it, so you can edit `/etc/nuntius/.env` and `/etc/nuntius/config.toml` first. + +The script ends with the exact next steps you need to run by hand. + +## 3. nginx Reverse Proxy + +Add this location block to the same nginx server that already serves your site (most likely `/etc/nginx/sites-available/petrbalvin.org`): + +```nginx +location /api/nuntius/ { + proxy_pass http://127.0.0.1:8080/api/nuntius/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Then: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +Without `X-Forwarded-For`, the rate limiter will see every request as coming from `127.0.0.1` and effectively become useless once the form is live. The `Host` header also matters for vhost-aware upstreams; the default is fine for a single nuntius instance. + +## 4. Wire It Up to Your Web + +If you proxy `/api/nuntius/*` through nginx (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL: + +```bash +# .env (in the web project, only needed if NOT proxying through nginx) +VITE_NUNTIUS_URL=https://your-server:8080 +``` + +For a feedback or newsletter endpoint on the same site, just call the matching path: + +```js +await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, message }), +}); + +await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/newsletter`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), +}); +``` + +CORS is enforced per form by the `allowed_origins` list in `config.toml`. Add every frontend origin that should be able to post to each form. + +## 5. Updating Later + +```bash +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' +``` + +For schema changes, also ship the new `config.toml` and a migration note in your changelog. + +## 6. Updating the Config + +The service does **not** auto-reload `config.toml`. After editing it, restart: + +```bash +sudo $EDITOR /etc/nuntius/config.toml +sudo systemctl restart nuntius +sudo journalctl -u nuntius -f +``` + +The process exits 1 on a broken config (unknown field, unknown form type, duplicate path, missing SMTP host, …) and systemd will not keep it up. Fix the reported line and retry. + +## Files This Guide Creates on the Server + +| File | Owner | Mode | Purpose | +|---|---|---|---| +| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary | +| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory + data dir | +| `/var/lib/nuntius/data/` | `nuntius` | `0750` | Newsletter JSONL logs (created at runtime) | +| `/etc/nuntius/config.toml` | `root` | `0644` | nuntius config (auto-generated, then edited) | +| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets (SMTP password) | +| `/etc/systemd/system/nuntius.service` | `root` | `0644` | systemd unit | + +`/var/lib/nuntius/data/newsletter-*.jsonl` is created on the first `newsletter` form submission, by the `nuntius` user. + +## Verifying the Deploy + +After `systemctl restart nuntius`, check: + +```bash +# Service is up +systemctl status nuntius + +# Binary version +/usr/local/bin/nuntius --version + +# Listener is bound +ss -tlnp | grep 8080 + +# Health endpoint responds +curl -s http://127.0.0.1:8080/health | jq . + +# Recent log lines +journalctl -u nuntius -n 50 --no-pager + +# nginx proxy works +curl -i https://your-domain.example/api/nuntius/health + +# End-to-end POST through nginx +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"}' +``` + +If the end-to-end POST returns `{"ok": true}` and `journalctl -u nuntius -f` shows a `request` line with `status=200`, the deploy is live. + +## Backing Up + +- `/etc/nuntius/config.toml` and `/etc/nuntius/.env` — back up alongside your other config files. +- `/var/lib/nuntius/data/*.jsonl` — back up the JSONL log with your usual file backup job. It is append-only and trivially `tar`'able. + +## Uninstalling + +```bash +sudo systemctl disable --now nuntius +sudo rm /etc/systemd/system/nuntius.service /usr/local/bin/nuntius +sudo rm -rf /etc/nuntius /var/lib/nuntius +sudo userdel nuntius +``` diff --git a/docs/library-usage.md b/docs/library-usage.md new file mode 100644 index 0000000..ffd3a7e --- /dev/null +++ b/docs/library-usage.md @@ -0,0 +1,231 @@ +# Library Usage — `pkg/contactform` + +`pkg/contactform` is the public, reusable Go package that the nuntius HTTP server uses for validation. It has no third-party dependencies and no awareness of HTTP, SMTP, or the filesystem — drop it into any Go project that needs to validate a contact-style form payload. + +## Installation + +```bash +go get codeberg.org/petrbalvin/nuntius/pkg/contactform +``` + +Because nuntius ships with no `go.sum` entries from third-party modules, this import pulls in only stdlib transitively. + +## Types + +### `Request` + +The JSON body sent to the contact endpoint. + +```go +type Request struct { + Name string `json:"name"` + Email string `json:"email"` + Service string `json:"service,omitempty"` + Message string `json:"message"` + Honeypot string `json:"-"` +} +``` + +| Field | JSON tag | Notes | +|---|---|---| +| `Name` | `name` | Required for `contact`, `feedback`, `generic` | +| `Email` | `email` | Required for all four types | +| `Service` | `service,omitempty` | Optional. Validated against an allow-list for `contact` only. | +| `Message` | `message` | Required for `contact`, `feedback`, `generic` | +| `Honeypot` | `-` | Never marshalled to / from JSON. Read it from a separate field in your own request struct, or wire it through your favourite JSON tag. | + +The handler-side mapping (used inside the nuntius server) reads the honeypot value into `Request.Honeypot` based on the form's `honeypot_field` config. If you embed `contactform.Request` directly in your own API, you are responsible for reading the honeypot field separately. + +### `Response` + +```go +type Response struct { + OK bool `json:"ok"` +} +``` + +A successful submission returns `{"ok": true}`. + +### `FieldError` + +```go +type FieldError struct { + Field string `json:"field"` + Message string `json:"message"` +} +``` + +One entry in the `details[]` array of a `validation` error. + +### `ErrorResponse` + +```go +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Details []FieldError `json:"details,omitempty"` +} +``` + +Returned for any non-2xx response. The `Error` field is a short, machine-readable code (`validation`, `invalid_json`, `origin_not_allowed`, `rate_limited`, `send_failed`, `storage_failed`). `Message` is a human-readable summary; `Details` carries the per-field validation failures. + +## Functions + +### `Validate` + +```go +func Validate(r *Request, formType string) []FieldError +``` + +Dispatches to the right per-type validator. Returns a slice of field errors; an empty (or nil) slice means the request is valid. + +| `formType` | What is checked | +|---|---| +| `"contact"` | `name` (2–100 runes), `email` (RFC 5322), `service` (allow-list), `message` (10–5,000 runes) | +| `"feedback"` | `name`, `email`, `message` (no `service`) | +| `"newsletter"` | `email` only | +| `"generic"` | `name`, `email`, `message` (no `service`) | +| `""` (empty) | Falls back to `contact` | +| anything else | Falls back to `contact` — the strict allow-list is enforced at the config layer, not here | + +Whitespace is trimmed from `name`, `email`, `service`, and `message` **before** validation. The trimmed values are what the validator sees and what the email body sees. + +### Constants + +| Name | Value | Used by | +|---|---|---| +| `MinNameRunes` | `2` | `name` minimum length | +| `MaxNameRunes` | `100` | `name` maximum length | +| `MinMessageRunes` | `10` | `message` minimum length | +| `MaxMessageRunes` | `5000` | `message` maximum length | + +## Standalone Example + +The `examples/minimal/` directory contains a runnable demo. From the repo root: + +```bash +go run ./examples/minimal +``` + +Source (`examples/minimal/main.go`): + +```go +// Minimal example showing how to use the contactform package +// standalone (without the HTTP server). +package main + +import ( + "fmt" + "os" + + "codeberg.org/petrbalvin/nuntius/pkg/contactform" +) + +func main() { + req := &contactform.Request{ + Name: "Jane Doe", + Email: "jane@example.com", + Service: "architecture", + Message: "Hi, I'd like to discuss a possible engagement.", + } + + if errs := contactform.Validate(req, "contact"); len(errs) > 0 { + fmt.Fprintln(os.Stderr, "validation failed:") + for _, e := range errs { + fmt.Fprintf(os.Stderr, " - %s: %s\n", e.Field, e.Message) + } + os.Exit(1) + } + + fmt.Println("Request is valid:") + fmt.Printf(" name: %s\n", req.Name) + fmt.Printf(" email: %s\n", req.Email) + fmt.Printf(" service: %s\n", req.Service) + fmt.Printf(" message: %s\n", req.Message) +} +``` + +## Custom Validators + +If you need to extend the allow-list of `service` values, edit `validServices` in `pkg/contactform/validate.go`: + +```go +var validServices = map[string]bool{ + "": true, + "architecture": true, + "ai": true, + "infrastructure": true, + "software": true, + "unix": true, + "other": true, + // add yours here, e.g.: + // "security": true, +} +``` + +If you need different length limits, edit the four `Min*` / `Max*` constants in the same file. Or fork the package — it is intentionally small (~150 LOC) so a copy-into-your-project is also reasonable. + +## Using `Validate` From Your Own HTTP Handler + +```go +package main + +import ( + "encoding/json" + "log" + "net/http" + + "codeberg.org/petrbalvin/nuntius/pkg/contactform" +) + +func contactHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + defer r.Body.Close() + + var req contactform.Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{ + Error: "invalid_json", + Message: "Could not parse JSON body.", + }) + return + } + + if errs := contactform.Validate(&req, "contact"); len(errs) > 0 { + writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{ + Error: "validation", + Details: errs, + }) + return + } + + // ... your SMTP / persistence / webhook code here ... + + writeJSON(w, http.StatusOK, contactform.Response{OK: true}) +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func main() { + http.HandleFunc("/api/contact", contactHandler) + log.Fatal(http.ListenAndServe(":8080", nil)) +} +``` + +The example deliberately omits CORS, rate limiting, and the honeypot — copy those from `internal/handler/contact.go` if you need them. + +## Why a Separate Package? + +`pkg/contactform` is the one piece of nuntius that is **not** tied to HTTP, SMTP, or the filesystem. Keeping it in a separate, dependency-free package: + +- Makes it trivially embeddable in any Go service. +- Makes it cheap to unit-test (see `pkg/contactform/validate_test.go`). +- Makes the public API obvious — there is one entry point (`Validate`) and four types, all in one file pair. +- Keeps `internal/handler` focused on HTTP, `internal/email` on SMTP, and `internal/storage` on disk. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..ca4e145 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,142 @@ +# Security + +nuntius is a public-facing HTTP service that accepts untrusted input and turns it into outbound email and on-disk records. This document covers the threats it faces, the defenses built in, and the things you, the operator, are responsible for. + +## Threat Model + +nuntius is designed to defend against: + +| Threat | Mitigation | +|---|---| +| Naive spam bots filling the form | Per-form honeypot field that silently accepts and drops | +| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory) | +| Cross-origin abuse from a third-party site | Per-form CORS allowlist enforced on preflight and actual requests | +| Malformed bodies | Strict JSON parsing and per-type field validation | +| SMTP credential leakage in logs | `log/slog` never logs the `From` / `Authorization` / password; the password only lives in `FormSender` | +| Process crash on disk full | Append-only JSONL opens with `O_APPEND`; partial writes cannot corrupt earlier lines | +| Partial write bricking the subscriber log | `Count` and `List` skip malformed lines | +| Config typos silently disabling a form | `interpres.NewDecoder(...).DisallowUnknownFields()` + per-type allow-list at startup | +| Long-running abusive connections | `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout` on the server | +| Process hanging on shutdown | 15 s graceful shutdown via `signal.NotifyContext` + `srv.Shutdown` | + +nuntius is **not** designed to defend against: + +- A determined attacker who can submit a valid request from a rotating pool of IPs and bypass the per-IP rate limit. nuntius has no CAPTCHA, no proof-of-work, no IP reputation service, and no global rate limit. If you need those, put a reverse proxy with Cloudflare Turnstile / hCaptcha / reCAPTCHA in front of it. +- A compromised frontend that posts valid CORS-allowlisted traffic at high rate from a single IP. The token bucket handles that case. +- An attacker who has root on the server. nuntius reads the SMTP password from the process environment; anyone with `ps` access to the nuntius PID can read it from `/proc//environ`. Use `systemd` `EnvironmentFile=` with mode `0600` to keep the file readable only by `root:nuntius`. +- An attacker who can MITM the SMTP connection. Use a provider that supports STARTTLS on port `587` (Go's `net/smtp.SendMail` does STARTTLS automatically when the server advertises it) or implicit TLS on port `465` via a small wrapper. + +## Input Validation + +All four form types are validated by `pkg/contactform.Validate`, which enforces: + +- `name` — 2–100 runes after `strings.TrimSpace` +- `email` — RFC 5322 via `net/mail.ParseAddress` after `strings.TrimSpace` +- `service` (contact only) — restricted to a fixed allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`, or empty +- `message` — 10–5,000 runes after `strings.TrimSpace` + +Validation is server-side and runs on every request, regardless of what the client claims it has already checked. Field-level errors are returned as `details[]` so the UI can highlight individual inputs. + +## Honeypot + +Each form has a `honeypot_field` (default `"website"`) that should be hidden from humans via CSS (`display: none`, off-screen positioning, `tabindex="-1"`, `autocomplete="off"`). The form's HTML has a real-looking input for that name; humans never fill it; bots auto-fill every input they see. + +If the field is non-empty, nuntius: + +1. Logs a `honeypot triggered` line at `INFO` with `form`, `path`, and `ip`. +2. Responds `200 {"ok": true}` — the same as a legitimate submission. +3. Does **not** send mail. +4. Does **not** persist the subscriber. + +This makes the bot think it succeeded. There is no way for an external observer to tell a honeypot-hit response from a real success without the server log. + +To disable the honeypot on a specific form, set `honeypot_field: ""`. The form's HTML must then omit the hidden input. + +## CORS + +Per-form allowlist, configured in `config.toml`: + +```toml +[[forms]] +name = "contact" +allowed_origins = ["https://petrbalvin.org", "https://www.petrbalvin.org"] +``` + +Rules: + +- An absent `Origin` header is allowed (treats the client as `curl` / non-browser). This makes debugging easy without weakening anything in production. +- A present `Origin` is checked against the allowlist. If not present, the request is rejected with `403 origin_not_allowed`. +- Wildcards are not supported. List every origin explicitly. +- The preflight (`OPTIONS`) and the actual request go through the same allowlist check. + +The response sets `Access-Control-Allow-Origin` to the echoed origin (not `*`) and includes `Vary: Origin` so downstream caches do not mix origins. + +## Rate Limiting + +Each form has its own per-IP token bucket. The bucket size is `rate_limit_per_hour` and the refill rate is `perHour / 3600` tokens per second. The bucket map is guarded by a `sync.Mutex` and is in-process. + +The IP used is taken from: + +1. `X-Forwarded-For` (first hop, before any comma) +2. `X-Real-IP` +3. `RemoteAddr` (with port stripped) + +Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` — otherwise every request looks like it came from `127.0.0.1` and the bucket becomes a single global bucket. + +To disable rate limiting on a specific form, set `rate_limit_per_hour: 0`. There is no global safety net. + +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`. + +## Secret Handling + +SMTP credentials live in `/etc/nuntius/.env` (mode `0600`, owner `root:nuntius`). The config file references them as `${NUNTIUS_SMTP_PASSWORD}`; `os.Expand` substitutes them **before** the TOML is parsed, so the parsed `config.Form` holds the expanded value in memory only for the lifetime of the process. + +Things nuntius does **not** do: + +- It does not log the password, the `Authorization` header, or the raw SMTP `From` / `To` of any single message (only the form-level `to` from the config). +- It does not write the password to disk. `/etc/nuntius/.env` is the only place it is persisted. +- It does not echo the password in any error response. +- It does not log the full request body. `slog.Info` carries only `method`, `path`, `status`, `duration_ms`, and `ip`. + +Things you should do: + +- Make sure `/etc/nuntius/.env` is `0600` and owned `root:nuntius` (the `install.sh` script does this for you). +- Use an app-specific password or OAuth token if your provider supports it. nuntius uses `PLAIN` auth because that is what every SMTP provider offers; the credential is the secret, not the auth mechanism. +- Rotate the password on the same cadence as any other production secret. +- Restrict read access to `journalctl -u nuntius` if you do not want the client IPs in your local log files. + +## Disk Storage + +The newsletter JSONL log is: + +- Append-only — nuntius only ever writes new lines and reads them back. There is no `UPDATE` / `DELETE` path. +- Crash-safe — `os.OpenFile(path, O_APPEND | O_CREATE | O_WRONLY, 0644)` plus a single `Write(line)`. A partial write leaves a malformed trailing line; `Count` and `List` skip malformed lines, so the rest of the log is still readable. +- Bounded — there is no upper bound; add a `logrotate` unit if it grows. + +The systemd unit runs nuntius as user `nuntius` and points `WorkingDirectory` at `/var/lib/nuntius`. The data dir inherits that ownership. + +## Timeouts + +The `http.Server` is configured with: + +| Timeout | Value | Purpose | +|---|---|---| +| `ReadHeaderTimeout` | 10 s | Limit Slowloris-style header reads | +| `ReadTimeout` | 15 s | Total request body read | +| `WriteTimeout` | 30 s | Total response write | +| `IdleTimeout` | 60 s | Keep-alive idle | + +The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`). + +## What nuntius Does Not (Yet) Do + +- **No CAPTCHA / proof-of-work.** If you need a stronger abuse signal, add a reverse proxy with a CAPTCHA in front of nuntius; the same CORS rules apply. +- **No request signing / HMAC.** A frontend that has been compromised can post at the configured rate from a single IP. The token bucket handles the volume; the origin allowlist limits the surface. +- **No per-IP persistence across restarts.** Restart resets the buckets. That is an acceptable trade-off for a small backend and is documented in the rate-limit section above. +- **No TLS for the nuntius listener itself.** nuntius is meant to run behind nginx (or another reverse proxy) that terminates TLS. Exposing the binary directly on `:8080` is a development convenience, not a production pattern. +- **No DKIM signing.** Outbound mail is signed by your SMTP provider, not by nuntius. +- **No TLS for the SMTP connection itself is configured by nuntius.** Go's `net/smtp.SendMail` opportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on `:465`, wrap `SendMail` in a custom dialer. + +## Reporting a Vulnerability + +Email **petrbalvin@yandex.com** with a description and a reproducer. Please do not file a public issue for security-sensitive reports. diff --git a/examples/minimal/main.go b/examples/minimal/main.go new file mode 100644 index 0000000..3d8f110 --- /dev/null +++ b/examples/minimal/main.go @@ -0,0 +1,35 @@ +//go:build linux || freebsd + +// Minimal example showing how to use the contactform package +// standalone (without the HTTP server). +package main + +import ( + "fmt" + "os" + + "codeberg.org/petrbalvin/nuntius/pkg/contactform" +) + +func main() { + req := &contactform.Request{ + Name: "Jane Doe", + Email: "jane@example.com", + Service: "architecture", + Message: "Hi, I'd like to discuss a possible engagement.", + } + + if errs := contactform.Validate(req, "contact"); len(errs) > 0 { + fmt.Fprintln(os.Stderr, "validation failed:") + for _, e := range errs { + fmt.Fprintf(os.Stderr, " - %s: %s\n", e.Field, e.Message) + } + os.Exit(1) + } + + fmt.Println("Request is valid:") + fmt.Printf(" name: %s\n", req.Name) + fmt.Printf(" email: %s\n", req.Email) + fmt.Printf(" service: %s\n", req.Service) + fmt.Printf(" message: %s\n", req.Message) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ff8c6ad --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module codeberg.org/petrbalvin/nuntius + +go 1.26 + +require codeberg.org/petrbalvin/interpres v1.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..868f1b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +codeberg.org/petrbalvin/interpres v1.0.0 h1:bnY6zSekKklss+w/95O/0GCO1col05pV4HE8JsVXQN4= +codeberg.org/petrbalvin/interpres v1.0.0/go.mod h1:sWgi5ijKNNLu5sCtjix9jG/guhdnDRlROENrFhrIt90= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6d77d0e --- /dev/null +++ b/install.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# install.sh — install nuntius as a systemd service. +# +# nuntius ships as a single static binary. Build it on your workstation, upload +# the binary, the systemd unit, and the .env template to the server, then run +# this script there as root from that directory: +# +# scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/ +# ssh user@host +# cd /tmp/nuntius && sudo bash install.sh +# +# It expects ./nuntius and ./nuntius.service (and optionally ./.env.example) in +# the current working directory. Idempotent: re-running is safe — it skips the +# user, config, and .env when they already exist, and never starts the service. + +set -euo pipefail + +NUNTIUS_USER="nuntius" +BIN_DEST="/usr/local/bin/nuntius" +CONFIG_DIR="/etc/nuntius" +CONFIG_FILE="${CONFIG_DIR}/config.toml" +ENV_FILE="${CONFIG_DIR}/.env" +DATA_DIR="/var/lib/nuntius" +SERVICE_DEST="/etc/systemd/system/nuntius.service" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'EOF' +install.sh — install nuntius as a systemd service. + +Upload the binary, the systemd unit, and the .env template to the server, then +run this script there as root from that directory: + + scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/ + ssh user@host + cd /tmp/nuntius && sudo bash install.sh + +Idempotent: re-running is safe. The service is enabled but never started. +EOF + exit 0 +} + +require_root() { + [ "$(id -u)" -eq 0 ] || die "Run as root: sudo bash install.sh" +} + +require_files() { + [ -f ./nuntius ] || die "./nuntius binary not found — copy it here first (e.g. rsync bin/nuntius)." + [ -f ./nuntius.service ] || \ + die "./nuntius.service not found — paste the unit from docs/deployment.md (Option A)." +} + +create_user() { + if id "$NUNTIUS_USER" >/dev/null 2>&1; then + log "User ${NUNTIUS_USER} already exists." + else + log "Creating system user ${NUNTIUS_USER}…" + useradd --system --shell /usr/sbin/nologin --home-dir "$DATA_DIR" --user-group "$NUNTIUS_USER" + fi +} + +create_data_dir() { + log "Setting up ${DATA_DIR}…" + mkdir -p "$DATA_DIR" + chown "$NUNTIUS_USER:$NUNTIUS_USER" "$DATA_DIR" + chmod 750 "$DATA_DIR" +} + +install_binary() { + log "Installing binary to ${BIN_DEST}…" + install -m 0755 ./nuntius "$BIN_DEST" +} + +install_service() { + log "Installing systemd unit ${SERVICE_DEST}…" + install -m 0644 ./nuntius.service "$SERVICE_DEST" +} + +generate_config() { + if [ -f "$CONFIG_FILE" ]; then + log "Config ${CONFIG_FILE} already exists; leaving it untouched." + return + fi + log "Generating ${CONFIG_FILE}…" + mkdir -p "$CONFIG_DIR" + # nuntius writes the template config on first start; run it briefly, then + # let `timeout` send SIGTERM (graceful shutdown). No backgrounding or manual + # kill — the write happens at startup, before the timeout elapses. + timeout -k 5s 4s "$BIN_DEST" >/dev/null 2>&1 || true + [ -f "$CONFIG_FILE" ] || warn "nuntius did not create ${CONFIG_FILE}; check the uploaded binary." + chown -R "$NUNTIUS_USER:$NUNTIUS_USER" "$DATA_DIR" +} + +install_env() { + if [ -f "$ENV_FILE" ]; then + log "${ENV_FILE} already exists; leaving it untouched." + elif [ -f ./.env.example ]; then + log "Creating ${ENV_FILE} from .env.example (edit it!)…" + install -m 0600 ./.env.example "$ENV_FILE" + chown "root:$NUNTIUS_USER" "$ENV_FILE" + else + warn ".env.example not found; skipping ${ENV_FILE}. Create it before starting." + fi +} + +enable_service() { + log "Reloading systemd and enabling nuntius…" + systemctl daemon-reload + systemctl enable nuntius.service +} + +final_notes() { + cat <<'EOF' + +============================================================ +✓ nuntius installed and enabled, but NOT started. + +Next manual steps: + 1. Set the SMTP password: + sudo $EDITOR /etc/nuntius/.env + 2. Edit the auto-generated config (smtp.host, smtp.user, allowed_origins): + sudo $EDITOR /etc/nuntius/config.toml + 3. Start the service: + sudo systemctl start nuntius + sudo journalctl -u nuntius -f + 4. Add the nginx location block (see README.md), then: + sudo nginx -t && sudo systemctl reload nginx +============================================================ +EOF +} + +main() { + case "${1:-}" in + -h | --help) usage ;; + esac + + require_root + require_files + create_user + create_data_dir + install_binary + install_service + generate_config + install_env + enable_service + final_notes +} + +main "$@" diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..86f1ebc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,231 @@ +//go:build linux || freebsd + +// Package config loads nuntius configuration from a TOML file. +// +// Configuration supports env var expansion for secrets: +// 1. A TOML file (config.toml) — checked into the repo or provisioned +// per environment. Holds non-secret defaults and references to env vars. +// 2. Environment variables — used for secrets like SMTP passwords. +// Reference them in the TOML file as ${VAR_NAME} or $VAR_NAME. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "codeberg.org/petrbalvin/interpres" +) + +// ValidFormTypes lists the form types nuntius knows how to handle. +// Each form's `type` field is validated against this list in validate(). +var ValidFormTypes = map[string]bool{ + "contact": true, + "feedback": true, + "newsletter": true, + "generic": true, +} + +// Config is the root configuration for nuntius. +type Config struct { + Server ServerConfig `toml:"server"` + Forms []Form `toml:"forms"` + DataDir string `toml:"data_dir"` +} + +// ServerConfig holds server-wide settings. +type ServerConfig struct { + Port int `toml:"port"` +} + +// Form is one contact / feedback / newsletter endpoint. +type Form struct { + Name string `toml:"name"` + Path string `toml:"path"` + Type string `toml:"type"` + SMTP SMTPConfig `toml:"smtp"` + To string `toml:"to"` + From string `toml:"from"` + RateLimitPerHour int `toml:"rate_limit_per_hour"` + HoneypotField string `toml:"honeypot_field"` + AllowedOrigins []string `toml:"allowed_origins"` +} + +// SMTPConfig holds SMTP credentials and connection details. +type SMTPConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + User string `toml:"user"` + Password string `toml:"password"` +} + +// Load reads, expands env vars in, and parses the TOML file at path. +// If the file does not exist, a default template is created first. +func Load(path string) (*Config, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, defaultConfig, 0644); err != nil { + return nil, fmt.Errorf("write default config to %s: %w", path, err) + } + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + // Step 1: expand ${VAR_NAME} references in the raw text. + expanded := os.Expand(string(raw), os.Getenv) + + // Step 2: parse TOML, rejecting unknown fields to catch typos early. + var cfg Config + if err := interpres.NewDecoder().DisallowUnknownFields().Decode([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + if err := cfg.validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +// validate runs sanity checks on the loaded config and applies defaults. +func (c *Config) validate() error { + if c.Server.Port == 0 { + c.Server.Port = 8080 + } + if c.DataDir == "" { + c.DataDir = "./data" + } + if len(c.Forms) == 0 { + return fmt.Errorf("at least one form must be defined under `forms`") + } + + paths := make(map[string]string, len(c.Forms)) + for i := range c.Forms { + f := &c.Forms[i] + if f.Name == "" { + return fmt.Errorf("form #%d: name is required", i+1) + } + if f.Path == "" { + return fmt.Errorf("form %q: path is required", f.Name) + } + if f.Type == "" { + c.Forms[i].Type = "contact" + } + if !ValidFormTypes[c.Forms[i].Type] { + supported := make([]string, 0, len(ValidFormTypes)) + for k := range ValidFormTypes { + supported = append(supported, k) + } + return fmt.Errorf("form %q: type %q is not supported (must be one of: %s)", + f.Name, c.Forms[i].Type, strings.Join(supported, ", ")) + } + if !strings.HasPrefix(f.Path, "/") { + return fmt.Errorf("form %q: path must start with /", f.Name) + } + if existing, ok := paths[f.Path]; ok { + return fmt.Errorf("form %q: duplicate path %q (also used by %q)", f.Name, f.Path, existing) + } + paths[f.Path] = f.Name + + if f.SMTP.Host == "" { + return fmt.Errorf("form %q: smtp.host is required", f.Name) + } + if f.SMTP.Port == 0 { + return fmt.Errorf("form %q: smtp.port is required", f.Name) + } + if f.SMTP.User == "" { + return fmt.Errorf("form %q: smtp.user is required", f.Name) + } + if f.To == "" { + return fmt.Errorf("form %q: `to` is required", f.Name) + } + if f.From == "" { + // Default From to the SMTP user. + c.Forms[i].From = f.SMTP.User + } + if f.RateLimitPerHour == 0 { + c.Forms[i].RateLimitPerHour = 10 + } + if f.HoneypotField == "" { + c.Forms[i].HoneypotField = "website" + } + } + + return nil +} + +// ConfigPath returns the canonical path for nuntius configuration. +func ConfigPath() string { + return "/etc/nuntius/config.toml" +} + +// AddrFor returns "host:port" for the given SMTP config. +func (s SMTPConfig) AddrFor() string { + return s.Host + ":" + strconv.Itoa(s.Port) +} + +// defaultConfig is written to disk when no config exists yet. +var defaultConfig = []byte(`# nuntius configuration. +# +# Secrets (SMTP passwords) are referenced as ${VAR_NAME} and expanded from the +# environment at startup, so they never live in this file. + +data_dir = "./data" + +[server] +port = 8080 + +[[forms]] +name = "contact" +type = "contact" +path = "/api/nuntius/contact" +to = "you@example.com" +from = "contact@example.com" +rate_limit_per_hour = 10 +honeypot_field = "website" +allowed_origins = ["https://example.com"] + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "contact@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" + +[[forms]] +name = "feedback" +type = "feedback" +path = "/api/nuntius/feedback" +to = "you@example.com" +from = "contact@example.com" +rate_limit_per_hour = 10 +honeypot_field = "website" +allowed_origins = ["https://example.com"] + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "contact@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" + +[[forms]] +name = "newsletter" +type = "newsletter" +path = "/api/nuntius/newsletter" +to = "you@example.com" +from = "contact@example.com" +rate_limit_per_hour = 100 +honeypot_field = "bot_email" +allowed_origins = ["https://example.com"] + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "contact@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" +`) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9c312a7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,94 @@ +//go:build linux || freebsd + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadWritesAndParsesDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + + // The first Load writes the default template, then parses it back. + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != 8080 { + t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port) + } + if cfg.DataDir != "./data" { + t.Errorf("DataDir = %q, want ./data", cfg.DataDir) + } + if len(cfg.Forms) != 3 { + t.Fatalf("len(Forms) = %d, want 3", len(cfg.Forms)) + } + if cfg.Forms[0].Name != "contact" || cfg.Forms[0].SMTP.Host != "smtp.example.com" { + t.Errorf("Forms[0] = %#v", cfg.Forms[0]) + } + if cfg.Forms[2].RateLimitPerHour != 100 { + t.Errorf("newsletter rate_limit_per_hour = %d, want 100", cfg.Forms[2].RateLimitPerHour) + } +} + +func TestLoadExpandsEnvVars(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +data_dir = "./data" + +[server] +port = 9000 + +[[forms]] +name = "contact" +type = "contact" +path = "/api/nuntius/contact" +to = "you@example.com" + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "u@example.com" + password = "${NUNTIUS_TEST_PW}" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("NUNTIUS_TEST_PW", "s3cret") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Forms[0].SMTP.Password != "s3cret" { + t.Errorf("password = %q, want s3cret", cfg.Forms[0].SMTP.Password) + } +} + +func TestLoadRejectsUnknownField(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +bogus = true + +[server] +port = 8080 + +[[forms]] +name = "contact" +path = "/api/nuntius/contact" +to = "you@example.com" + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "u@example.com" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected an error for an unknown field") + } +} diff --git a/internal/email/sender.go b/internal/email/sender.go new file mode 100644 index 0000000..085d080 --- /dev/null +++ b/internal/email/sender.go @@ -0,0 +1,292 @@ +//go:build linux || freebsd + +// Package email handles SMTP message composition and delivery. +package email + +import ( + "bytes" + "fmt" + "html/template" + "net/smtp" + "time" + + "codeberg.org/petrbalvin/nuntius/internal/config" + "codeberg.org/petrbalvin/nuntius/pkg/contactform" +) + +// FormSender delivers contact form submissions for a single form +// using its own SMTP credentials. Each form has its own FormSender +// so credentials are isolated per tenant. +type FormSender struct { + form *config.Form +} + +// NewFormSender returns a new FormSender bound to the given form. +func NewFormSender(form *config.Form) *FormSender { + return &FormSender{form: form} +} + +// Send composes and sends a multi-part email (plain text + HTML) +// for the given request. Returns an error if the SMTP round-trip fails. +func (s *FormSender) Send(req contactform.Request) error { + auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host) + msg := compose(s.form.From, s.form.To, req, s.form.Name, s.form.Type) + return smtp.SendMail(s.form.SMTP.AddrFor(), auth, s.form.From, []string{s.form.To}, msg) +} + +// emailTemplateContact is the HTML body template for contact-type forms. +var emailTemplateContact = template.Must(template.New("contact").Parse(` + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ Contact Form Submission +

+

+ {{.FormName}} +

+
+ + + + + + + {{if .Service}} + + + + {{end}} + + + +
+ From +
+ {{.Name}} + {{.Email}} +
+ Service Interest +
+ {{.Service}} +
+ Received +
+ {{.Received}} +
+ + +

Message

+
{{.Message}}
+ +
+

+ Delivered by nuntius — a contact form backend for Linux servers. +

+
+
+ +`)) + +// emailTemplateFeedback is the HTML body template for feedback-type forms. +var emailTemplateFeedback = template.Must(template.New("feedback").Parse(` + + + + + +
+ + + +
+

New Feedback

+

{{.FormName}}

+
+

From

+

{{.Name}} {{.Email}}

+

Feedback

+
{{.Message}}
+

Delivered by nuntius

+
+
+ +`)) + +// emailTemplateNewsletter is the HTML body template for newsletter-signup forms. +var emailTemplateNewsletter = template.Must(template.New("newsletter").Parse(` + + + + + +
+ + + +
+

{{.FormName}} — new subscriber

+
+

Email

+

{{.Email}}

+

Delivered by nuntius

+
+
+ +`)) + +// emailTemplateGeneric is the HTML body template for generic forms. +var emailTemplateGeneric = template.Must(template.New("generic").Parse(` + + + + + +
+ + + +
+

Form Submission

+

{{.FormName}}

+
+

From

+

{{.Name}} {{.Email}}

+

Message

+
{{.Message}}
+

Delivered by nuntius

+
+
+ +`)) + +func compose(from, to string, req contactform.Request, formName, formType string) []byte { + received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC") + + // --- HTML part --- + var htmlBuf bytes.Buffer + tmpl := emailTemplateContact + switch formType { + case "newsletter": + tmpl = emailTemplateNewsletter + case "feedback": + tmpl = emailTemplateFeedback + case "generic": + tmpl = emailTemplateGeneric + } + _ = tmpl.Execute(&htmlBuf, map[string]string{ + "FormName": formName, + "Name": req.Name, + "Email": req.Email, + "Service": req.Service, + "Message": req.Message, + "Received": received, + }) + + // --- Subject + plain-text body per form type --- + var subject, text string + switch formType { + case "newsletter": + subject = fmt.Sprintf("[nuntius/%s] New newsletter subscriber", formName) + text = fmt.Sprintf( + "New Newsletter Subscriber: %s\r\n"+ + "---\r\n"+ + "Email: %s\r\n"+ + "Received: %s\r\n"+ + "\r\n"+ + "Delivered by nuntius\r\n", + formName, req.Email, received, + ) + case "feedback": + subject = fmt.Sprintf("[nuntius/%s] New feedback", formName) + text = fmt.Sprintf( + "New Feedback: %s\r\n"+ + "---\r\n"+ + "From: %s <%s>\r\n"+ + "Received: %s\r\n"+ + "\r\n"+ + "%s\r\n\r\n"+ + "Delivered by nuntius\r\n", + formName, req.Name, req.Email, received, req.Message, + ) + case "generic": + subject = fmt.Sprintf("[nuntius/%s] New submission", formName) + text = fmt.Sprintf( + "New Submission: %s\r\n"+ + "---\r\n"+ + "From: %s <%s>\r\n"+ + "Received: %s\r\n"+ + "\r\n"+ + "%s\r\n\r\n"+ + "Delivered by nuntius\r\n", + formName, req.Name, req.Email, received, req.Message, + ) + default: // contact + subject = fmt.Sprintf("[nuntius/%s] Contact form submission", formName) + if req.Service != "" { + subject = fmt.Sprintf("[nuntius/%s][%s] Contact form submission", formName, req.Service) + } + text = fmt.Sprintf( + "Contact Form Submission: %s\r\n"+ + "---\r\n"+ + "From: %s <%s>\r\n"+ + "Service interest: %s\r\n"+ + "Received: %s\r\n"+ + "\r\n"+ + "%s\r\n\r\n"+ + "Delivered by nuntius\r\n", + formName, req.Name, req.Email, req.Service, received, req.Message, + ) + } + + // Assemble multipart/alternative message. + boundary := "nuntius-boundary" + var msg bytes.Buffer + msg.WriteString(fmt.Sprintf("From: %s\r\n", from)) + msg.WriteString(fmt.Sprintf("To: %s\r\n", to)) + msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))) + msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", req.Email)) + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary)) + msg.WriteString("\r\n") + + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + msg.WriteString("\r\n") + msg.WriteString(text) + msg.WriteString("\r\n") + + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + msg.WriteString("\r\n") + msg.WriteString(htmlBuf.String()) + msg.WriteString("\r\n") + + msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + return msg.Bytes() +} diff --git a/internal/handler/contact.go b/internal/handler/contact.go new file mode 100644 index 0000000..d9fd0b9 --- /dev/null +++ b/internal/handler/contact.go @@ -0,0 +1,269 @@ +//go:build linux || freebsd + +package handler + +import ( + "encoding/json" + "log/slog" + "net/http" + "path/filepath" + "strings" + "sync" + "time" + + "codeberg.org/petrbalvin/nuntius/internal/config" + "codeberg.org/petrbalvin/nuntius/internal/email" + "codeberg.org/petrbalvin/nuntius/internal/storage" + "codeberg.org/petrbalvin/nuntius/pkg/contactform" +) + +// ContactHandler serves one or more contact forms, dispatched by URL path. +// Each form has its own sender, rate limiter, CORS allowlist, and honeypot. +// Newsletter-type forms also get a per-form subscriber store. +type ContactHandler struct { + forms map[string]*config.Form + senders map[string]*email.FormSender + rateLimits map[string]*rateLimiter + stores map[string]*storage.NewsletterStore +} + +// New constructs a ContactHandler that serves all forms defined in cfg. +func New(cfg *config.Config) *ContactHandler { + h := &ContactHandler{ + forms: make(map[string]*config.Form, len(cfg.Forms)), + senders: make(map[string]*email.FormSender, len(cfg.Forms)), + rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)), + stores: make(map[string]*storage.NewsletterStore, len(cfg.Forms)), + } + for i := range cfg.Forms { + f := &cfg.Forms[i] + h.forms[f.Path] = f + h.senders[f.Path] = email.NewFormSender(f) + h.rateLimits[f.Path] = newRateLimiter(f.RateLimitPerHour) + if f.Type == "newsletter" { + storePath := filepath.Join(cfg.DataDir, "newsletter-"+f.Name+".jsonl") + h.stores[f.Path] = storage.NewNewsletterStore(storePath) + } + } + return h +} + +// Register mounts one POST + OPTIONS handler per form, plus a single +// GET /health handler. +func (h *ContactHandler) Register(mux *http.ServeMux) { + for path := range h.forms { + mux.HandleFunc("POST "+path, h.makeHandler(path)) + mux.HandleFunc("OPTIONS "+path, h.makeHandler(path)) + } + mux.HandleFunc("GET /health", h.Health) +} + +// makeHandler returns the per-form HTTP handler. +func (h *ContactHandler) makeHandler(path string) http.HandlerFunc { + form := h.forms[path] + sender := h.senders[path] + limiter := h.rateLimits[path] + store := h.stores[path] // nil for non-newsletter forms + + return func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // CORS preflight. + if r.Method == http.MethodOptions { + if formAllowed(form, origin) { + writeCORS(w, origin, form.AllowedOrigins) + } + w.WriteHeader(http.StatusNoContent) + return + } + + // CORS on actual request. + if formAllowed(form, origin) { + writeCORS(w, origin, form.AllowedOrigins) + } else if origin != "" { + respondError(w, http.StatusForbidden, "origin_not_allowed", "") + return + } + + // Rate limit. + if form.RateLimitPerHour > 0 { + ip := clientIP(r) + if !limiter.allow(ip) { + respondError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests, please try again later.") + return + } + } + + // Parse body. + defer r.Body.Close() + var req contactform.Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.") + return + } + + // Honeypot: silently accept but never send. + if form.HoneypotField != "" && req.Honeypot != "" { + slog.Info("honeypot triggered, dropping silently", + "form", form.Name, "path", path, "ip", clientIP(r)) + respondOK(w) + return + } + + // Validate. + if errs := contactform.Validate(&req, form.Type); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(contactform.ErrorResponse{ + Error: "validation", + Details: errs, + }) + return + } + + // Send email. + if err := sender.Send(req); err != nil { + slog.Error("send failed", + "err", err, "form", form.Name, "path", path, "ip", clientIP(r)) + respondError(w, http.StatusInternalServerError, "send_failed", "Could not send email.") + return + } + + // For newsletter forms, also persist the subscriber to disk. + if store != nil { + if err := store.Append(storage.Subscriber{ + Email: req.Email, + IP: clientIP(r), + Form: form.Name, + }); err != nil { + slog.Error("newsletter store append failed", + "err", err, "form", form.Name, "path", path, "ip", clientIP(r)) + respondError(w, http.StatusInternalServerError, "storage_failed", + "Could not record subscription.") + return + } + } + + slog.Info("message sent", + "form", form.Name, "path", path, + "service", req.Service, "ip", clientIP(r), + ) + respondOK(w) + } +} + +// Health handles GET /health. +func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "forms": len(h.forms), + }) +} + +// --- internals --- + +func formAllowed(form *config.Form, origin string) bool { + if origin == "" { + return true + } + for _, a := range form.AllowedOrigins { + if a == origin { + return true + } + } + return false +} + +func writeCORS(w http.ResponseWriter, origin string, allowed []string) { + // Only echo the origin back if it is in the allowlist. + for _, a := range allowed { + if a == origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + return + } + } +} + +// rateLimiter is a per-IP token bucket. +type rateLimiter struct { + mu sync.Mutex + perHour int + buckets map[string]*bucket +} + +type bucket struct { + tokens float64 + last time.Time +} + +func newRateLimiter(perHour int) *rateLimiter { + return &rateLimiter{ + perHour: perHour, + buckets: make(map[string]*bucket), + } +} + +func (r *rateLimiter) allow(ip string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + b, ok := r.buckets[ip] + if !ok { + b = &bucket{tokens: float64(r.perHour), last: now} + r.buckets[ip] = b + } + + rate := float64(r.perHour) / 3600.0 + elapsed := now.Sub(b.last).Seconds() + b.tokens = minF(b.tokens+elapsed*rate, float64(r.perHour)) + b.last = now + + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +func minF(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func respondOK(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(contactform.Response{OK: true}) +} + +func respondError(w http.ResponseWriter, code int, err, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(contactform.ErrorResponse{ + Error: err, + Message: msg, + }) +} + +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if xr := r.Header.Get("X-Real-IP"); xr != "" { + return xr + } + host := r.RemoteAddr + if i := strings.LastIndexByte(host, ':'); i >= 0 { + host = host[:i] + } + return host +} diff --git a/internal/storage/newsletter.go b/internal/storage/newsletter.go new file mode 100644 index 0000000..f4e21f0 --- /dev/null +++ b/internal/storage/newsletter.go @@ -0,0 +1,154 @@ +//go:build linux || freebsd + +// Package storage provides file-based persistence for nuntius. +// +// Today it contains only the newsletter subscriber log. It is designed +// to be zero-dependency (stdlib only) and crash-safe: each Append writes +// a single JSON line to an append-only file, so partial writes do not +// corrupt earlier records. +package storage + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Subscriber is a single newsletter signup, one JSON object per line. +type Subscriber struct { + Email string `json:"email"` + IP string `json:"ip,omitempty"` + Form string `json:"form"` + CreatedAt time.Time `json:"created_at"` +} + +// NewsletterStore is a file-based append-only log of subscribers. +// All methods are safe for concurrent use. +type NewsletterStore struct { + path string + mu sync.Mutex +} + +// NewNewsletterStore returns a store that appends to path. +// The file and its parent directory are created lazily on first Append. +func NewNewsletterStore(path string) *NewsletterStore { + return &NewsletterStore{path: path} +} + +// Path returns the file path this store writes to. +func (s *NewsletterStore) Path() string { + return s.path +} + +// Append writes sub as a single JSON line to the log. +// The file and parent directory are created on first call. +func (s *NewsletterStore) Append(sub Subscriber) error { + if sub.CreatedAt.IsZero() { + sub.CreatedAt = time.Now().UTC() + } + line, err := json.Marshal(sub) + if err != nil { + return fmt.Errorf("marshal subscriber: %w", err) + } + line = append(line, '\n') + + s.mu.Lock() + defer s.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err) + } + f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("open %s: %w", s.path, err) + } + defer f.Close() + if _, err := f.Write(line); err != nil { + return fmt.Errorf("write %s: %w", s.path, err) + } + return nil +} + +// Count returns the number of valid subscriber lines in the log. +// Malformed lines are silently skipped so a partial write does not +// brick the entire file. +func (s *NewsletterStore) Count() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.countLocked() +} + +func (s *NewsletterStore) countLocked() (int, error) { + f, err := os.Open(s.path) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("open %s: %w", s.path, err) + } + defer f.Close() + + n := 0 + scanner := bufio.NewScanner(f) + // Allow up to 1 MB per line in case a single record balloons. + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var sub Subscriber + if err := json.Unmarshal(line, &sub); err != nil { + // Skip malformed lines rather than failing the whole count. + continue + } + if sub.Email != "" { + n++ + } + } + if err := scanner.Err(); err != nil { + return n, fmt.Errorf("scan %s: %w", s.path, err) + } + return n, nil +} + +// List returns all valid subscribers in insertion order. +// Use with care on large files; it reads the whole log into memory. +func (s *NewsletterStore) List() ([]Subscriber, error) { + s.mu.Lock() + defer s.mu.Unlock() + + f, err := os.Open(s.path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("open %s: %w", s.path, err) + } + defer f.Close() + + var out []Subscriber + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var sub Subscriber + if err := json.Unmarshal(line, &sub); err != nil { + continue + } + if sub.Email != "" { + out = append(out, sub) + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan %s: %w", s.path, err) + } + return out, nil +} diff --git a/internal/storage/newsletter_test.go b/internal/storage/newsletter_test.go new file mode 100644 index 0000000..8979598 --- /dev/null +++ b/internal/storage/newsletter_test.go @@ -0,0 +1,153 @@ +//go:build linux || freebsd + +package storage + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewsletterStore_AppendCreatesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subs", "subscribers.jsonl") + s := NewNewsletterStore(path) + + if err := s.Append(Subscriber{Email: "a@example.com", Form: "newsletter"}); err != nil { + t.Fatalf("Append: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file to exist, got %v", err) + } +} + +func TestNewsletterStore_AppendAndCount(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subs.jsonl") + s := NewNewsletterStore(path) + + for i, e := range []string{"a@x.com", "b@x.com", "c@x.com"} { + if err := s.Append(Subscriber{Email: e, Form: "newsletter"}); err != nil { + t.Fatalf("Append #%d: %v", i, err) + } + } + + n, err := s.Count() + if err != nil { + t.Fatalf("Count: %v", err) + } + if n != 3 { + t.Errorf("expected 3 subscribers, got %d", n) + } +} + +func TestNewsletterStore_AppendIsOneLinePerRecord(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subs.jsonl") + s := NewNewsletterStore(path) + + for _, e := range []string{"a@x.com", "b@x.com"} { + if err := s.Append(Subscriber{Email: e, Form: "n"}); err != nil { + t.Fatalf("Append: %v", err) + } + } + + // Each line should be a self-contained JSON object. + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + scanner := bufio.NewScanner(strings.NewReader(string(data))) + count := 0 + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var sub Subscriber + if err := json.Unmarshal(line, &sub); err != nil { + t.Fatalf("Unmarshal line: %v", err) + } + count++ + } + if err := scanner.Err(); err != nil { + t.Fatalf("scanner.Err: %v", err) + } + if count != 2 { + t.Errorf("expected 2 records, decoded %d", count) + } +} + +func TestNewsletterStore_CountOnMissingFileIsZero(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "does-not-exist.jsonl") + s := NewNewsletterStore(path) + + n, err := s.Count() + if err != nil { + t.Fatalf("Count on missing file: %v", err) + } + if n != 0 { + t.Errorf("expected 0 on missing file, got %d", n) + } +} + +func TestNewsletterStore_ListReturnsInsertionOrder(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subs.jsonl") + s := NewNewsletterStore(path) + + want := []string{"a@x.com", "b@x.com", "c@x.com"} + for _, e := range want { + _ = s.Append(Subscriber{Email: e, Form: "n"}) + } + + subs, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(subs) != len(want) { + t.Fatalf("expected %d, got %d", len(want), len(subs)) + } + for i, sub := range subs { + if sub.Email != want[i] { + t.Errorf("position %d: want %q, got %q", i, want[i], sub.Email) + } + } +} + +func TestNewsletterStore_SkipsMalformedLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subs.jsonl") + + // Write a mix of valid and garbage lines. + content := `{"email":"good1@x.com","form":"n","created_at":"2026-01-01T00:00:00Z"} +this is not valid json +{"email":"good2@x.com","form":"n","created_at":"2026-01-02T00:00:00Z"} +{not even close to json +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewNewsletterStore(path) + n, err := s.Count() + if err != nil { + t.Fatalf("Count: %v", err) + } + if n != 2 { + t.Errorf("expected 2 valid records (skipping malformed), got %d", n) + } +} + +func TestNewsletterStore_PathReturnsConfiguredPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "subs.jsonl") + s := NewNewsletterStore(path) + if got := s.Path(); got != path { + t.Errorf("Path: want %q, got %q", path, got) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..676785a --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,10 @@ +//go:build linux || freebsd + +// Package version exposes the nuntius release identity. +package version + +// Name is the program name reported by `--version` and in log lines. +const Name = "nuntius" + +// Version is the nuntius release version. Follows SemVer. +var Version = "1.0.0" diff --git a/justfile b/justfile new file mode 100644 index 0000000..d038e64 --- /dev/null +++ b/justfile @@ -0,0 +1,27 @@ +set dotenv-load := false +set positional-arguments := false + +default: + @just --list + +install: + @echo "→ Installing Go module dependencies…" + go mod tidy + go mod download + +uninstall: + @echo "→ Removing ./bin…" + rm -rf bin + +# Run the server in dev mode +run: + go run ./cmd/server + +build: + @echo "→ Building for production…" + @mkdir -p bin + go build -ldflags="-s -w" -o bin/nuntius ./cmd/server + +test: + @echo "→ Running tests…" + go test ./... diff --git a/pkg/contactform/types.go b/pkg/contactform/types.go new file mode 100644 index 0000000..a8937ae --- /dev/null +++ b/pkg/contactform/types.go @@ -0,0 +1,33 @@ +//go:build linux || freebsd + +// Package contactform provides reusable types and validation +// for a JSON contact form. Import it into any Go project that +// needs a contact endpoint. +package contactform + +// Request is the JSON body sent to the contact endpoint. +type Request struct { + Name string `json:"name"` + Email string `json:"email"` + Service string `json:"service,omitempty"` + Message string `json:"message"` + Honeypot string `json:"-"` +} + +// Response is the success response. +type Response struct { + OK bool `json:"ok"` +} + +// FieldError describes a single validation failure. +type FieldError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ErrorResponse is returned for any non-2xx response. +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Details []FieldError `json:"details,omitempty"` +} diff --git a/pkg/contactform/validate.go b/pkg/contactform/validate.go new file mode 100644 index 0000000..79a3fb5 --- /dev/null +++ b/pkg/contactform/validate.go @@ -0,0 +1,111 @@ +//go:build linux || freebsd + +package contactform + +import ( + "net/mail" + "strings" + "unicode/utf8" +) + +// validServices is the allow-list for contact-type forms. +var validServices = map[string]bool{ + "": true, + "architecture": true, + "ai": true, + "infrastructure": true, + "software": true, + "unix": true, + "other": true, +} + +// MinNameRunes is the minimum allowed length of a name. +const MinNameRunes = 2 + +// MaxNameRunes is the maximum allowed length of a name. +const MaxNameRunes = 100 + +// MinMessageRunes is the minimum allowed length of a message body. +const MinMessageRunes = 10 + +// MaxMessageRunes is the maximum allowed length of a message body. +const MaxMessageRunes = 5000 + +// Validate dispatches to the correct type-specific validator. +// Supported types: contact, feedback, newsletter, generic. Default is "contact". +func Validate(r *Request, formType string) []FieldError { + switch formType { + case "newsletter": + return validateNewsletter(r) + case "feedback", "generic": + return validateGeneric(r) + default: + return validateContact(r) + } +} + +func validateContact(r *Request) []FieldError { + r.Name = strings.TrimSpace(r.Name) + r.Email = strings.TrimSpace(r.Email) + r.Service = strings.TrimSpace(r.Service) + r.Message = strings.TrimSpace(r.Message) + + var errs []FieldError + + if n := utf8.RuneCountInString(r.Name); n < MinNameRunes { + errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"}) + } else if n > MaxNameRunes { + errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"}) + } + + if _, err := mail.ParseAddress(r.Email); err != nil { + errs = append(errs, FieldError{Field: "email", Message: "email is invalid"}) + } + + if !validServices[r.Service] { + errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"}) + } + + if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes { + errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"}) + } else if n > MaxMessageRunes { + errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"}) + } + + return errs +} + +func validateNewsletter(r *Request) []FieldError { + r.Email = strings.TrimSpace(r.Email) + + if _, err := mail.ParseAddress(r.Email); err != nil { + return []FieldError{{Field: "email", Message: "email is invalid"}} + } + return nil +} + +func validateGeneric(r *Request) []FieldError { + r.Name = strings.TrimSpace(r.Name) + r.Email = strings.TrimSpace(r.Email) + r.Message = strings.TrimSpace(r.Message) + + var errs []FieldError + + if n := utf8.RuneCountInString(r.Name); n < MinNameRunes { + errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"}) + } else if n > MaxNameRunes { + errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"}) + } + + if _, err := mail.ParseAddress(r.Email); err != nil { + errs = append(errs, FieldError{Field: "email", Message: "email is invalid"}) + } + + if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes { + errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"}) + } else if n > MaxMessageRunes { + errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"}) + } + + return errs +} diff --git a/pkg/contactform/validate_test.go b/pkg/contactform/validate_test.go new file mode 100644 index 0000000..3c01a62 --- /dev/null +++ b/pkg/contactform/validate_test.go @@ -0,0 +1,127 @@ +//go:build linux || freebsd + +package contactform + +import ( + "strings" + "testing" +) + +func TestValidateContact_Happy(t *testing.T) { + r := &Request{ + Name: "Jane Doe", + Email: "jane@example.com", + Service: "architecture", + Message: "Hello, I would like to discuss a project.", + } + if errs := Validate(r, "contact"); len(errs) > 0 { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestValidateContact_DefaultType(t *testing.T) { + // Unknown / empty type falls back to contact validation. + r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."} + if errs := Validate(r, ""); len(errs) > 0 { + t.Fatalf("expected no errors with empty type, got %v", errs) + } +} + +func TestValidateContact_RejectsInvalidEmail(t *testing.T) { + r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."} + errs := Validate(r, "contact") + if len(errs) == 0 { + t.Fatal("expected error for invalid email") + } + if errs[0].Field != "email" { + t.Errorf("expected field=email, got %q", errs[0].Field) + } +} + +func TestValidateContact_RejectsBadService(t *testing.T) { + r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."} + errs := Validate(r, "contact") + if len(errs) == 0 || errs[0].Field != "service" { + t.Fatalf("expected service error, got %v", errs) + } +} + +func TestValidateFeedback_OmitsService(t *testing.T) { + // Feedback validates like generic: name, email, message; no service field. + r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."} + if errs := Validate(r, "feedback"); len(errs) > 0 { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestValidateGeneric_RejectsShortMessage(t *testing.T) { + r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"} + errs := Validate(r, "generic") + if len(errs) == 0 || errs[0].Field != "message" { + t.Fatalf("expected message error, got %v", errs) + } +} + +func TestValidateNewsletter_Happy(t *testing.T) { + r := &Request{Email: "jane@example.com"} + if errs := Validate(r, "newsletter"); len(errs) > 0 { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestValidateNewsletter_RequiresEmail(t *testing.T) { + r := &Request{Email: "garbage"} + errs := Validate(r, "newsletter") + if len(errs) == 0 { + t.Fatal("expected error for missing/invalid email") + } +} + +func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) { + // Newsletter type only cares about the email field. + r := &Request{Email: "jane@example.com", Name: "", Message: ""} + if errs := Validate(r, "newsletter"); len(errs) > 0 { + t.Fatalf("newsletter should ignore empty name/message, got %v", errs) + } +} + +func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) { + // Sanity: an unsupported form type (e.g. "foo") falls back to contact + // validation, not to a hard error. The hard error is enforced at the + // config layer, not in Validate itself. + r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."} + if errs := Validate(r, "foo"); len(errs) > 0 { + t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs) + } +} + +func TestValidateTrimsWhitespace(t *testing.T) { + r := &Request{ + Name: " Jane ", + Email: " jane@example.com ", + Service: " architecture ", + Message: " hello there ", + } + if errs := Validate(r, "contact"); len(errs) > 0 { + t.Fatalf("expected no errors, got %v", errs) + } + if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" { + t.Errorf("expected whitespace to be trimmed, got %+v", r) + } +} + +func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) { + // Sanity: the minLength error message in the en.json UI mirror should + // remain a simple string. Here we just check that error messages are + // human-readable, not empty. + r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"} + errs := Validate(r, "contact") + if len(errs) < 2 { + t.Fatalf("expected at least 2 errors, got %v", errs) + } + for _, e := range errs { + if strings.TrimSpace(e.Message) == "" { + t.Errorf("error message is empty for field %q", e.Field) + } + } +}