commit 3a38f00dc08cd98c1c373a1d8f3b93f25685a120 Author: Petr Balvín Date: Tue Sep 29 00:32:56 2026 +0200 feat: contact form backend for linux and freebsd servers Assisted-by: GLM 5.3 Flash diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23b48d9 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# 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 (nuntius.service) loads this file via +# EnvironmentFile= and exports every variable to nuntius at startup. +# The TOML config substitutes ${VAR_NAME} references from this file +# before parsing, so secrets can stay out of config.toml. + +# Required: SMTP password used by every form. Reference in config.toml as +# ${NUNTIUS_SMTP_PASSWORD}. The value below is a placeholder; replace it. +NUNTIUS_SMTP_PASSWORD=admin diff --git a/.gitea/workflows/race.yml b/.gitea/workflows/race.yml new file mode 100644 index 0000000..ed69a75 --- /dev/null +++ b/.gitea/workflows/race.yml @@ -0,0 +1,38 @@ +# Race, Go. Dispatched by hand, and run as part of the release gates. +# +# The race detector roughly doubles both time and memory, which the shared runner box +# cannot afford on every push. Locally it belongs to `just gates`, which runs it once per +# task; here it is an explicit decision rather than a routine. +# +# Every step is one command, so the step that fails is the gate that failed. +name: Race + +on: + workflow_dispatch: + +env: + # interpres is fetched directly from the self-hosted Gitea, not via + # proxy.golang.org, and skips the public checksum database. + GOPRIVATE: sourcedock.dev + # One core: parallelism buys no speed here and costs memory the box does not have. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + +jobs: + race: + runs-on: fedora + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install gcc + # The race detector needs cgo and the runner image carries no C compiler. + run: dnf install -y gcc + + - name: Race + run: go test -race -count=1 -timeout 10m ./... diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..1a2a267 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,379 @@ +# Release, Go binaries. Runs on version tags (v1.2.3) pushed to main. +# +# The version contract these steps implement is in the `release` skill, and its point is +# that nothing is injected: the toolchain records the tag into the binary's build +# information, so the build simply has to happen at the tag, which the trigger guarantees. +# +# The gates run in their own job, once, before the matrix, minus the race detector: race +# never runs on a push path or a tag, and the local gate raced this tree before the tag +# was cut. Putting the gates inside the matrix would run the whole suite once per target +# on the box that also hosts the forge. Each job validates the tag for itself rather than +# passing a value between jobs, so no workflow feature has to be trusted for the version +# to reach the file name. +name: Release + +on: + push: + tags: ["v*"] + +env: + # interpres is fetched directly from the self-hosted Gitea, not via + # proxy.golang.org, and skips the public checksum database. + GOPRIVATE: sourcedock.dev + # The box is shared with the forge, so parallelism is bounded on purpose. The gates job + # needs it most; the build jobs inherit it for their parallel compilation. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + +jobs: + gates: + runs-on: fedora + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install Perl + # Perl for the steps below. The install is a no-op where the package + # is already present. + run: dnf install -y perl + + - name: Validate the tag + env: + VERSION: ${{ gitea.ref_name }} + run: | + perl -e ' + my $v = $ENV{VERSION} // q{}; + $v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$} + or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n}; + print qq{tag $v\n}; + ' + + - name: Build + run: go build ./... + + - name: Format + run: | + perl -e ' + open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!}; + my @bad = <$g>; + close($g); + print @bad; + exit(@bad ? 1 : 0); + ' + + - name: Vet + run: go vet ./... + + - name: Modernise + run: go fix -diff ./... + + - name: Tests + # Equal to `packages` in the project's justfile: the logic packages hold the + # floor, and the thin cmd/ would drag it under 80 %. + run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/... + + - name: Coverage floor + run: | + perl -e ' + open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!}; + my $total; + while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} } + close($c); + die qq{no total line in coverage.out\n} unless defined $total; + printf qq{Total coverage: %s%%\n}, $total; + exit($total < 80 ? 1 : 0); + ' + + build: + runs-on: fedora + timeout-minutes: 25 + needs: gates + strategy: + fail-fast: false + matrix: + # Portable targets: amd64, arm64, loong64 and riscv64 on Linux; amd64 + # and arm64 on FreeBSD, shipped as cross compiles and runtime untested. + # freebsd/riscv64 is not a supported port and is not shipped. The build + # constraints declare linux and freebsd only, so there is no macOS, no + # Windows, no 32-bit, no wasm. Add GOOS=android GOARCH=arm64 where a + # project ships Android. + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: linux + goarch: loong64 + - goos: linux + goarch: riscv64 + - goos: freebsd + goarch: amd64 + - goos: freebsd + goarch: arm64 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install Perl + run: dnf install -y perl + + - name: Validate the tag + id: version + env: + VERSION: ${{ gitea.ref_name }} + run: | + perl -e ' + my $v = $ENV{VERSION} // q{}; + $v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$} + or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n}; + (my $nv = $v) =~ s{^v}{}; + open(my $o, q{>>}, $ENV{GITEA_OUTPUT}) or die qq{GITEA_OUTPUT: $!}; + print $o qq{version_no_v=$nv\n}; + close($o); + print qq{version $nv\n}; + ' + + - name: Build + env: + VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + # Nothing is injected. The toolchain records the tag into the binary's build + # information, so the version is right because this build happens at the tag, and + # there is no path for anyone to get wrong. -s -w only strips symbols. + go build -ldflags "-s -w" -o "bin/nuntius-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/server + + # Artefacts stay on v3: v4 and later detect Gitea as GHES and abort. + - name: Upload artefact + uses: actions/upload-artifact@v3 + with: + name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/nuntius-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} + if-no-files-found: error + + - name: Smoke test + # Only a binary matching the runner can be run here. The check is not that --version + # exits cleanly but that it reports the tag and nothing more: a build outside version + # control reports (devel), and a build whose tree was dirty reports +dirty, and both + # would otherwise be published. + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + env: + TAG: ${{ gitea.ref_name }} + BIN: bin/nuntius-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} + run: | + perl -e ' + my $want = $ENV{TAG} // die qq{ERROR: no tag\n}; + open(my $bin, q{-|}, $ENV{BIN}, q{--version}) or die qq{$ENV{BIN}: $!}; + my $got = <$bin>; + close($bin); + $got = defined $got ? $got : q{}; + chomp $got; + index($got, $want) >= 0 + or die qq{ERROR: the binary printed "$got", which does not contain $want. Version control was disabled, so there is no recorded version.\n}; + index($got, q{+dirty}) < 0 + or die qq{ERROR: the binary printed "$got". The tree was dirty at build time, which means the checkout was not the tag, or the build artefacts are not ignored.\n}; + print qq{$ENV{BIN} reports $got\n}; + ' + + release: + runs-on: fedora + timeout-minutes: 15 + needs: build + permissions: + # contents: read is required for the checkout: a job that declares any + # permissions gets a token scoped to exactly those, and releases: write + # alone leaves the fetch with no read access, which Gitea answers with + # a 404 "Repository not found". Verified on the instance 2026-09-16. + contents: read + releases: write + steps: + - uses: actions/checkout@v7 + + - name: Download all artefacts + uses: actions/download-artifact@v3 + with: + path: dist + + - name: Install Perl + run: dnf install -y perl + + - name: Extract the CHANGELOG section + env: + VERSION: ${{ gitea.ref_name }} + run: | + # Each step derives what it needs from the tag, so no value has to travel between + # jobs. + perl -e ' + my $v = $ENV{VERSION} // q{}; + $v =~ s{^v}{}; + open(my $vout, q{>}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!}; + print $vout $v; + close($vout); + open(my $in, q{<}, q{CHANGELOG.md}) or die qq{CHANGELOG.md: $!}; + my @lines = <$in>; + close($in); + my ($start, $end) = (-1, scalar @lines); + for my $i (0 .. $#lines) { + if ($start < 0) { $start = $i if $lines[$i] =~ m{^##\s+\[\Q$v\E\]} } + elsif ($lines[$i] =~ m{^##\s+\[}) { $end = $i; last } + } + $start >= 0 or die qq{ERROR: no CHANGELOG section for $v, expected a heading like: ## [$v] - YYYY-MM-DD\n}; + my @body = grep { m{\S} } @lines[$start + 1 .. $end - 1]; + @body or die qq{ERROR: the CHANGELOG section for $v is empty\n}; + open(my $out, q{>}, q{release-body.md}) or die qq{release-body.md: $!}; + print $out @body; + close($out); + printf qq{notes for %s: %d lines\n}, $v, scalar @body; + ' + + - name: Build the release request + run: | + perl -e ' + open(my $vin, q{<}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!}; + my $v = <$vin>; + close($vin); + chomp $v; + open(my $in, q{<:raw}, q{release-body.md}) or die qq{release-body.md: $!}; + my $body = do { local $/; <$in> }; + close($in); + # Byte-oriented escaping: JSON is UTF-8, so non-ASCII passes through and only the + # characters JSON forbids are rewritten. + $body =~ s/([\\"])/\\$1/g; + $body =~ s/\t/\\t/g; + $body =~ s/\r//g; + $body =~ s/\n/\\n/g; + $body =~ s/([\x00-\x08\x0b\x0c\x0e-\x1f])/sprintf(q{\u%04x}, ord($1))/ge; + my $json = sprintf(qq{{"tag_name":"v%s","name":"v%s","body":"%s","draft":false,"prerelease":false}}, $v, $v, $body); + open(my $out, q{>}, q{release.json}) or die qq{release.json: $!}; + print $out $json; + close($out); + print qq{release.json written for v$v\n}; + ' + + - name: Create the release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: | + perl -e ' + my @cmd = (q{curl}, q{-sS}, q{-o}, q{response.json}, q{-w}, q{%{http_code}}, + q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, + q{-H}, q{Content-Type: application/json}, + q{-X}, q{POST}, + qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases}, + q{--data-binary}, q{@release.json}); + open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; + my $code = <$curl>; + my $ok = close($curl); + my $exit = $? >> 8; + $code = defined $code ? $code : q{}; + $ok or die qq{ERROR: curl failed (exit $exit) calling $ENV{GITEA_SERVER_URL}\n}; + open(my $r, q{<:raw}, q{response.json}) or die qq{response.json: $!}; + my $body = do { local $/; <$r> }; + close($r); + $code eq q{201} or die qq{ERROR: the release was not created, HTTP $code: $body\n}; + $body =~ m{"id"\s*:\s*([0-9]+)} or die qq{ERROR: no release id in the response: $body\n}; + open(my $o, q{>}, q{release-id.txt}) or die qq{release-id.txt: $!}; + print $o $1; + close($o); + print qq{release id $1\n}; + ' + + - name: Upload assets + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: | + perl -e ' + open(my $f, q{<}, q{release-id.txt}) or die qq{release-id.txt: $!}; + my $id = <$f>; + close($f); + chomp $id; + my @files = grep { -f $_ } glob(q{dist/*/*}); + @files or die qq{ERROR: no assets under dist/\n}; + my $bad = 0; + for my $path (@files) { + (my $name = $path) =~ s{.*/}{}; + my @cmd = (q{curl}, q{-sS}, q{-o}, q{/dev/null}, q{-w}, q{%{http_code}}, + q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, + q{-H}, q{Content-Type: application/octet-stream}, + q{-X}, q{POST}, q{--data-binary}, q{@} . $path, + qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases/$id/assets?name=$name}); + open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; + my $code = <$curl>; + my $ok = close($curl); + my $exit = $? >> 8; + $code = defined $code ? $code : q{}; + unless ($ok) { + printf qq{%s: curl failed (exit %d)\n}, $name, $exit; + $bad = 1; + next; + } + printf qq{%s: HTTP %s\n}, $name, $code; + $bad = 1 if $code ne q{201}; + } + exit($bad ? 1 : 0); + ' + + - name: Read the assets back + # HTTP 201 from the upload alone lies: an attachment can be created + # and still stored empty. Every asset is read back through the release + # download route, and the served length must equal the sent file. + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + TAG: ${{ gitea.ref_name }} + run: | + perl -e ' + my @files = grep { -f $_ } glob(q{dist/*/*}); + @files or die qq{ERROR: no assets under dist/\n}; + my $bad = 0; + for my $path (@files) { + (my $name = $path) =~ s{.*/}{}; + my @cmd = (q{curl}, q{-sS}, q{-o}, q{asset-readback.bin}, + q{-w}, q{%{http_code} %{size_download}}, + q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, + qq{$ENV{GITEA_SERVER_URL}/$ENV{GITEA_REPOSITORY}/releases/download/$ENV{TAG}/$name}); + open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; + my $line = <$curl>; + my $ok = close($curl); + my $exit = $? >> 8; + unless ($ok) { + printf qq{%s: curl failed (exit %d)\n}, $name, $exit; + $bad = 1; + next; + } + $line = defined $line ? $line : q{}; + chomp $line; + my ($code, $served) = split q{ }, $line; + $code //= q{}; + $served //= 0; + my $sent = -s $path; + unless ($code eq q{200}) { + printf qq{%s: HTTP %s on read-back\n}, $name, $code; + $bad = 1; + next; + } + unless ($served == $sent) { + printf qq{%s: served %s bytes, sent %d\n}, $name, $served, $sent; + $bad = 1; + next; + } + printf qq{%s: read back, %d bytes\n}, $name, $served; + } + exit($bad ? 1 : 0); + ' diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..2934af3 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,96 @@ +# Test, Go. Push and pull request to development. Never on main. +# +# The gates are the ones the justfile's `gates` recipe runs, minus race: the shared +# runner box cannot afford the race detector on every push, so it lives in race.yml. +# The box is one core and 2 GB beside Gitea, so parallelism is bounded on purpose and +# everything runs in one job. Extra jobs would duplicate the checkout, the Go setup and +# the dependency download three times without buying any parallelism. +# +# Every step is one command, so the step that fails is the gate that failed, and no shell +# option has to be trusted for the run to stop. The scripted steps are Perl, not shell and +# not Python: Perl behaves the same on both runner images, there is no bashism to trip over +# on ash, and it is one language instead of two. The Perl uses builtins only, because +# Fedora packages the Perl modules separately and nothing beyond `perl` itself may be +# assumed present. +name: Test + +on: + push: + branches: [development] + pull_request: + branches: [development] + +env: + # interpres is fetched directly from the self-hosted Gitea, not via + # proxy.golang.org, and skips the public checksum database. + GOPRIVATE: sourcedock.dev + # One core: parallelism buys no speed here and costs memory the box does not have. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + +# A superseded run of the same ref is cancelled instead of queueing behind one that +# no longer matters. Verified on Gitea 1.27.1 on 2026-09-17: a queued run whose ref +# moved on is cancelled before it ever reaches the runner, while a run already +# dispatched there runs to completion. +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: fedora + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + # The module is the source of truth for the version, so it cannot drift. + go-version-file: go.mod + cache: true + + - name: Install Perl + # The runner images are minimal and Perl is not guaranteed. The install is a + # no-op where it is already present; drop this step once verified on the box. + run: dnf install -y perl + + # The steps follow the `gates` order of the justfile contract: build, format, + # vet, test. The vet gate is go vet and go fix -diff, two steps here. + - name: Build + run: go build ./... + + - name: Format + run: | + perl -e ' + open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!}; + my @bad = <$g>; + close($g); + print @bad; + exit(@bad ? 1 : 0); + ' + + - name: Vet + run: go vet ./... + + - name: Modernise + # Exits non-zero when it has something to rewrite, so it needs no output capture. + run: go fix -diff ./... + + - name: Tests + # Equal to `packages` in the project's justfile: the logic packages hold the + # floor, and the thin cmd/ would drag it under 80 %. + # The inner timeout matches the job's, so a hanging test reports its own + # goroutine dump rather than a silent job kill. + run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/... + + - name: Coverage floor + run: | + perl -e ' + open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!}; + my $total; + while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} } + close($c); + die qq{no total line in coverage.out\n} unless defined $total; + printf qq{Total coverage: %s%%\n}, $total; + exit($total < 80 ? 1 : 0); + ' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5135160 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea/ +.zcode/ + +# Build output +bin/ +*.out +coverage.html +*.test + +# Local secrets and config +.env* +!.env.example +config.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dd73881 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,107 @@ +# 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). + +## [0.1.0] - 2026-09-29 + +### Added + +- **A contact form backend in one static binary.** nuntius serves JSON form + endpoints, delivers the submissions by SMTP and writes structured JSON logs. + No Node, no Python, no Redis, no Postgres, and no third-party Go module + beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres) + TOML parser. +- **Plain HTML form posts, no JavaScript required.** Every endpoint accepts + `application/x-www-form-urlencoded` and `multipart/form-data` under the + fixed field names `name`, `email`, `service` and `message` beside the JSON + contract, and a form with `redirect_url` set answers every accepted + submission, honeypot hits included, with `303 See Other` to that page. + Failures stay machine-readable JSON. Body-parse rejections report + `invalid_body` across every content type. +- **Four form types**: `contact`, `feedback`, `newsletter` and `generic`. Each + form has its own endpoint, rate limit, CORS allowlist, honeypot field and + SMTP credentials, so one process serves many independent forms. +- **Declarative validation policy.** A form's rules are configuration: + `services` puts an allow-list on the optional `service` payload field of any + form type (the empty value always passes, the `*` entry accepts any value, + an explicit empty list accepts only the empty value), `require_name` and + `require_message` move the free-text fields in and out of the checks, and + `min_name_runes`, `max_name_runes`, `min_message_runes`, `max_message_runes` + size them. A new form shape is a matter of `config.toml`, never of Go code. +- **Newsletter double opt-in.** A signup stays pending and the subscriber + receives a confirmation mail with a single-use link + (`GET
/confirm?token=...`); only a redeemed link writes the + address into the JSONL log and notifies the owner. Unconfirmed entries + expire after `pending_ttl_seconds` (72 hours by default), tokens are + 256-bit random values stored as SHA-256 hashes, and replaying a used link + returns `410 Gone`. A repeat signup of an already recorded address gets the + same `200 ok` response with no second mail and no duplicate record. +- **Abuse defence without CAPTCHA.** A per-IP token bucket bounds each form + (capped at `server.rate_limit_max_buckets` distinct IPs, unknown IPs denied + while full), a configurable honeypot field silently drops bot submissions, + per-form CORS closes cross-site posts, and proxy headers are trusted only + with the explicit `server.trust_proxy_headers` opt-in, so a direct caller + cannot rotate identities to dodge the per-IP limit. +- **Rate-limit state survives graceful restarts.** On shutdown the per-IP + buckets are snapshotted to `data_dir/ratelimit-snapshot.json` (atomic write, + mode `0600`) and restored at startup; stale entries are dropped and a + missing or corrupt file behaves like an empty one. +- **Optional submission archive.** A form with `archive = true` writes every + accepted submission to `data_dir/archive-.jsonl` before the mail is + attempted, so a failed SMTP round-trip loses nothing; a failed append + blocks the send, so a retry cannot split the mail from its record. + Newsletter forms reject the key because they persist through the double + opt-in log already. +- **Optional automated receipt to the submitter.** A form with + `auto_reply = true` mails the submitter a short confirmation with the + owner's address as `Reply-To`; the receipt is best effort and its failure + never turns an accepted submission into an error. Newsletter forms reject + the key because their subscribers already receive the confirmation mail. +- **Telegram notifications.** A per-form `[forms.telegram]` channel + (`bot_token`, `chat_id`, optional `timeout_seconds`) posts the submission + summary into the owner's chat beside the mail. The submission counts as + delivered when either channel gets through: an SMTP outage does not + silence the bell, and only when both fail does the caller see + `send_failed`. The notification is one-way; newsletter forms reject the + key because their double opt-in flow is mail-native. +- **Operational surface.** `GET /health` reports the listener and the form + count, `GET /metrics` reports lifetime counters per form and in total + (optionally guarded by a `server.metrics_token` bearer credential), + `--check-config` validates the configuration file and exits nonzero on any + error (so a systemd `ExecStartPre` can refuse to start on a broken config), + and the `NUNTIUS_CONFIG` environment variable moves the configuration file + out of `/etc/nuntius/` for development. +- **Every operational limit is a configuration key.** `server.bind`, + `server.read_header_timeout_seconds`, `server.read_timeout_seconds`, + `server.write_timeout_seconds`, `server.idle_timeout_seconds`, + `server.shutdown_timeout_seconds`, `server.max_body_bytes`, + `server.rate_limit_max_buckets`, `server.rate_limit_cleanup_seconds`, + `server.rate_limit_max_bucket_age_seconds`, and per form + `pending_ttl_seconds` plus `smtp.timeout_seconds`. Every key is optional + with its default documented in `docs/CONFIGURATION.md`. +- **SMTP delivery with a TLS policy.** PLAIN auth over STARTTLS, implicit TLS + on port `465` (encrypted from the first byte), the `smtp.require_tls` switch + that aborts delivery when a STARTTLS port never upgrades, and a configurable + conversation timeout. Secrets stay out of `config.toml` through `${VAR}` + expansion, and an undefined variable aborts startup instead of surfacing + later as a failed SMTP auth. +- **Messages are hardened at composition.** CR/LF sequences reaching `From`, + `To`, `Subject` or `Reply-To` are replaced with spaces inside the SMTP + builder itself, and the MIME boundary is random per message with a failure + aborting the send rather than falling back to a fixed boundary. +- **Configuration fails loudly at startup.** Unknown TOML fields and unknown + form types are rejected, ports and limits are range-checked, form `path` + values are validated as static route patterns so a mistyped path cannot + widen into a wildcard, and form names are restricted to `[a-zA-Z0-9_-]` so + a crafted name cannot escape the data directory. +- **The release identity comes from the toolchain.** `--version` prints the + tag the binary was built at, a pseudo-version naming the commit otherwise, + and `(devel)` outside version control, with `+dirty` appended on a dirty + tree. Nothing is injected at build time. +- **Binaries for Linux and FreeBSD.** Every release ships `linux/amd64`, + `linux/arm64`, `linux/loong64` and `linux/riscv64`, plus `freebsd/amd64` and + `freebsd/arm64` as cross compiles; the FreeBSD binaries are runtime + untested. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8a1fc0b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,129 @@ +# Contributing + +Contributions to **nuntius** are governed by the Contributor terms +below; submitting one means you accept them. + +## Contributor terms + +1. This project belongs to its owner alone. The owner decides what is + accepted, in what form and when; the decision is final and needs no + justification. +2. By submitting a contribution you assign to Petr Balvín + all present and future copyright and + related rights in it, worldwide, for the full term of the rights, + with the right to relicense and sublicense without restriction, + including under proprietary terms. +3. Where that assignment is not effective, it counts as a perpetual, + irrevocable, royalty-free licence with the same scope. +4. To the fullest extent permitted by law, you waive any right of + attribution and integrity in the contribution. The project names no + contributors and keeps no credits list. +5. By submitting you represent that the work is yours and that you + hold the rights to assign it as above. + +## Development setup + +Requirements: Go 1.27.1 (the version recorded in `go.mod`), and +[just](https://github.com/casey/just) for the recipes. The race detector in +`just gates` needs a C compiler, so gcc must be installed. nuntius builds +and tests on Linux and FreeBSD. + +```sh +git clone https://sourcedock.dev/petrbalvin/nuntius.git +cd nuntius +just build +just test +``` + +The automated suite is hermetic: the SMTP tests run against in-process fake +servers and the storage tests against temporary directories, so no network +or SMTP account is needed to develop. A local SMTP account only matters for +manual end-to-end checks. + +## Workflow + +1. Branch from `development`. Never commit directly to `main`, which is release-only. +2. Commit in [Conventional Commits](https://www.conventionalcommits.org/) form: + `type(scope): description`, subject line only, imperative mood, lowercase after the + colon, no trailing full stop. Allowed types: `feat`, `fix`, `docs`, `style`, + `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`. +3. One logical change per commit. A refactor, a behaviour change and a formatting pass + are three commits, never one. +4. Record every user-visible change in `CHANGELOG.md` under `## [development]`. +5. Add or update tests. Coverage stays at 80 percent or more; it is a hard gate. +6. Update the documentation when the public API, the configuration or the behaviour + changes: `README.md` for the overview, `docs/CONFIGURATION.md` for keys, + `docs/API.md` for endpoints, `docs/ARCHITECTURE.md` for structure, + `docs/DEVELOPMENT.md` for tooling. +7. Never commit while `just gates` is red; run it locally first. +8. Open a pull request against `development`. + +Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The release +workflow builds the assets and publishes the release and its notes. + +## Code style + +`gofmt` and `go vet` run through `just fmt` and `just vet`, with zero diff and zero +warnings tolerated. `just vet` also runs `go fix -diff`, so modernisations are part of +the gate and not a follow-up. `just gates` is the definition of done in one command, +and the recipe file names what it contains. Errors are checked explicitly, wrapped as +`fmt.Errorf("context: %w", err)`, and nothing panics outside `main`. Error messages +are English, lowercase, with no trailing full stop, and SMTP credentials or full +request bodies never appear in a log line. + +New source files open with the project's two-line licence header, whose SPDX +identifier matches `LICENSE`. Configuration files, workflows and dotfiles do not carry +it. + +## AI contribution policy + +AI tools are welcome as productivity aids and are a normal part of modern software +development. What matters is that the contribution stays understandable, reviewable and +genuinely useful. + +- **Disclose the assistance.** If AI helped draft any part of a commit, issue, pull + request or review, say so. +- **Commit messages carry exactly one trailer**, on the line after the subject: + + ``` + Assisted-by: MODEL + ``` + + Name the model that did the work, spelled the way its maker spells it, for example + `GLM 5.3`, `DeepSeek V4.1 Flash` or `Qwen 3.8 Flash`. No `Co-Authored-By`, no `Signed-off-by`, + no other trailers, and no prose: the trailer is the disclosure. +- **Issues and pull requests** attribute the assistance in a comment, for example + `_Assisted-by: GLM 5.3 Flash_`. It does not belong in the pull request description. +- **Take responsibility.** You are accountable for the accuracy, completeness and + intent of everything you submit, whether or not AI produced it. +- **Review before marking ready.** Read the diff carefully, run it locally, and add the + tests it needs. Do not mark a pull request ready until you can defend every change in + it. +- **Quality over quantity.** Contributions that look like un-reviewed output, or whose + author cannot engage substantively during review, may be closed. +- **Preferred models.** Prefer open-weight models with transparent training data and + minimal output filtering. + +AI assists. It does not replace judgement. + +## Continuous integration + +Workflows live in `.gitea/workflows/` and run on the project's own runners: + +| Workflow | Trigger | What it does | +|---|---|---| +| Test | push or pull request to `development` | build, format check, vet, modernisation, the test suite with the coverage floor | +| Race | dispatched by hand | the suite under the race detector, as a second opinion after the local gate | +| Release | a `v*` tag | the same gates as Test, then the matrix build, the proven version and the release itself; the race detector runs locally in `just gates` before the tag is cut | + +The local equivalent is `just gates`, which is the same set plus the race detector. + +## Reporting bugs + +Open an issue at with the +version (`bin/nuntius --version`), the operating system and architecture, the +exact command or request, the full output, and the expected against the +actual behaviour. + +**Security issues do not go in the issue tracker.** Report them as +[SECURITY.md](SECURITY.md) describes. 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..f8d643f --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# Nuntius + +A small contact form backend for Linux and FreeBSD servers, made to run +behind [Caddy](https://caddyserver.com/) on a modest server: Caddy ends the +TLS and serves the form from the same origin, nuntius carries the messages. +Latin *nuntius* means "messenger": the service carries messages from web +visitors to your inbox. It serves multiple JSON form endpoints (contact, +feedback, newsletter, generic) from a single static binary, delivers +submissions via SMTP, and optionally persists newsletter signups to an +append-only JSONL log. + +## Features + +- **Single static binary**: roughly 6 MB stripped; no Node, no Python, no + Redis, no Postgres, and no third-party Go module beyond the first-party + [`interpres`](https://sourcedock.dev/petrbalvin/interpres) TOML parser. +- **Four form kinds**: `contact`, `feedback`, `newsletter`, and `generic`. + Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field. +- **No-JavaScript forms**: endpoints also accept plain `urlencoded` and + `multipart` posts and can answer `303 See Other` to a thank-you page, so an + ordinary HTML `` works with no script at all. +- **SMTP delivery**: Go stdlib `net/smtp` with `PLAIN` auth over STARTTLS + (upgraded automatically when the server advertises it, credentials never + sent in plaintext), implicit TLS on port `465`, and an optional + `require_tls` policy that aborts delivery against servers without TLS. +- **Newsletter double opt-in**: a signup stays pending until the subscriber + clicks the confirmation link mailed to them; only confirmed addresses land + in the JSONL log, unconfirmed ones expire, and repeat signups are silently + skipped. +- **Optional submission archive**: a per-form switch logs every accepted + submission to an append-only JSONL file before the mail goes out, so a + failed SMTP round-trip loses nothing. +- **Optional submitter receipt**: `auto_reply` mails the sender a short + automated acknowledgement with the owner's address as `Reply-To`. +- **Telegram notifications**: a per-form `[forms.telegram]` channel posts the + summary into your chat beside the mail; the submission counts as delivered + when either channel gets through, so an SMTP outage does not silence the + bell. +- **Server-side validation**: every field is checked before anything is + delivered: name, email (RFC 5322), an allow-listed optional `service` + field, and message length. Every limit is a configuration key, and the + server builds each form's policy from `config.toml`. +- **Token-bucket rate limiting**: per IP, per form, configurable submissions + per hour, capped in memory, persisted across graceful restarts. +- **Honeypot field**: invisible to humans, required by bots. Silently accepts + and drops spam submissions. +- **CORS allowlists**: per form, explicit origins only; disallowed origins + receive `403 Forbidden`. +- **Bounded requests**: every timeout, the body cap and the graceful shutdown + deadline are configuration keys; oversized bodies get `413`. +- **Operational endpoints**: `GET /health` and `GET /metrics` with lifetime + counters per form, optionally guarded by a bearer token. +- **Structured logging**: `log/slog` with JSON output to stdout; credentials + are never logged. +- **TOML configuration**: single file, parsed by the first-party `interpres` + library. Unknown fields and unknown form types are rejected at startup so + typos fail loudly. +- **Environment variable expansion**: `${VAR_NAME}` and `$VAR_NAME` in the + TOML file keep SMTP credentials out of version control. +- **IPv6-first**: binds to `[::]` by default, with automatic IPv4 + compatibility via dual-stack sockets. +- **Cross-platform**: pre-built binaries for Linux (amd64, arm64, loong64, + riscv64) and FreeBSD (amd64, arm64) on every release. The FreeBSD binaries + ship as cross compiles and are runtime untested. + +## Install + +Prebuilt binaries for Linux and FreeBSD are on the +[releases page](https://sourcedock.dev/petrbalvin/nuntius/releases). From +source: + +```sh +git clone https://sourcedock.dev/petrbalvin/nuntius.git +cd nuntius +just build # produces bin/nuntius +``` + +## Quick start + +```sh +# 1. Point the server at a writable config path and satisfy the secret +# referenced by the starter template. +export NUNTIUS_CONFIG=./config.toml +export NUNTIUS_SMTP_PASSWORD=admin + +# 2. Build and run; the first start writes a three-form starter config. +just build +just run # listens on [::]:8080 + +# 3. Verify. +curl http://127.0.0.1:8080/health +``` + +Edit the generated `./config.toml` with real SMTP credentials and recipient +addresses before exposing the service. In production the default location is +`/etc/nuntius/config.toml`; see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) +for the full setup. The full schema lives in +[`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). + +## Usage + +Run the server: + +```sh +export NUNTIUS_CONFIG=./config.toml # omitted: /etc/nuntius/config.toml +bin/nuntius # serves until SIGINT or SIGTERM +``` + +Validate a configuration without listening, as a systemd `ExecStartPre` +would: + +```sh +NUNTIUS_CONFIG=./config.toml bin/nuntius --check-config +``` + +Post a submission: + +```sh +curl -X POST http://127.0.0.1:8080/api/nuntius/contact \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{"name":"Jane Doe","email":"jane@example.com","message":"Hello"}' +# → 200 {"ok": true}; the mail lands in the form's recipient address +``` + +A plain HTML form posts the same fields urlencoded and, with `redirect_url` +set, receives `303 See Other` to its thank-you page. The complete endpoint +reference is [`docs/API.md`](docs/API.md); the flags and exit codes are in +[`docs/CLI.md`](docs/CLI.md). + +Nuntius speaks plain HTTP by design: put Caddy in front of it for TLS, a +same-origin form endpoint and client IPs you can trust. Two lines in the +Caddyfile are enough: + +```caddyfile +handle_path /api/nuntius/* { + reverse_proxy 127.0.0.1:8080 +} +``` + +Caddy overwrites `X-Forwarded-For` for untrusted clients, so with +`server.trust_proxy_headers = true` the rate limiter and the logs see the +real visitor. The full production setup, including the systemd unit, is in +[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). + +## Development + +```sh +just build # build +just test # the test suite with the coverage floor +just fmt # format +``` + +See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and +[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute. + +## Documentation + +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow +- [docs/API.md](docs/API.md): the API reference +- [docs/CONFIGURATION.md](docs/CONFIGURATION.md): every configuration key +- [docs/CLI.md](docs/CLI.md): flags, exit codes, and the manual page +- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): production setup, Caddy, updates +- [SECURITY.md](SECURITY.md): how to report a vulnerability + +## Licence + +MIT, see [LICENSE](LICENSE). + +Copyright © 2026 [Petr Balvín](https://petrbalvin.org) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..df01ba7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security policy + +## Supported versions + +Security fixes go to the newest release and to the `development` branch. Older releases +do not receive them. + +| Version | Supported | +|---|---| +| 1.0.x | yes | +| older releases | no | + +## Reporting a vulnerability + +**Do not open a public issue for a security problem.** A public report tells everyone +about the flaw before there is a fix. Report it privately to +**opensource@petrbalvin.org**. + +Include: + +- the version or commit you tested, and the platform +- what the problem is, and what an attacker gains from it +- the smallest reproducer you have, ideally a test or a single command +- a suggested fix, if you have one + +## What to expect + +- A human reads the report, and you get an acknowledgement. +- You are kept informed while the fix is being made, and told when it ships. +- The fix is released before the details are published, and the timing is agreed with + you. +- The reporter is credited in the release notes unless they ask to remain anonymous. + +## Out of scope + +- Findings that require the attacker to already run code as the user, or to have local + access. +- Missing hardening with no demonstrated impact. +- Flaws in a third-party dependency: report them to that project, and to this one only + when this project's use of it makes them reachable. +- Bypassing the per-IP rate limit from a rotating pool of addresses. nuntius has no + CAPTCHA, no proof-of-work and no global rate limit; put a reverse proxy with those + capabilities in front of it. +- Abuse from a compromised frontend that posts valid, allowlisted traffic at the + configured rate. The token bucket bounds the volume; nothing else can distinguish it + from real users. +- A compromised server. nuntius reads the SMTP password from the process environment, + so anyone with root or `ps` access can read it from `/proc//environ`. +- Downgrade of the SMTP transport on a STARTTLS port. Port 587 upgrades opportunistically + by design; set `smtp.require_tls = true` to make the upgrade a hard requirement. +- Absence of DKIM signing and request signing. Outbound mail is signed by the SMTP + provider, and message authenticity rests on the origin allowlist and the honeypot. +- Content carried by a configured Telegram channel. The submission summary reaches + Telegram's servers, exactly as mail reaches the SMTP provider's; forms that must + not share content with Telegram simply do not configure the channel. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..17bb702 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//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" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "sourcedock.dev/petrbalvin/nuntius/internal/config" + "sourcedock.dev/petrbalvin/nuntius/internal/handler" + "sourcedock.dev/petrbalvin/nuntius/internal/version" +) + +func main() { + showVersion := flag.Bool("version", false, "Print version and exit") + checkConfig := flag.Bool("check-config", false, "Validate the configuration file and exit without listening") + flag.Parse() + if *showVersion { + fmt.Printf("%s %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() + + if *checkConfig { + if _, err := config.Load(cfgPath); err != nil { + fmt.Fprintf(os.Stderr, "nuntius: configuration %s is invalid: %v\n", cfgPath, err) + os.Exit(1) + } + fmt.Printf("configuration OK: %s\n", cfgPath) + return + } + + 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{ + // The bind host and every timeout are configuration: an omitted + // key resolves to the value this release has always used, so the + // zero-config behaviour is unchanged. + Addr: net.JoinHostPort(cfg.Server.Bind, strconv.Itoa(cfg.Server.Port)), + Handler: withRequestLog(mux, logger, cfg.Server.TrustProxyHeaders), + ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout(), + ReadTimeout: cfg.Server.ReadTimeout(), + WriteTimeout: cfg.Server.WriteTimeout(), + IdleTimeout: cfg.Server.IdleTimeout(), + } + + 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(), cfg.Server.ShutdownTimeout()) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown error", "err", err) + h.PersistState() + os.Exit(1) + } + h.PersistState() + h.Close() + logger.Info("nuntius stopped cleanly") +} + +// withRequestLog logs each HTTP request method, path, status, and duration. +// The logged IP follows the same trust rules as rate limiting: proxy +// headers only when cfg says a trusted proxy is in front. +func withRequestLog(next http.Handler, logger *slog.Logger, trustProxy bool) 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", handler.ClientIP(r, trustProxy), + ) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..45590fa --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +package main + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStatusRecorder(t *testing.T) { + rec := httptest.NewRecorder() + sr := &statusRecorder{ResponseWriter: rec, status: http.StatusOK} + + // Default status before WriteHeader. + if sr.status != http.StatusOK { + t.Errorf("initial status = %d, want %d", sr.status, http.StatusOK) + } + + sr.WriteHeader(http.StatusNotFound) + if sr.status != http.StatusNotFound { + t.Errorf("status after WriteHeader = %d, want %d", sr.status, http.StatusNotFound) + } + + // rec should also have the status set. + if rec.Code != http.StatusNotFound { + t.Errorf("rec.Code = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestWithRequestLog(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + wrapped := withRequestLog(handler, logger, false) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.1:12345" + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..bf1c855 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,188 @@ +# API + +The HTTP surface of nuntius. Every form defined in `config.toml` exposes +the same verbs on its configured `path`; the server adds two global +endpoints. There is no authentication on the form endpoints: the browser +contract is the per-form CORS allowlist, and `GET /metrics` optionally +carries a bearer token. + +## HTTP API + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `` | validate, honeypot-check, deliver (mail plus the optional Telegram notification); newsletter forms start the double opt-in | +| `OPTIONS` | `` | CORS preflight | +| `GET` | `/confirm` | redeem a newsletter double opt-in token | +| `GET` | `/health` | listener liveness and the form count | +| `GET` | `/metrics` | lifetime counters per form and in total | + +Any other method on a form path answers `405 Method Not Allowed`. + +### `POST ` + +Two body shapes carry the same fields. The JSON contract is one JSON +object per request. The plain HTML form contract accepts +`application/x-www-form-urlencoded` and `multipart/form-data` posts under +the fixed field names `name`, `email`, `service` and `message` plus the +configured honeypot field, so an ordinary `` works +without JavaScript; file parts are ignored. + +| Field | Required | Notes | +|---|---|---| +| `name` | `contact`, `feedback`, `generic` presets; tunable with `require_name` | 2 to 100 runes by default | +| `email` | always | RFC 5322, never length-limited | +| `service` | optional | checked against the form's allow-list | +| `message` | `contact`, `feedback`, `generic` presets; tunable with `require_message` | 10 to 5,000 runes by default | + +```sh +curl -X POST http://localhost:8080/api/nuntius/contact \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{"name":"Jane Doe","email":"jane@example.com","service":"architecture","message":"Hello, I would like to discuss an engagement."}' +# → 200 {"ok": true} +``` + +```sh +curl -i -X POST http://localhost:8080/api/nuntius/contact \ + -d 'name=Jane Doe&email=jane@example.com&message=Hello, I would like to discuss an engagement.' +# → 303 See Other, Location: https://example.com/thanks (when redirect_url is set) +``` + +| Status | Body | When | +|---|---|---| +| `200` | `{"ok": true}` | Submission accepted and delivered. A form with `forms.telegram` counts as delivered when either the mail or the chat message gets through | +| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) | +| `303` | _redirect_ | A form with `redirect_url` set answers every accepted submission, honeypot hits and duplicate signups included, with `303 See Other` to the configured page | +| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid | +| `400` | `{"error": "invalid_body", "message": "..."}` | The body is unparsable: not valid JSON, or a broken form body | +| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` | +| `413` | `{"error": "body_too_large", "message": "..."}` | Body exceeds `server.max_body_bytes` | +| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form | +| `500` | `{"error": "send_failed", "message": "..."}` | Both delivery channels failed: the SMTP round-trip and, where configured, the Telegram notification | +| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter`, or any form with `archive` set: could not write the JSONL line | + +The error bodies are one shape: `error` is a machine-readable code +(`validation`, `invalid_body`, `origin_not_allowed`, `rate_limited`, +`send_failed`, `storage_failed`), `message` a human-readable summary, and +`details[]` carries the per-field validation failures: + +```json +{ + "error": "validation", + "details": [ + { "field": "name", "message": "name must be at least 2 characters" }, + { "field": "email", "message": "email is invalid" } + ] +} +``` + +Rate limiting is a per-IP token bucket per form: the bucket size equals +`rate_limit_per_hour`, it refills at `perHour / 3600` tokens per second, +and `rate_limit_per_hour: 0` disables it. State is per-process and survives +graceful restarts via the snapshot file under `data_dir/`; scaled +horizontally, each replica has its own bucket. The IP is the connection +peer address unless `server.trust_proxy_headers` opts in to proxy headers. + +#### CORS + +A browser preflight is an `OPTIONS` request with `Origin` and +`Access-Control-Request-Method`; an allowed origin gets `204` with +`Access-Control-Allow-Origin` echoed, `POST, OPTIONS` methods, +`Content-Type` allowed and `Vary: Origin`. A plain HTML form post carries +an `Origin` header like any browser POST, so a same-origin `` lists +its own origin in `allowed_origins` too. An absent `Origin` header is +allowed, which makes `curl` work out of the box; a present origin that is +not allowlisted gets `403`. Wildcard `*` is not supported. + +### `GET /confirm` + +Newsletter forms expose this endpoint next to their POST route; the link +inside every confirmation mail points here, and the response is a small +HTML page. + +| Outcome | Status | Page | +|---|---|---| +| Valid, unexpired token | `200` | "Subscription confirmed"; the address moves into the subscriber log and the owner is notified | +| Unknown, already redeemed or expired token | `410` | "Link expired", inviting a fresh signup | +| Storage failure while saving the record | `500` | "Almost there", the link stays usable for a retry | + +Tokens are single-use 256-bit random values; only their SHA-256 hashes are +stored server-side. + +#### Flow + +```mermaid +sequenceDiagram + participant Subscriber + participant Nuntius + participant Mailbox as Subscriber's inbox + Subscriber->>Nuntius: POST (email) + Nuntius->>Nuntius: store pending (SHA-256 token) + Nuntius-->>Subscriber: 200 ok + Nuntius->>Mailbox: confirmation link + Subscriber->>Nuntius: GET /confirm?token=... + Nuntius->>Nuntius: append subscriber, notify owner + Nuntius-->>Subscriber: 200 confirmed page +``` + +### `GET /health` + +```sh +curl -s http://localhost:8080/health +# → {"status":"ok","forms":3} +``` + +`status` is always `"ok"` while the process is up; there is no deep health +check. `forms` is the number of forms loaded from `config.toml`. The +endpoint is not rate-limited and is not CORS-checked. + +### `GET /metrics` + +```sh +curl -s -H "Authorization: Bearer $NUNTIUS_METRICS_TOKEN" http://localhost:8080/metrics +``` + +```json +{ + "totals": { "received": 42, "honeypot_blocked": 7, "rate_limited": 3, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 2, "validation_failed": 5, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 1, "sent": 19, "auto_reply_failed": 0, "telegram_failed": 0 }, + "forms": { + "/api/nuntius/contact": { "received": 30, "honeypot_blocked": 5, "rate_limited": 2, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 1, "validation_failed": 3, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 0, "sent": 15, "auto_reply_failed": 0, "telegram_failed": 0 } + } +} +``` + +| Counter | Meaning | +|---|---| +| `received` | POST requests that passed the origin check | +| `honeypot_blocked` | Bot submissions dropped by the honeypot field | +| `rate_limited` | Requests rejected with `429 rate_limited` | +| `origin_blocked` | Requests rejected with `403 origin_not_allowed` | +| `body_too_large` | Requests rejected with `413 body_too_large` | +| `invalid_body` | Requests rejected because the body was unreadable or unparsable, whichever shape it claimed | +| `validation_failed` | Requests rejected with field-level `400 validation` errors | +| `send_failed` | Deliveries where neither channel got through | +| `persist_failed` | Newsletter submissions or archive writes whose disk append failed | +| `duplicate_signup` | Newsletter signups skipped because the address is already recorded | +| `confirmation_sent` | Newsletter signups whose confirmation link was mailed | +| `confirmed` | Confirmation links successfully redeemed | +| `confirmation_failed` | Confirmations that could not be saved, or clicked after expiry or unknown | +| `sent` | Completed owner notifications: the submission mail for non-newsletter forms | +| `auto_reply_failed` | Receipts to the submitter that failed after an accepted submission | +| `telegram_failed` | Telegram notifications that failed; the submission stays delivered when the mail went out | + +Counters are lifetime values held in memory; they reset on restart, the +same trade-off as the rate-limit buckets. The endpoint is not +rate-limited and sends no CORS headers, so third-party pages cannot read +submission volumes. With `server.metrics_token` set it requires that +token as a bearer credential and answers `401` with a +`WWW-Authenticate: Bearer` challenge otherwise; without the key it is +open, protected at the reverse proxy like any operational surface. + +## Notes + +- Request bodies are bounded by `server.max_body_bytes` (1 MiB by default) + and read fully into memory; for a 5,000-rune message this is negligible. +- Logs are one JSON line per request (`method`, `path`, `status`, + `duration_ms`, `ip`) via `log/slog`, plus one line per sent or failed + message and one per honeypot hit. No PII beyond the client IP, and no + credentials anywhere. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ca61023 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,122 @@ +# Architecture + +How nuntius is put together. Every node, package and arrow below exists in the +source tree; nothing is aspirational. + +## Overview + +```mermaid +flowchart TD + Browser[Browser / Frontend] --> Caddy[Caddy reverse proxy] + Caddy --> CLI[cmd/server/main.go] + CLI --> Config[internal/config] + CLI --> Handler[internal/handler] + Handler --> Validate[internal/contactform] + Handler --> Email[internal/email] + Handler --> Telegram[internal/telegram] + Handler --> Storage[internal/storage] + Email -->|SMTP PLAIN| SMTP[(SMTP server)] + Telegram -->|Bot API| Chat[(Telegram chat)] + Storage -->|append JSONL| Disk[(data_dir)] +``` + +One process serves many forms. The entry point loads the configuration and +builds a `ContactHandler`; every request then moves through one pipeline: +CORS, rate limit, parse, honeypot, validate, deliver, persist. The layers +below the handler know nothing about HTTP, and the handler knows nothing +about SMTP or the Bot API beyond two small interfaces. + +## Packages + +| Package | Responsibility | +|---|---| +| `cmd/server` | Orchestration only: slog setup, config load, handler build, route registration, request-log middleware, the `http.Server` with its configured timeouts, and the graceful shutdown on SIGINT or SIGTERM. No business logic. | +| `internal/config` | The gatekeeper for everything that varies per deployment. `${VAR}` expansion runs on the raw text before `interpres` parses it, unknown fields are rejected, and `validate()` enforces required fields, unique static paths, known form types and sane policy values. `(*Form).Policy()` resolves a form's validation policy: the built-in preset of its type with the configured `services`, `require_*` and rune-limit keys applied on top. Optional keys are pointers, so an omitted key falls back to its default while an explicit zero keeps its documented meaning. | +| `internal/contactform` | The request types and the one validator in the codebase, `Validate(req, policy)`, plus `Preset(formType)` and the `NormalizeAndValidate` preset wrapper. No awareness of HTTP, SMTP, the filesystem, or the platform. | +| `internal/handler` | The HTTP pipeline: per-form routes, CORS, rate limiting, honeypot, delivery fan-out, double opt-in confirmation, metrics registry, and the state persistence across restarts. It owns no delivery or storage logic; it calls the interfaces below. | +| `internal/email` | MIME composition (multipart/alternative, per-type templates, sanitised headers, randomised boundary) and deadline-bounded SMTP delivery with per-form credentials. STARTTLS, implicit TLS on port 465 and the `require_tls` policy are the operator's transport choices, enforced in code. | +| `internal/telegram` | One-way submission summaries through the Bot API, plain text, the bot token redacted from every error. | +| `internal/storage` | The append-only JSONL stores: newsletter subscribers with a dedupe index, pending double opt-in tokens, and the optional submission archive. Malformed lines are skipped on read, never fatal. | +| `internal/version` | The release identity read from the build information; nothing is injected. | + +A new form type is a preset in `contactform.Preset`, a subject and a +`compose` branch in `internal/email`, and the wiring in `internal/config`; +every other layer is type-agnostic. + +## Data flow + +```mermaid +sequenceDiagram + participant Client + participant Handler as ContactHandler + participant Limiter as rateLimiter + participant Validate as contactform.Validate + participant Sender as email.FormSender + participant Bell as telegram.Notifier + participant Store as storage + + Client->>Handler: POST + Handler->>Handler: CORS check + Handler->>Limiter: allow(ip) + Handler->>Handler: parse body (JSON, urlencoded or multipart) + Handler->>Handler: honeypot non-empty? + Handler->>Validate: Validate(req, form.Policy()) + Handler->>Store: archive append (when enabled) + Handler->>Sender: Send(req) + Handler->>Bell: Notify(form, req) (when configured) + Handler-->>Client: 200 ok / 303 redirect / 4xx / 5xx +``` + +The pipeline is short-circuiting, and each step turns its failure into the +response the caller sees: a disallowed origin into `403 origin_not_allowed`, +an empty bucket into `429 rate_limited`, an oversized or unparsable body +into `413 body_too_large` or `400 invalid_body`, failed validation into +`400 validation` with per-field details, a failed archive append into +`500 storage_failed` before anything is sent, and a submission that reached +neither the mail nor the configured Telegram channel into `500 send_failed`. +Delivery itself is dual-channel: the mail is the record and the Telegram +notification the bell, so the submission counts as delivered when either +gets through. The honeypot turns a bot into an indistinguishable success, +and a newsletter form swaps the send for the double opt-in: a pending entry +keyed by the SHA-256 hash of a single-use token, a mailed confirmation +link, and the owner notification only after the link is redeemed. + +The client identity for rate limiting and logging is the connection peer +address unless `server.trust_proxy_headers` opts in to `X-Forwarded-For` +first hop, then `X-Real-IP`; one `ClientIP` implementation serves both the +limiter and the request log. + +## State and lifetime + +- Long-lived: the `ContactHandler`, one `rateLimiter` per form with its + cleanup goroutine, one pending store and one subscriber store per + newsletter form, one archive store per archiving form, and the metrics + registry. The binary holds no other state and opens no persistent + connections; every mail and every Telegram call dials fresh. +- The rate limiter is a per-IP token bucket refilling at + `perHour/3600` tokens per second, capped at + `server.rate_limit_max_buckets` buckets per form, cleaned every + `server.rate_limit_cleanup_seconds` of buckets idle longer than + `server.rate_limit_max_bucket_age_seconds`. On shutdown the buckets + snapshot to `data_dir/ratelimit-snapshot.json` (atomic write, mode + `0600`) and startup restores them, dropping stale entries. +- The JSONL stores append and close on every write; the subscriber log is + the record of confirmed addresses, the pending file holds hashed tokens + that expire with `pending_ttl_seconds`, and the archive holds full + submissions written before the send. There is no rotation: a `logrotate` + unit is the operator's move when a file grows. +- The metrics counters are lifetime values in memory and reset on restart; + the same trade-off as the rate-limit buckets. +- A single mutex guards each store and the limiter map, and the registry + snapshots under lock; all request-path state is safe for concurrent use. + +## Dependencies + +The one non-stdlib module is +[`interpres`](https://sourcedock.dev/petrbalvin/interpres), the first-party +TOML parser, chosen for the strict decoding that turns configuration typos +into startup failures. Everything else is the standard library: `net/http` +with its method-and-pattern `ServeMux`, `net/smtp` with `crypto/tls`, +`net/mail`, `log/slog`, `crypto/rand`, and `runtime/debug` for the recorded +version. The supply chain outside the forge is empty, which keeps the build +reproducible and the binary small. diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..f75e84f --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,62 @@ +# Command line + +The reference below is taken from the program's own `--help`. If the two +disagree, the program is right and this file is a defect. + +## Synopsis + +```sh +nuntius [--version] [--check-config] +``` + +With no flags, nuntius loads the configuration, serves the form endpoints, +and shuts down gracefully on SIGINT or SIGTERM. + +## Global flags + +| Flag | Effect | +|---|---| +| `--help`, `-h` | prints the usage | +| `--version` | prints the release and exits: the tag at a tag, a pseudo-version naming the commit otherwise, `(devel)` outside version control, `+dirty` appended on a dirty tree | +| `--check-config` | loads and validates the configuration, then exits without listening; prints `configuration OK: ` on success, names the problem and exits nonzero on any error, so it runs as a systemd `ExecStartPre` | + +Every flag is long-only, and Go's `flag` package accepts it with one or two +leading hyphens, so `-version` and `--version` are the same flag. + +## Environment and files + +`NUNTIUS_CONFIG` moves the configuration file away from +`/etc/nuntius/config.toml`. The full schema lives in +[`CONFIGURATION.md`](CONFIGURATION.md). + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | the version was printed, the configuration is valid, or the server shut down cleanly | +| `1` | the configuration is missing, invalid or fails validation, or the server failed to serve | + +## Examples + +Print the release: + +```sh +nuntius --version +# nuntius v1.0.0 +``` + +Validate a configuration without listening, as a deploy pipeline would: + +```sh +NUNTIUS_CONFIG=./config.toml nuntius --check-config +# configuration OK: ./config.toml +``` + +## Manual page + +A roff copy of this reference ships as `man/nuntius.1` in the repository +and installs with the binary; read it with: + +```sh +man ./man/nuntius.1 +``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..7f88573 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,214 @@ +# Configuration + +nuntius reads its configuration from one TOML file, `/etc/nuntius/config.toml` +by default. The `NUNTIUS_CONFIG` environment variable points the server at +any other path. When the file does not exist, the first start writes a +three-form starter template there. + +## File + +A complete example with every key present at its default value, so the file +documents itself: + +```toml +data_dir = "./data" + +[server] +port = 8080 +bind = "::" +read_header_timeout_seconds = 10 +read_timeout_seconds = 15 +write_timeout_seconds = 30 +idle_timeout_seconds = 60 +shutdown_timeout_seconds = 15 +max_body_bytes = 1048576 +rate_limit_max_buckets = 32768 +rate_limit_cleanup_seconds = 3600 +rate_limit_max_bucket_age_seconds = 7200 +trust_proxy_headers = false +#metrics_token = "${NUNTIUS_METRICS_TOKEN}" + +[[forms]] +name = "contact" +path = "/api/nuntius/contact" +type = "contact" +redirect_url = "" +archive = false +auto_reply = false +to = "you@example.com" +from = "noreply@example.com" +rate_limit_per_hour = 10 +honeypot_field = "website" +allowed_origins = ["https://example.com"] +services = ["architecture", "ai", "infrastructure", "software", "unix", "other"] +require_name = true +require_message = true +min_name_runes = 2 +max_name_runes = 100 +min_message_runes = 10 +max_message_runes = 5000 +subject_prefix = "nuntius" +email_brand = "nuntius" + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "noreply@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" + require_tls = false + timeout_seconds = 20 + + [forms.telegram] + bot_token = "${NUNTIUS_TELEGRAM_TOKEN}" + chat_id = "123456789" + timeout_seconds = 10 + +[[forms]] +name = "newsletter" +path = "/api/nuntius/newsletter" +type = "newsletter" +to = "you@example.com" +from = "noreply@example.com" +rate_limit_per_hour = 100 +honeypot_field = "bot_email" +allowed_origins = ["https://example.com"] +pending_ttl_seconds = 259200 + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "noreply@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" +``` + +The newsletter form shows the keys that apply to it; `archive`, +`auto_reply` and `telegram` do not apply to newsletter forms and are +rejected at startup. + +## Keys + +| Key | Type | Default | Effect | +|---|---|---|---| +| `data_dir` | string | `"./data"` | Directory for the JSONL logs; created on first write. Relative to the working directory. | +| `server.port` | int | `8080` | HTTP listen port. Must be 1 to 65535. | +| `server.bind` | string | `"::"` | Host part of the listen address; `"::"` is the dual-stack wildcard, an address binds that one only. Must not contain whitespace. | +| `server.read_header_timeout_seconds` | int | `10` | Budget for reading the request headers (Slowloris defence). `0` switches it off. | +| `server.read_timeout_seconds` | int | `15` | Budget for reading the request body. `0` switches it off. | +| `server.write_timeout_seconds` | int | `30` | Budget for writing the response. `0` switches it off. | +| `server.idle_timeout_seconds` | int | `60` | Keep-alive idle budget. `0` switches it off. | +| `server.shutdown_timeout_seconds` | int | `15` | Graceful shutdown budget before in-flight connections are cut. | +| `server.max_body_bytes` | int | `1048576` | Request body cap in bytes; oversized bodies get `413`. Must be at least 1. | +| `server.rate_limit_max_buckets` | int | `32768` | Per-form cap on distinct IP buckets; once full, unknown IPs are denied until cleanup frees stale entries. Must be at least 1. | +| `server.rate_limit_cleanup_seconds` | int | `3600` | Interval between bucket cleanup sweeps. Must be at least 1. | +| `server.rate_limit_max_bucket_age_seconds` | int | `7200` | Age at which an idle bucket is dropped. Must be at least 1. | +| `server.trust_proxy_headers` | bool | `false` | Read client IPs from `X-Forwarded-For` / `X-Real-IP` instead of the connection peer address. Enable only behind a proxy that overwrites these headers (Caddy 2.5+ does by default). | +| `server.metrics_token` | string | none | Bearer token guarding `GET /metrics`. Empty keeps the endpoint open. Supports `${VAR}` expansion. | +| `forms[].name` | string | yes | Internal identifier; appears in logs and file names. Letters, digits, hyphens and underscores only. | +| `forms[].path` | string | yes | HTTP path the form is served on. Must start with `/`, be unique, and be a static pattern with no empty segments. | +| `forms[].type` | string | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`; selects the behaviour bundle, the mail template and the default validation policy. | +| `forms[].smtp` | table | yes | SMTP connection settings: `host`, `port`, `user`, `password`, `require_tls`, `timeout_seconds` (default `20`). Port `465` speaks implicit TLS; `587` upgrades via STARTTLS, which `require_tls = true` makes mandatory. | +| `forms[].to` | string | yes | Recipient address (`To:` header). | +| `forms[].from` | string | `smtp.user` | Sender address (`From:` header). | +| `forms[].redirect_url` | string | none | Turns the form into a plain HTML form target: accepted submissions answer `303 See Other` with this location. Failures stay JSON. Must not contain whitespace. | +| `forms[].archive` | bool | `false` | Persist every accepted submission to `data_dir/archive-.jsonl` before the mail is attempted. Not valid on `newsletter` forms. | +| `forms[].auto_reply` | bool | `false` | Mail the submitter a short automated receipt with `Reply-To` set to `to`. Best effort; failures land in the `auto_reply_failed` counter. Not valid on `newsletter` forms. | +| `forms[].telegram` | table | none | Telegram notification channel: `bot_token` (supports `${VAR}`), `chat_id` (numeric id or `@channelusername`), `timeout_seconds` (default `10`). Not valid on `newsletter` forms. | +| `forms[].rate_limit_per_hour` | int | `10` | Submissions per IP per hour. `0` disables. | +| `forms[].honeypot_field` | string | `"website"` | Name of the invisible form field. Empty string disables the honeypot. | +| `forms[].allowed_origins` | []string | `[]` | CORS allowlist, one origin per entry; a same-origin HTML form lists its own origin too. | +| `forms[].services` | []string | type default | Allow-list for the optional `service` payload field, on any form type. | +| `forms[].require_name` | bool | type default | Switch the name field into the validation. | +| `forms[].require_message` | bool | type default | Switch the message field into the validation. | +| `forms[].min_name_runes` | int | `2` | Minimum name length in runes; 0 or more. | +| `forms[].max_name_runes` | int | `100` | Maximum name length in runes; at least `min_name_runes` and 1 or more. | +| `forms[].min_message_runes` | int | `10` | Minimum message length in runes; 0 or more. | +| `forms[].max_message_runes` | int | `5000` | Maximum message length in runes; at least `min_message_runes` and 1 or more. | +| `forms[].pending_ttl_seconds` | int | `259200` | Double opt-in lifetime for newsletter forms (72 hours). Must be at least 1. | +| `forms[].subject_prefix` | string | `"nuntius"` | The `[/]` segment of every mail subject. Empty string drops the segment. | +| `forms[].email_brand` | string | `"nuntius"` | The `Delivered by ` footer in every mail. Empty string drops the footer. | + +The email address is always required and always checked against RFC 5322: +every form delivers mail and needs a reply-to address, so there is no key +to switch that check off. + +### Form types + +The four types are behaviour bundles. Each bundles a mail template, a +subject text, and a default validation policy; every part of that policy +can be overridden per form with the keys above, so a form whose needs +differ is a configuration matter, never a code change. + +| Type | Default required fields | Email subject | Notes | +|---|---|---|---| +| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/] Contact form submission`, or `[nuntius/][] ...` if `service` is set | The default. `service` is checked against the allow-list: the built-in list by default, or the form's own `services` key. | +| `feedback` | `name`, `email`, `message` | `[nuntius/] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. A `services` key opts the field into the validation and the mail. | +| `newsletter` | `email` | `[nuntius/] New newsletter subscriber` | Double opt-in: the address waits in `data_dir/newsletter--pending.json` until the emailed link (`GET /confirm?token=...`) is redeemed. | +| `generic` | `name`, `email`, `message` | `[nuntius/] New submission` | Escape hatch for one-off forms; pair it with the per-form policy keys to shape it freely. | + +### The service allow-list + +The `service` payload field is optional everywhere and the empty value +always passes, so a frontend that never sends it needs no configuration at +all. When a value is sent, it is checked against the form's allow-list: + +| Configuration | Accepted service values | +|---|---| +| key omitted | `contact`: the built-in list below. Other types: the field is not validated at all. | +| `services = ["consulting", "support"]` | only the listed values (plus the empty value), on any form type | +| `services = ["*"]` | any value | +| `services = []` | only the empty value | + +The built-in list for `contact`: `architecture`, `ai`, `infrastructure`, +`software`, `unix`, `other`. Anything else triggers a `400 validation` +error. Entries must not be empty and must not carry surrounding +whitespace; such a config is rejected at startup. + +### The newsletter log + +A confirmed signup is one JSON line in +`data_dir/newsletter-.jsonl`: + +```json +{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"} +``` + +`email` is the trimmed address, `ip` the client IP as seen by nuntius, and +`created_at` a UTC timestamp set at append time. The log is append-only, +survives restarts, and is never rotated automatically: add a `logrotate` +unit if it grows. Malformed lines are skipped on read, so a partial write +can never brick the file. + +## Precedence + +The configuration is one file plus the environment, in this order: + +1. `NUNTIUS_CONFIG` chooses the file; without it the path is + `/etc/nuntius/config.toml`. +2. `${VAR_NAME}` and `$VAR_NAME` references in the raw text expand from the + process environment before parsing. Expansion is strict: a referenced + variable that is not set aborts startup with an error naming it, while a + set-but-empty value expands to the empty string and a dollar sign with no + variable name stays literal. Comment lines are never expanded. +3. Every key omitted from the file falls back to the default in the table + above; an explicit value, including a disabling zero, always wins. + +Secrets belong in the environment, not the file: reference them as +`${NUNTIUS_SMTP_PASSWORD}` and provide them through the systemd +`EnvironmentFile=` (`/etc/nuntius/.env`, mode `0600`, which +`scripts/install.pl` sets up). Credentials are never logged. + +## Validation + +A wrong value stops the program at startup with a message naming the key; +nothing is silently ignored and nothing falls back silently. The loader +rejects unknown TOML fields and unknown form types, ports outside 1 to +65535, negative timeouts and limits, form paths that are not static route +patterns, form names with characters outside `[a-zA-Z0-9_-]`, policy +windows where the maximum sits below the minimum, service entries that are +empty or carry surrounding whitespace, `redirect_url` with whitespace, +`archive`, `auto_reply` or `telegram` on newsletter forms, and a Telegram +channel without `bot_token` or `chat_id`. At request time the payload is +validated against the form's policy: limits are counted in runes after +whitespace is trimmed, the email address must parse as RFC 5322, and the +error messages quote the configured numbers. See [`API.md`](API.md) for the +response shapes. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..8b3c0b8 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,181 @@ +# Deployment + +How nuntius runs in production: a single static binary behind Caddy, under +systemd, with its state in one data directory. + +## Topology + +```mermaid +flowchart LR + Browser[Visitor's browser] --> Caddy[Caddy :443] + Caddy -->|reverse_proxy /api/nuntius/*| Nuntius[nuntius :8080] + Systemd[Systemd unit] -.->|manages| Nuntius + Nuntius -->|SMTP PLAIN| SMTP[(SMTP provider)] + Nuntius -->|append JSONL| Disk[(/var/lib/nuntius/data)] +``` + +nuntius binds `[::]:8080` by default: the dual-stack wildcard that accepts +both IPv4 and IPv6 connections. The host part is the `server.bind` key in +`config.toml`; set it to `127.0.0.1` for a loopback-only listener, which is +the recommended shape behind a proxy. + +### Reverse proxy + +Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`): + +```caddyfile +handle_path /api/nuntius/* { + reverse_proxy 127.0.0.1:8080 +} +``` + +Then validate and reload: + +```sh +sudo caddy validate +sudo systemctl reload caddy +``` + +Caddy overwrites `X-Forwarded-For` for untrusted clients, which is what the +`server.trust_proxy_headers` opt-in expects. Frontends on the same site +post to the same origin and need no extra URL; the per-form +`allowed_origins` list carries every origin that may post. + +## Requirements + +- A Linux or FreeBSD host with systemd and a C compiler-free runtime: the + binary is static, nothing else ships with it. +- The `nuntius` system user and group, the data directory + `/var/lib/nuntius`, the configuration at `/etc/nuntius/config.toml`, and + the environment file `/etc/nuntius/.env` (all created by the installer or + the manual steps below). +- Port 8080 locally, 443 publicly through Caddy. +- An SMTP account (any standards-compliant provider, such as Proton Mail or + Thundermail). + +| File | Owner | Mode | Purpose | +|---|---|---|---| +| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary | +| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory and data root | +| `/var/lib/nuntius/data/` | `nuntius` | `0750` | JSONL logs and the rate-limit snapshot (created at runtime) | +| `/etc/nuntius/config.toml` | `root` | `0644` | The configuration (auto-generated, then edited) | +| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets, loaded via `EnvironmentFile=` | +| `/etc/systemd/system/nuntius.service` | `root` | `0644` | The systemd unit | + +## Build + +```sh +just build +``` + +Copy the artefacts to the server: + +```sh +rsync -avz bin/nuntius nuntius.service .env.example scripts/install.pl user@your-server:/tmp/nuntius/ +``` + +## Run + +The installer does the whole sequence and is idempotent; re-running is +safe and it never starts the service, so you review the configuration +first: + +```sh +ssh user@your-server +cd /tmp/nuntius +sudo perl install.pl +``` + +It creates the `nuntius` system user, `/var/lib/nuntius`, installs the +binary and the unit, generates the starter configuration, seeds +`/etc/nuntius/.env` from `.env.example`, and enables the service. Then +review and start: + +```sh +sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD +sudo $EDITOR /etc/nuntius/config.toml # real SMTP settings and allowed origins +sudo systemctl start nuntius +sudo journalctl -u nuntius -f +``` + +Without the installer, the same steps by hand: create the user and +directory as above, `install -m 0755` the binary, `install -m 0644` the +unit, start the binary once to generate `/etc/nuntius/config.toml`, +`systemctl daemon-reload && systemctl enable --now nuntius`. + +## Service unit + +The unit lives at `nuntius.service` in the repository root and installs to +`/etc/systemd/system/nuntius.service`; the copy in this document would +drift, the pointer does not. It runs as the `nuntius` user with +`WorkingDirectory=/var/lib/nuntius` (so the default `data_dir = "./data"` +resolves to `/var/lib/nuntius/data`), loads `/etc/nuntius/.env`, validates +the configuration through `ExecStartPre=/usr/local/bin/nuntius +--check-config`, and restarts on failure. The service is enabled, not +started, after installation. + +## Production configuration + +The keys that differ from the defaults in production: `allowed_origins` +carries the real frontend origins, the `[forms.smtp]` block carries the +real host and credentials, `server.metrics_token` guards the metrics +endpoint when it is exposed, and `server.bind` stays `::` or moves to a +loopback address behind the proxy. Secrets live in `/etc/nuntius/.env` and +reach the configuration through `${VAR}` references; no secret value +belongs in `config.toml` or in this repository. + +## Upgrade + +```sh +cd nuntius +just build +rsync -avz bin/nuntius user@your-server:/tmp/nuntius/ +ssh user@your-server 'sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius && sudo systemctl restart nuntius' +``` + +The service does not reload `config.toml`; after editing it, restart. A +broken configuration exits 1 and systemd does not keep it up, so fix the +reported key and start again. `just gates` before the new binary ships. + +## Rollback + +Reinstall the previous release's binary from the releases page and +restart; the configuration, the JSONL logs and the rate-limit snapshot +carry over untouched. Rehearsed exactly this far, and no further: there is +no automated rollback path. The reverse of the whole installation is +`systemctl disable --now nuntius`, removing the unit, the binary, +`/etc/nuntius` and `/var/lib/nuntius`, and deleting the user. + +## Monitoring + +A healthy instance answers the health endpoint and writes one JSON request +line per submission: + +```sh +systemctl status nuntius +/usr/local/bin/nuntius --version +curl -s http://127.0.0.1:8080/health +journalctl -u nuntius -n 50 --no-pager +curl -i https://your-domain.example/api/nuntius/health +``` + +An end-to-end check through the proxy: + +```sh +curl -X POST https://your-domain.example/api/nuntius/contact \ + -H "Content-Type: application/json" \ + -H "Origin: https://your-domain.example" \ + -d '{"name":"Test","email":"test@example.com","message":"hello there, this is a test message"}' +``` + +`{"ok": true}` plus a `request` line with `status=200` in the journal means +the deploy is live. Subscriber counts come from the log: + +```sh +wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl +jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u +``` + +Back up `/etc/nuntius/config.toml`, `/etc/nuntius/.env` and +`/var/lib/nuntius/data/*.jsonl` with the usual jobs; the log is +append-only and trivially archivable. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..268d378 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,130 @@ +# Development + +How to work on nuntius. + +## Prerequisites + +- Go 1.27.1, the version recorded in `go.mod`, the newest stable release. +- [just](https://github.com/casey/just) for the recipes. +- gcc for `just gates`: the race detector needs cgo. + +Nothing else. There is no Node, no Python, and no third-party Go module +beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres) +TOML parser. + +## Setup + +```sh +git clone https://sourcedock.dev/petrbalvin/nuntius.git +cd nuntius +just build +``` + +Configuration lives in TOML, read from `/etc/nuntius/config.toml` by +default; the `NUNTIUS_CONFIG` environment variable points the server at any +file you like, which keeps development free of root-owned paths: + +```sh +export NUNTIUS_CONFIG=./config.toml +export NUNTIUS_SMTP_PASSWORD=admin # the placeholder the template references +just run # first start writes the starter config +``` + +The starter forms point at example.com addresses, so submissions return +`500 send_failed` until `config.toml` carries real SMTP settings; strict +environment expansion means every `${VAR}` the config references must be set +before startup. + +## Recipes + +Every recipe in the project's file, taken from the file itself: + +| Recipe | What it does | +|--------|--------------| +| `just default` | prints the recipe list | +| `just build` | `CGO_ENABLED=0 go build -trimpath -buildvcs=true -ldflags "-s -w"` into `bin/nuntius` | +| `just test` | the suite with no test cache, then the 80 % coverage floor over `./internal/...` | +| `just race` | the same suite under the race detector | +| `just unit` | fast scoped run for iterating: cached, no race, no coverage | +| `just fuzz` | time-boxed fuzz of one target in one package | +| `just bench` | benchmarks | +| `just fmt` | `gofmt -w .` | +| `just fmt-check` | zero gofmt diff; prints nothing when everything is formatted | +| `just vet` | `go vet ./...` and `go fix -diff ./...` | +| `just gates` | build, fmt-check, vet, test, race: the definition of done, once per task | +| `just clean` | removes `bin/` and `coverage.out` | +| `just install` | builds, then copies the binary into `~/.local/bin` (override with `BINDIR`) | +| `just uninstall` | removes the installed binary | +| `just run` | `go run -buildvcs=true ./cmd/server` | +| `just dev` | the same as `run`: nuntius carries no watch or reload tool | +| `just coverage-html` | HTML coverage report from the gate's profile; an extension, not a gate | + +The test, race, unit and fuzz recipes run under a cgroup memory fence, so a +runaway test dies at the ceiling instead of eating the machine. + +## Running a single test + +```sh +go test -run TestName ./internal/handler/ +``` + +Add `-v` for the sub-test names, and `-race` when the change touches +concurrency. `-count=1` defeats the test cache when a result looks stale; +`just unit` keeps the cache on purpose, because a scoped iterating run wants +to be instant. + +## Coverage + +```sh +just test +go tool cover -func=coverage.out +``` + +The `total:` line is the number that matters, and it stays at 80 percent or +more. Coverage is measured over the logic packages only; `cmd/server` is +thin glue around them. For the HTML map: + +```sh +just coverage-html +``` + +## Benchmarks + +```sh +just bench +``` + +Benchmark on an idle machine, and compare only runs made in one process +against each other: runs in separate processes, or on a loaded machine, +differ by more than the effects being measured. + +## Fuzzing + +Two fuzz targets exist: `FuzzValidate` in `internal/contactform` and +`FuzzLoadConfig` in `internal/config`. They are exploration, never a gate; +time-box one explicitly: + +```sh +just fuzz FuzzValidate ./internal/contactform 30s +``` + +## Debugging the build + +```sh +go build -gcflags='-m' ./... # inlining decisions +go build -gcflags='-S' ./... # what the compiler generated +``` + +## Continuous integration + +Workflows live in `.gitea/workflows/` and run on the project's own runners. +They are written by hand rather than through `just`, but they enforce the +same set of gates, so a green `just gates` locally is the fastest way to a +green pipeline. The per-workflow table is in +[CONTRIBUTING.md](../CONTRIBUTING.md). + +## Releases + +Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. +The tag drives the release workflow, which builds the assets and publishes +the notes it extracted from `CHANGELOG.md`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..485c681 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module sourcedock.dev/petrbalvin/nuntius + +go 1.27.1 + +require sourcedock.dev/petrbalvin/interpres/v2 v2.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c5dcce8 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +sourcedock.dev/petrbalvin/interpres/v2 v2.0.0 h1:DkWtszKv4BTafedilEKY5QuTaOSv/J8gpcxHoi6oHnw= +sourcedock.dev/petrbalvin/interpres/v2 v2.0.0/go.mod h1:SCMhffAzwoPrmeHKHeAar7dKm58QKKMdL8qhaZmc4ds= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1f4cf5f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,850 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//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. It 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" + "time" + + "sourcedock.dev/petrbalvin/interpres/v2" + "sourcedock.dev/petrbalvin/nuntius/internal/contactform" +) + +// Default values applied by validate() when fields are omitted. +const ( + DefaultPort = 8080 + DefaultDataDir = "./data" + DefaultRateLimitPerHour = 10 + DefaultHoneypotField = "website" + + // Bind is the host part of the listen address: the dual-stack IPv6 + // wildcard, rendered as "[::]:port" once the port is joined on. + DefaultBind = "::" + + // HTTP server timeouts, in seconds. An explicit 0 keeps its net/http + // meaning: the timeout is switched off. + DefaultReadHeaderTimeoutSeconds = 10 + DefaultReadTimeoutSeconds = 15 + DefaultWriteTimeoutSeconds = 30 + DefaultIdleTimeoutSeconds = 60 + DefaultShutdownTimeoutSeconds = 15 + + // Request body cap and the rate limiter's memory mechanics. + DefaultMaxBodyBytes = 1 << 20 + DefaultRateLimitMaxBuckets = 32768 + DefaultRateLimitCleanupSeconds = 3600 + DefaultRateLimitMaxBucketAgeSeconds = 7200 + + // Newsletter double opt-in lifetime: 72 hours. + DefaultPendingTTLSeconds = 72 * 3600 + + // Telegram API call bound, in seconds. + DefaultTelegramTimeoutSeconds = 10 + + // SMTP conversation bound, in seconds. + DefaultSMTPTimeoutSeconds = 20 + + // Mail branding: the subject prefix and the "Delivered by" footer. + DefaultSubjectPrefix = "nuntius" + DefaultEmailBrand = "nuntius" +) + +// validFormTypes lists the form types nuntius knows how to handle. +var validFormTypes = map[string]bool{ + "contact": true, + "feedback": true, + "newsletter": true, + "generic": true, +} + +// IsValidFormType reports whether t is a supported form type. +func IsValidFormType(t string) bool { + return validFormTypes[t] +} + +// 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"` + // Bind is the host part of the listen address, resolved to DefaultBind + // by validate() when omitted. "::" binds the dual-stack wildcard, an + // address or hostname binds that one only. + Bind string `toml:"bind"` + // TrustProxyHeaders opts in to client IPs read from X-Forwarded-For / + // X-Real-IP instead of the connection peer address. It is off by + // default because those headers are client-controlled: without a + // trusted reverse proxy in front of nuntius that overwrites them, + // enabling this would let any caller forge its rate-limit identity. + TrustProxyHeaders bool `toml:"trust_proxy_headers"` + + // MetricsToken guards GET /metrics with a bearer token. Empty (the + // default) keeps the endpoint open, protected at the reverse proxy + // like any other operational surface. The value supports the same + // ${VAR} expansion as the rest of the file. + MetricsToken string `toml:"metrics_token"` + + // The timeout fields are pointers so that an omitted key falls back to + // its default while an explicit 0 keeps its net/http meaning: the + // timeout is switched off. The getters return durations. + + ReadHeaderTimeoutSeconds *int `toml:"read_header_timeout_seconds"` + ReadTimeoutSeconds *int `toml:"read_timeout_seconds"` + WriteTimeoutSeconds *int `toml:"write_timeout_seconds"` + IdleTimeoutSeconds *int `toml:"idle_timeout_seconds"` + ShutdownTimeoutSeconds *int `toml:"shutdown_timeout_seconds"` + + // MaxBodyBytes caps the JSON request body. An explicit value must be + // at least 1 byte. + MaxBodyBytes *int `toml:"max_body_bytes"` + + // Rate limiter mechanics: the per-form cap on distinct IP buckets, the + // cleanup tick and the age at which an idle bucket is dropped (also + // applied when a restart restores the persisted buckets). Explicit + // values must be at least 1. + RateLimitMaxBuckets *int `toml:"rate_limit_max_buckets"` + RateLimitCleanupSeconds *int `toml:"rate_limit_cleanup_seconds"` + RateLimitMaxBucketAgeSeconds *int `toml:"rate_limit_max_bucket_age_seconds"` +} + +// ReadHeaderTimeout returns the budget for reading the request headers. +func (s ServerConfig) ReadHeaderTimeout() time.Duration { + return secondsOrDefault(s.ReadHeaderTimeoutSeconds, DefaultReadHeaderTimeoutSeconds) +} + +// ReadTimeout returns the budget for reading the request body. +func (s ServerConfig) ReadTimeout() time.Duration { + return secondsOrDefault(s.ReadTimeoutSeconds, DefaultReadTimeoutSeconds) +} + +// WriteTimeout returns the budget for writing the response. +func (s ServerConfig) WriteTimeout() time.Duration { + return secondsOrDefault(s.WriteTimeoutSeconds, DefaultWriteTimeoutSeconds) +} + +// IdleTimeout returns the keep-alive idle budget. +func (s ServerConfig) IdleTimeout() time.Duration { + return secondsOrDefault(s.IdleTimeoutSeconds, DefaultIdleTimeoutSeconds) +} + +// ShutdownTimeout returns the graceful shutdown budget. +func (s ServerConfig) ShutdownTimeout() time.Duration { + return secondsOrDefault(s.ShutdownTimeoutSeconds, DefaultShutdownTimeoutSeconds) +} + +// BodyLimit returns the request body cap in bytes. +func (s ServerConfig) BodyLimit() int { + if s.MaxBodyBytes == nil { + return DefaultMaxBodyBytes + } + return *s.MaxBodyBytes +} + +// MaxRateLimitBuckets returns the per-form cap on distinct IP buckets. +func (s ServerConfig) MaxRateLimitBuckets() int { + if s.RateLimitMaxBuckets == nil { + return DefaultRateLimitMaxBuckets + } + return *s.RateLimitMaxBuckets +} + +// RateLimitCleanup returns the interval between bucket cleanup sweeps. +func (s ServerConfig) RateLimitCleanup() time.Duration { + return secondsOrDefault(s.RateLimitCleanupSeconds, DefaultRateLimitCleanupSeconds) +} + +// RateLimitMaxBucketAge returns the age at which an idle bucket is dropped. +func (s ServerConfig) RateLimitMaxBucketAge() time.Duration { + return secondsOrDefault(s.RateLimitMaxBucketAgeSeconds, DefaultRateLimitMaxBucketAgeSeconds) +} + +// secondsOrDefault converts an optional seconds value into a duration: a +// nil pointer yields the default, an explicit 0 stays 0 (the documented +// "switched off" meaning), anything else is the value in seconds. +func secondsOrDefault(v *int, def int) time.Duration { + if v == nil { + return time.Duration(def) * time.Second + } + return time.Duration(*v) * time.Second +} + +// Form is one contact / feedback / newsletter endpoint. +// +// Optional numeric and string options are pointers so that an omitted key +// can fall back to its default while an explicit zero value keeps its +// documented meaning (`rate_limit_per_hour = 0` disables the limit, +// `honeypot_field = ""` disables the honeypot). +// +// The validation keys (services, require_name, require_message and the +// four rune limits) override the built-in preset of the form's type, so a +// new form shape is a matter of configuration, never of Go code. +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"` + + // RedirectURL turns the form into a plain HTML form target: an + // accepted submission answers 303 See Other with this location, so + // the form works without JavaScript. Empty (the default) keeps the + // JSON contract. + RedirectURL string `toml:"redirect_url"` + + // Archive persists every accepted submission to + // data_dir/archive-.jsonl before the mail is attempted, so a + // failed SMTP round-trip loses nothing. Newsletter forms always + // persist through the double opt-in log instead and reject the key. + Archive bool `toml:"archive"` + + // AutoReply mails the submitter a short receipt confirming the + // message arrived. The submitter's address is always validated + // first and the request is rate limited like any other, so the + // receipt cannot be turned into a mail relay. Newsletter forms + // reject the key: their subscribers already receive the + // confirmation mail. + AutoReply bool `toml:"auto_reply"` + + // Telegram is the optional notification channel: the submission + // summary lands in the chat alongside the mail. The submission + // counts as delivered when either channel gets through. Newsletter + // forms reject the key: the double opt-in flow is mail-native. + Telegram *TelegramConfig `toml:"telegram"` + + // Services is the allow-list for the optional service payload field. + // A nil value (the key omitted) keeps the type's preset behaviour: the + // built-in list for contact, no service validation for the other + // types. An explicit list applies to any type; the empty value always + // passes and the "*" entry accepts any value. An explicit empty list + // accepts only the empty value. + Services []string `toml:"services"` + + // RequireName and RequireMessage switch the two free-text fields into + // the validation. The email address is always required. + RequireName *bool `toml:"require_name"` + RequireMessage *bool `toml:"require_message"` + + // Length limits in runes; the preset values are 2 and 100 for the name + // and 10 and 5000 for the message. + MinNameRunes *int `toml:"min_name_runes"` + MaxNameRunes *int `toml:"max_name_runes"` + MinMessageRunes *int `toml:"min_message_runes"` + MaxMessageRunes *int `toml:"max_message_runes"` + + // PendingTTLSeconds is the double opt-in lifetime for newsletter + // forms; the default is 72 hours. + PendingTTLSeconds *int `toml:"pending_ttl_seconds"` + + // SubjectPrefix carries the "[nuntius/]" segment of every mail + // subject; an explicit empty string drops the segment. + SubjectPrefix *string `toml:"subject_prefix"` + + // EmailBrand carries the "Delivered by " footer; an explicit + // empty string drops the footer. + EmailBrand *string `toml:"email_brand"` +} + +// RateLimit returns the effective submissions-per-hour cap for the form. +// An omitted value yields DefaultRateLimitPerHour; an explicit 0 disables +// rate limiting. Validate rejects negative values at load time. +func (f *Form) RateLimit() int { + if f.RateLimitPerHour == nil { + return DefaultRateLimitPerHour + } + return *f.RateLimitPerHour +} + +// Honeypot returns the name of the hidden anti-bot field. An omitted value +// yields DefaultHoneypotField; an explicit empty string disables the +// honeypot for the form. +func (f *Form) Honeypot() string { + if f.HoneypotField == nil { + return DefaultHoneypotField + } + return *f.HoneypotField +} + +// PendingTTL returns the double opt-in lifetime for newsletter forms. +func (f *Form) PendingTTL() time.Duration { + if f.PendingTTLSeconds == nil { + return time.Duration(DefaultPendingTTLSeconds) * time.Second + } + return time.Duration(*f.PendingTTLSeconds) * time.Second +} + +// EmailSubjectPrefix returns the "[/]" segment of every +// mail subject; an explicit empty string disables the segment. +func (f *Form) EmailSubjectPrefix() string { + if f.SubjectPrefix == nil { + return DefaultSubjectPrefix + } + return *f.SubjectPrefix +} + +// Brand returns the "Delivered by " footer name; an explicit empty +// string disables the footer. +func (f *Form) Brand() string { + if f.EmailBrand == nil { + return DefaultEmailBrand + } + return *f.EmailBrand +} + +// Policy builds the validation policy for this form: the built-in preset +// for its type with every configured key overriding the preset value. +func (f *Form) Policy() contactform.Policy { + p := contactform.Preset(f.Type) + if f.RequireName != nil { + p.RequireName = *f.RequireName + } + if f.RequireMessage != nil { + p.RequireMessage = *f.RequireMessage + } + if f.MinNameRunes != nil { + p.MinNameRunes = *f.MinNameRunes + } + if f.MaxNameRunes != nil { + p.MaxNameRunes = *f.MaxNameRunes + } + if f.MinMessageRunes != nil { + p.MinMessageRunes = *f.MinMessageRunes + } + if f.MaxMessageRunes != nil { + p.MaxMessageRunes = *f.MaxMessageRunes + } + if f.Services != nil { + p.Services = f.Services + } + return p +} + +// SMTPConfig holds SMTP credentials and connection details. +type SMTPConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + User string `toml:"user"` + // Password is expanded from the environment before the TOML parse. + Password string `toml:"password"` + // RequireTLS aborts delivery when an SMTP server on a STARTTLS port + // never advertises STARTTLS. Irrelevant for implicit-TLS ports. + RequireTLS bool `toml:"require_tls"` + // TimeoutSeconds bounds the whole SMTP conversation. An explicit value + // must be at least 1. + TimeoutSeconds *int `toml:"timeout_seconds"` +} + +// TelegramConfig holds the optional Telegram notification channel: one +// bot posting submission summaries into one chat. +type TelegramConfig struct { + // BotToken is the bot's token from BotFather, expanded from the + // environment like every other secret. + BotToken string `toml:"bot_token"` + // ChatID is the chat receiving the summaries: a numeric chat id + // (group chats carry a negative number) or an @channelusername. + ChatID string `toml:"chat_id"` + // TimeoutSeconds bounds the whole API call. An explicit value must + // be at least 1. + TimeoutSeconds *int `toml:"timeout_seconds"` +} + +// Timeout returns the bound on the whole Telegram API call. +func (t TelegramConfig) Timeout() time.Duration { + if t.TimeoutSeconds == nil { + return time.Duration(DefaultTelegramTimeoutSeconds) * time.Second + } + return time.Duration(*t.TimeoutSeconds) * time.Second +} + +// Timeout returns the bound on the whole SMTP conversation. +func (s SMTPConfig) Timeout() time.Duration { + if s.TimeoutSeconds == nil { + return time.Duration(DefaultSMTPTimeoutSeconds) * time.Second + } + return time.Duration(*s.TimeoutSeconds) * time.Second +} + +// ImplicitTLSPort is the SMTP port that speaks TLS from the first byte. +// Port 465 upgrades the connection itself instead of negotiating STARTTLS +// inside a plaintext session. +const ImplicitTLSPort = 465 + +// 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) + } + // O_EXCL makes the create atomic: if two instances race, only one + // succeeds and the other sees os.ErrExist, which is safe to ignore. + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err == nil { + if _, werr := f.Write(defaultConfig); werr != nil { + f.Close() + return nil, fmt.Errorf("write default config to %s: %w", path, werr) + } + if cerr := f.Close(); cerr != nil { + return nil, fmt.Errorf("close default config %s: %w", path, cerr) + } + } else if !os.IsExist(err) { + return nil, fmt.Errorf("create default config %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, failing on + // variables that are referenced but not set. + expanded, err := expandConfig(string(raw)) + if err != nil { + return nil, fmt.Errorf("expand env vars in %s: %w", path, err) + } + + // Step 2: parse TOML, rejecting unknown fields to catch typos early. + var cfg Config + if err := interpres.Unmarshal([]byte(expanded), &cfg, interpres.RejectUnknownFields(true)); 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 = DefaultPort + } + if c.Server.Port < 1 || c.Server.Port > 65535 { + return fmt.Errorf("server.port must be between 1 and 65535, got %d", c.Server.Port) + } + if c.Server.Bind == "" { + c.Server.Bind = DefaultBind + } + if strings.ContainsAny(c.Server.Bind, " \t\r\n") { + return fmt.Errorf("server.bind %q must not contain whitespace", c.Server.Bind) + } + for name, v := range map[string]*int{ + "server.read_header_timeout_seconds": c.Server.ReadHeaderTimeoutSeconds, + "server.read_timeout_seconds": c.Server.ReadTimeoutSeconds, + "server.write_timeout_seconds": c.Server.WriteTimeoutSeconds, + "server.idle_timeout_seconds": c.Server.IdleTimeoutSeconds, + "server.shutdown_timeout_seconds": c.Server.ShutdownTimeoutSeconds, + } { + if v != nil && *v < 0 { + return fmt.Errorf("%s must be >= 0, got %d", name, *v) + } + } + for name, v := range map[string]*int{ + "server.max_body_bytes": c.Server.MaxBodyBytes, + "server.rate_limit_max_buckets": c.Server.RateLimitMaxBuckets, + "server.rate_limit_cleanup_seconds": c.Server.RateLimitCleanupSeconds, + "server.rate_limit_max_bucket_age_seconds": c.Server.RateLimitMaxBucketAgeSeconds, + } { + if v != nil && *v < 1 { + return fmt.Errorf("%s must be >= 1, got %d", name, *v) + } + } + if c.DataDir == "" { + c.DataDir = DefaultDataDir + } + 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 !isValidName(f.Name) { + return fmt.Errorf("form %q: name may only contain letters, digits, hyphens and underscores", f.Name) + } + if f.Path == "" { + return fmt.Errorf("form %q: path is required", f.Name) + } + if f.Type == "" { + c.Forms[i].Type = "contact" + } + if !IsValidFormType(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 !isValidPath(f.Path) { + return fmt.Errorf("form %q: path %q must start with / and contain only letters, digits, '-', '_', '.', '/' with no empty segments", f.Name, f.Path) + } + 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.RedirectURL != "" && strings.ContainsAny(f.RedirectURL, " \t\r\n") { + return fmt.Errorf("form %q: redirect_url %q must not contain whitespace", f.Name, f.RedirectURL) + } + if f.Archive && f.Type == "newsletter" { + return fmt.Errorf("form %q: archive does not apply to newsletter forms; they persist through the double opt-in log", f.Name) + } + if f.AutoReply && f.Type == "newsletter" { + return fmt.Errorf("form %q: auto_reply does not apply to newsletter forms; the subscriber already receives the confirmation mail", f.Name) + } + if f.Telegram != nil { + if f.Type == "newsletter" { + return fmt.Errorf("form %q: telegram does not apply to newsletter forms; the double opt-in flow is mail-native", f.Name) + } + if f.Telegram.BotToken == "" { + return fmt.Errorf("form %q: telegram.bot_token is required", f.Name) + } + if f.Telegram.ChatID == "" { + return fmt.Errorf("form %q: telegram.chat_id is required", f.Name) + } + if f.Telegram.TimeoutSeconds != nil && *f.Telegram.TimeoutSeconds < 1 { + return fmt.Errorf("form %q: telegram.timeout_seconds must be >= 1, got %d", f.Name, *f.Telegram.TimeoutSeconds) + } + } + // An explicit 0 keeps its documented meaning: rate limiting off. + if f.RateLimitPerHour != nil && *f.RateLimitPerHour < 0 { + return fmt.Errorf("form %q: rate_limit_per_hour must be >= 0, got %d", f.Name, *f.RateLimitPerHour) + } + if f.PendingTTLSeconds != nil && *f.PendingTTLSeconds < 1 { + return fmt.Errorf("form %q: pending_ttl_seconds must be >= 1, got %d", f.Name, *f.PendingTTLSeconds) + } + if f.SMTP.TimeoutSeconds != nil && *f.SMTP.TimeoutSeconds < 1 { + return fmt.Errorf("form %q: smtp.timeout_seconds must be >= 1, got %d", f.Name, *f.SMTP.TimeoutSeconds) + } + for _, svc := range f.Services { + if svc == "" { + return fmt.Errorf("form %q: services must not contain an empty entry; the empty value is always accepted", f.Name) + } + if strings.TrimSpace(svc) != svc { + return fmt.Errorf("form %q: services entry %q must not carry surrounding whitespace", f.Name, svc) + } + } + if err := checkPolicyLimits(f.Name, f.Policy()); err != nil { + return err + } + } + + return nil +} + +// checkPolicyLimits rejects a form policy whose length limits are +// unusable: negative minima, a maximum below one, or a window that +// excludes everything. +func checkPolicyLimits(formName string, p contactform.Policy) error { + for _, l := range []struct { + field string + min int + max int + }{ + {"name", p.MinNameRunes, p.MaxNameRunes}, + {"message", p.MinMessageRunes, p.MaxMessageRunes}, + } { + if l.min < 0 { + return fmt.Errorf("form %q: min_%s_runes must be >= 0, got %d", formName, l.field, l.min) + } + if l.max < 1 { + return fmt.Errorf("form %q: max_%s_runes must be >= 1, got %d", formName, l.field, l.max) + } + if l.max < l.min { + return fmt.Errorf("form %q: max_%s_runes (%d) must be greater than or equal to min_%s_runes (%d)", + formName, l.field, l.max, l.field, l.min) + } + } + return nil +} + +// isValidName reports whether s contains only safe characters for use +// in file paths and log lines. +func isValidName(s string) bool { + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + default: + return false + } + } + return true +} + +// isValidPath reports whether s is a safe HTTP route pattern: a static path +// built from a leading slash plus letters, digits, hyphens, underscores, +// dots and single-slash separators. Characters that carry meaning inside +// net/http ServeMux patterns (braces, spaces, ...) are rejected so that a +// mistyped config cannot crash route registration or silently widen a form +// endpoint into a wildcard or subtree match. +func isValidPath(s string) bool { + if !strings.HasPrefix(s, "/") || strings.Contains(s, "//") { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '/': + default: + return false + } + } + return true +} + +// ConfigPath returns the canonical path for nuntius configuration: +// the NUNTIUS_CONFIG environment variable when set, otherwise +// /etc/nuntius/config.toml. The override keeps local development free of +// root-only paths. +func ConfigPath() string { + if p := os.Getenv("NUNTIUS_CONFIG"); p != "" { + return p + } + return "/etc/nuntius/config.toml" +} + +// expandConfig applies expandEnv to every non-comment line of a TOML file. +// Lines whose first non-blank character is '#' are documentation, never +// values, so dollar signs there must survive verbatim even when they show +// placeholder syntax like ${VAR_NAME}. +func expandConfig(s string) (string, error) { + lines := strings.Split(s, "\n") + for i, ln := range lines { + if strings.HasPrefix(strings.TrimLeft(ln, " \t"), "#") { + continue + } + out, err := expandEnv(ln) + if err != nil { + return "", err + } + lines[i] = out + } + return strings.Join(lines, "\n"), nil +} + +// expandEnv substitutes $VAR and ${VAR} references with their environment +// values. Syntax follows os.Expand. Unlike os.Expand it fails when a +// referenced variable is not set: a silently empty SMTP password is far +// harder to diagnose than an explicit startup error. A dollar sign not +// followed by a variable name stays literal. +func expandEnv(s string) (string, error) { + var b strings.Builder + for i := 0; i < len(s); { + c := s[i] + if c != '$' { + b.WriteByte(c) + i++ + continue + } + name, width := varName(s[i+1:]) + if name == "" { + // "$" with no name after it (or unterminated braces): keep it. + b.WriteByte('$') + i++ + continue + } + value, ok := os.LookupEnv(name) + if !ok { + return "", fmt.Errorf("environment variable %s is referenced but not set", name) + } + b.WriteString(value) + i += 1 + width + } + return b.String(), nil +} + +// varName parses the variable name that follows a dollar sign, mirroring +// os.Expand rules: either "${NAME}" or a bare run of ASCII letters, digits +// and underscores. The second result counts the bytes consumed after the +// dollar sign; zero width means there was no name at all. +func varName(s string) (string, int) { + if len(s) > 0 && s[0] == '{' { + end := strings.IndexByte(s, '}') + if end < 0 { + return "", 0 + } + return s[1:end], end + 1 + } + n := 0 + for n < len(s) { + c := s[n] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') { + break + } + n++ + } + return s[:n], n +} + +// 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. Referencing a +# variable that is not set aborts startup with an explicit error. +# +# Every key below is optional: omitting one keeps its default. The commented +# lines document the keys most deployments never need to touch. + +data_dir = "./data" + +[server] +port = 8080 +# Host part of the listen address; the default "::" binds the dual-stack +# wildcard, so port 8080 answers on IPv4 and IPv6 alike. +#bind = "::" +# HTTP timeouts in seconds; 0 switches a timeout off. Defaults: 10, 15, 30, 60. +#read_header_timeout_seconds = 10 +#read_timeout_seconds = 15 +#write_timeout_seconds = 30 +#idle_timeout_seconds = 60 +# Graceful shutdown budget in seconds (default 15). +#shutdown_timeout_seconds = 15 +# Request body cap in bytes (default 1048576, i.e. 1 MiB). +#max_body_bytes = 1048576 +# Rate limiter mechanics: per-form cap on distinct IP buckets, cleanup tick +# and the age at which an idle bucket is dropped (defaults 32768, 3600, 7200). +#rate_limit_max_buckets = 32768 +#rate_limit_cleanup_seconds = 3600 +#rate_limit_max_bucket_age_seconds = 7200 +# Bearer token guarding GET /metrics; empty keeps the endpoint open. +#metrics_token = "${NUNTIUS_METRICS_TOKEN}" + +[[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"] +# Plain HTML form mode: an accepted submission answers 303 See Other with +# this location, so the form works without JavaScript. Omitted (the +# default), accepted submissions answer JSON. +#redirect_url = "https://example.com/thanks" +# Archive accepted submissions to data_dir/archive-.jsonl before the +# mail goes out, so a failed SMTP round-trip loses nothing. Newsletter +# forms always persist through the double opt-in log instead. +#archive = true +# Send the submitter a short automated receipt. Newsletter forms reject +# the key; their subscribers already receive the confirmation mail. +#auto_reply = true +# Telegram notification channel: the summary lands in the chat alongside +# the mail, and the submission counts as delivered when either gets +# through. Newsletter forms reject the key. +#[forms.telegram] +#bot_token = "${NUNTIUS_TELEGRAM_TOKEN}" +#chat_id = "123456789" +#timeout_seconds = 10 +# Allow-list for the optional "service" payload field. The empty value is +# always accepted; the "*" entry accepts any value; omitting the key keeps +# the built-in list below; an explicit empty list accepts only the empty +# value. The same key works on any form type. +services = ["architecture", "ai", "infrastructure", "software", "unix", "other"] +# Validation overrides on top of the type preset; the values shown are the +# defaults. The email address is always required and never length-limited. +#require_name = true +#require_message = true +#min_name_runes = 2 +#max_name_runes = 100 +#min_message_runes = 10 +#max_message_runes = 5000 +# Subject prefix, rendered as "[nuntius/contact]" in every mail subject; an +# empty string drops the bracket segment. Default "nuntius". +#subject_prefix = "nuntius" +# Footer brand, rendered as "Delivered by nuntius"; an empty string drops +# the footer. Default "nuntius". +#email_brand = "nuntius" + + [forms.smtp] + host = "smtp.example.com" + port = 587 + user = "contact@example.com" + password = "${NUNTIUS_SMTP_PASSWORD}" + # Whole SMTP conversation bound in seconds (default 20). + #timeout_seconds = 20 + +[[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"] +# Double opt-in lifetime in seconds (default 259200, i.e. 72 hours). +#pending_ttl_seconds = 259200 + + [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..8474fe5 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,1193 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +package config + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "sourcedock.dev/petrbalvin/nuntius/internal/contactform" +) + +func TestLoadWritesAndParsesDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + + // The default template references ${NUNTIUS_SMTP_PASSWORD}; expansion + // is strict, so the variable must exist even though nothing sends mail. + t.Setenv("NUNTIUS_SMTP_PASSWORD", "test-only") + + // 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].RateLimit() != 100 { + t.Errorf("newsletter rate_limit_per_hour = %d, want 100", cfg.Forms[2].RateLimit()) + } +} + +// newsletterFixture returns a form that passes every check that runs +// before the archive and auto-reply rejections. +func newsletterFixture() Form { + return Form{ + Name: "news", + Path: "/news", + Type: "newsletter", + To: "owner@example.com", + SMTP: SMTPConfig{Host: "smtp.example.com", Port: 587, User: "news@example.com"}, + } +} + +func TestArchiveRejectedOnNewsletter(t *testing.T) { + c := &Config{Forms: []Form{}} + f := newsletterFixture() + f.Archive = true + c.Forms = append(c.Forms, f) + + err := c.validate() + if err == nil || !strings.Contains(err.Error(), "archive") { + t.Errorf("validate() = %v, want an archive rejection", err) + } +} + +func TestAutoReplyRejectedOnNewsletter(t *testing.T) { + c := &Config{Forms: []Form{}} + f := newsletterFixture() + f.AutoReply = true + c.Forms = append(c.Forms, f) + + err := c.validate() + if err == nil || !strings.Contains(err.Error(), "auto_reply") { + t.Errorf("validate() = %v, want an auto_reply rejection", err) + } +} + +func TestTelegramValidation(t *testing.T) { + test := func(name string, mutate func(f *Form), want string) { + t.Run(name, func(t *testing.T) { + c := &Config{Forms: []Form{}} + f := Form{ + Name: "contact", + Path: "/contact", + Type: "contact", + To: "owner@example.com", + SMTP: SMTPConfig{Host: "smtp.example.com", Port: 587, User: "c@example.com"}, + Telegram: &TelegramConfig{ + BotToken: "123:secret", + ChatID: "-100200300", + }, + } + mutate(&f) + c.Forms = append(c.Forms, f) + + err := c.validate() + if want == "" { + if err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("validate() = %v, want an error containing %q", err, want) + } + }) + } + + test("newsletter rejected", func(f *Form) { f.Type = "newsletter" }, "telegram") + test("missing token", func(f *Form) { f.Telegram.BotToken = "" }, "bot_token") + test("missing chat", func(f *Form) { f.Telegram.ChatID = "" }, "chat_id") + test("bad timeout", func(f *Form) { + zero := 0 + f.Telegram.TimeoutSeconds = &zero + }, "timeout_seconds") + + t.Run("well configured contact form passes", func(t *testing.T) { + c := &Config{Forms: []Form{}} + f := Form{ + Name: "contact", + Path: "/contact", + Type: "contact", + To: "owner@example.com", + SMTP: SMTPConfig{Host: "smtp.example.com", Port: 587, User: "c@example.com"}, + Telegram: &TelegramConfig{ + BotToken: "123:secret", + ChatID: "@mychannel", + }, + } + c.Forms = append(c.Forms, f) + if err := c.validate(); err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + }) +} + +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") + } +} + +func TestConfigPath(t *testing.T) { + t.Setenv("NUNTIUS_CONFIG", "") + if got := ConfigPath(); got != "/etc/nuntius/config.toml" { + t.Errorf("ConfigPath() = %q, want %q", got, "/etc/nuntius/config.toml") + } + + path := filepath.Join(t.TempDir(), "config.toml") + t.Setenv("NUNTIUS_CONFIG", path) + if got := ConfigPath(); got != path { + t.Errorf("ConfigPath() with NUNTIUS_CONFIG = %q, want %q", got, path) + } +} + +func TestSMTPConfigAddrFor(t *testing.T) { + smtp := SMTPConfig{Host: "smtp.example.com", Port: 587} + if got := smtp.AddrFor(); got != "smtp.example.com:587" { + t.Errorf("AddrFor() = %q, want %q", got, "smtp.example.com:587") + } +} + +func TestIsValidFormType(t *testing.T) { + for _, tc := range []struct { + typ string + want bool + }{ + {"contact", true}, + {"feedback", true}, + {"newsletter", true}, + {"generic", true}, + {"bogus", false}, + {"", false}, + } { + if got := IsValidFormType(tc.typ); got != tc.want { + t.Errorf("IsValidFormType(%q) = %v, want %v", tc.typ, got, tc.want) + } + } +} + +func TestLoadValidatesPortRange(t *testing.T) { + for _, port := range []int{-1, 65536, 100000} { + path := filepath.Join(t.TempDir(), "config.toml") + doc := fmt.Sprintf(` +[server] +port = %d + +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, port) + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Errorf("expected error for port %d", port) + } + } +} + +func TestLoadRejectsNegativeRateLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +rate_limit_per_hour = -5 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected error for negative rate_limit_per_hour") + } +} + +func TestLoadRejectsInvalidName(t *testing.T) { + for _, name := range []string{"bad/name", "bad name", "../evil", "name!"} { + path := filepath.Join(t.TempDir(), "config.toml") + doc := fmt.Sprintf(` +[[forms]] +name = %q +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, name) + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Errorf("expected error for name %q", name) + } + } +} + +func TestLoadValidationErrors(t *testing.T) { + base := func(mod string) string { + return ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + mod + } + cases := []struct { + name string + doc string + }{ + {"missing name", ` +[[forms]] +path = "/c" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"missing path", ` +[[forms]] +name = "c" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"path without slash", ` +[[forms]] +name = "c" +path = "c" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"wildcard path", ` +[[forms]] +name = "c" +path = "/api/{form}" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"space in path", ` +[[forms]] +name = "c" +path = "/api/contact x" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"empty path segment", ` +[[forms]] +name = "c" +path = "/api//contact" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"duplicate path", ` +[[forms]] +name = "c1" +path = "/c" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" + +[[forms]] +name = "c2" +path = "/c" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"invalid type", ` +[[forms]] +name = "c" +path = "/c" +type = "bogus" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + {"missing smtp host", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + [forms.smtp] + port = 587 + user = "u" +`}, + {"missing smtp port", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + [forms.smtp] + host = "h" + user = "u" +`}, + {"missing smtp user", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + [forms.smtp] + host = "h" + port = 587 +`}, + {"missing to", ` +[[forms]] +name = "c" +path = "/c" + [forms.smtp] + host = "h" + port = 587 + user = "u" +`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(tc.doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatalf("expected error for %s", tc.name) + } + }) + } + _ = base // silence unused helper +} + +func TestLoadAppliesDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != DefaultPort { + t.Errorf("default port = %d, want %d", cfg.Server.Port, DefaultPort) + } + if cfg.DataDir != DefaultDataDir { + t.Errorf("default data_dir = %q, want %q", cfg.DataDir, DefaultDataDir) + } + f := cfg.Forms[0] + if f.Type != "contact" { + t.Errorf("default type = %q, want contact", f.Type) + } + if f.RateLimit() != DefaultRateLimitPerHour { + t.Errorf("default rate = %d, want %d", f.RateLimit(), DefaultRateLimitPerHour) + } + if f.Honeypot() != DefaultHoneypotField { + t.Errorf("default honeypot = %q, want %q", f.Honeypot(), DefaultHoneypotField) + } + if f.From != "u" { + t.Errorf("default from = %q, want smtp user", f.From) + } +} + +// Paths that survive validation must register as exact static routes. +func TestLoadAcceptsDottedPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +[[forms]] +name = "c" +path = "/api/v1.2/contact" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err != nil { + t.Fatalf("Load: %v", err) + } +} + +// An explicit zero rate limit and an explicit empty honeypot name keep +// their documented "disabled" meaning instead of falling back to defaults. +func TestLoadHonoursExplicitZeroAndEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +rate_limit_per_hour = 0 +honeypot_field = "" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Forms[0].RateLimit(); got != 0 { + t.Errorf("explicit zero rate = %d, want 0 (disabled)", got) + } + if got := cfg.Forms[0].Honeypot(); got != "" { + t.Errorf("explicit empty honeypot = %q, want empty (disabled)", got) + } +} + +// A config that references an environment variable which is not set must +// fail to load with a message naming the variable. +func TestLoadRejectsUndefinedEnvVar(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + doc := ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" + password = "${NUNTIUS_MISSING_PW}" +` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil { + t.Fatal("expected an error for the undefined variable") + } + if !strings.Contains(err.Error(), "NUNTIUS_MISSING_PW") { + t.Errorf("error %q should name the missing variable", err) + } +} + +// Comment lines are documentation: placeholders shown there, such as the +// ${VAR_NAME} mention in the generated template header, must survive +// expansion untouched. +func TestExpandConfigSkipsCommentLines(t *testing.T) { + t.Setenv("NUNTIUS_TEST_A", "alpha") + in := "# see ${NUNTIUS_DOCS_VAR}\nvalue = \"${NUNTIUS_TEST_A}\"\n" + out, err := expandConfig(in) + if err != nil { + t.Fatalf("expandConfig: %v", err) + } + want := "# see ${NUNTIUS_DOCS_VAR}\nvalue = \"alpha\"\n" + if out != want { + t.Errorf("expandConfig() = %q, want %q", out, want) + } +} + +// minimalDoc is the smallest config that passes validation: every new +// policy key omitted, so the getters must return today's defaults. +const minimalDoc = ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + +func loadDoc(t *testing.T, doc string) *Config { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + return cfg +} + +// Every policy key left out must resolve to the value the hardcoded +// constants carried before the keys existed: zero config behaves exactly +// like the previous release. +func TestLoadAppliesPolicyDefaults(t *testing.T) { + cfg := loadDoc(t, minimalDoc) + s := cfg.Server + + if s.Bind != "::" { + t.Errorf("default bind = %q, want ::", s.Bind) + } + for name, got := range map[string]time.Duration{ + "read_header_timeout": s.ReadHeaderTimeout(), + "read_timeout": s.ReadTimeout(), + "write_timeout": s.WriteTimeout(), + "idle_timeout": s.IdleTimeout(), + "shutdown_timeout": s.ShutdownTimeout(), + "rate_limit_cleanup": s.RateLimitCleanup(), + "rate_limit_max_age": s.RateLimitMaxBucketAge(), + } { + if got <= 0 { + t.Errorf("default %s = %v, want a positive duration", name, got) + } + } + if s.ReadHeaderTimeout() != 10*time.Second || s.ReadTimeout() != 15*time.Second { + t.Errorf("read timeouts = %v/%v, want 10s/15s", s.ReadHeaderTimeout(), s.ReadTimeout()) + } + if s.WriteTimeout() != 30*time.Second || s.IdleTimeout() != 60*time.Second { + t.Errorf("write/idle timeouts = %v/%v, want 30s/60s", s.WriteTimeout(), s.IdleTimeout()) + } + if s.ShutdownTimeout() != 15*time.Second { + t.Errorf("shutdown timeout = %v, want 15s", s.ShutdownTimeout()) + } + if s.BodyLimit() != 1<<20 { + t.Errorf("default body limit = %d, want 1 MiB", s.BodyLimit()) + } + if s.MaxRateLimitBuckets() != 32768 { + t.Errorf("default bucket cap = %d, want 32768", s.MaxRateLimitBuckets()) + } + if s.RateLimitCleanup() != time.Hour || s.RateLimitMaxBucketAge() != 2*time.Hour { + t.Errorf("cleanup/max age = %v/%v, want 1h/2h", s.RateLimitCleanup(), s.RateLimitMaxBucketAge()) + } + + f := cfg.Forms[0] + if f.PendingTTL() != 72*time.Hour { + t.Errorf("default pending TTL = %v, want 72h", f.PendingTTL()) + } + if f.SMTP.Timeout() != 20*time.Second { + t.Errorf("default smtp timeout = %v, want 20s", f.SMTP.Timeout()) + } + if f.EmailSubjectPrefix() != "nuntius" { + t.Errorf("default subject prefix = %q, want nuntius", f.EmailSubjectPrefix()) + } + if f.Brand() != "nuntius" { + t.Errorf("default brand = %q, want nuntius", f.Brand()) + } + + // The contact preset carries the built-in service list and the + // historical length limits. + p := f.Policy() + if !p.RequireName || !p.RequireMessage { + t.Error("contact preset must require name and message") + } + if p.MinNameRunes != 2 || p.MaxNameRunes != 100 || p.MinMessageRunes != 10 || p.MaxMessageRunes != 5000 { + t.Errorf("contact preset limits = %+v, want 2/100/10/5000", p) + } + if len(p.Services) != 6 || !slices.Contains(p.Services, "architecture") { + t.Errorf("contact preset services = %v, want the built-in list", p.Services) + } +} + +// Setting a policy key must move the effective value away from the +// default, and the changed value must reach the getter and the policy. +func TestLoadHonoursPolicyOverrides(t *testing.T) { + cfg := loadDoc(t, ` +[server] +bind = "127.0.0.1" +read_header_timeout_seconds = 5 +read_timeout_seconds = 0 +write_timeout_seconds = 120 +idle_timeout_seconds = 300 +shutdown_timeout_seconds = 1 +max_body_bytes = 65536 +rate_limit_max_buckets = 1024 +rate_limit_cleanup_seconds = 60 +rate_limit_max_bucket_age_seconds = 600 + +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +services = ["consulting", "support"] +require_name = false +min_name_runes = 3 +max_name_runes = 50 +min_message_runes = 1 +max_message_runes = 2000 +pending_ttl_seconds = 3600 +subject_prefix = "web" +email_brand = "" + + [forms.smtp] + host = "h" + port = 587 + user = "u" + timeout_seconds = 45 +`) + s := cfg.Server + if s.Bind != "127.0.0.1" { + t.Errorf("bind = %q, want 127.0.0.1", s.Bind) + } + if s.ReadHeaderTimeout() != 5*time.Second { + t.Errorf("read header timeout = %v, want 5s", s.ReadHeaderTimeout()) + } + // An explicit 0 keeps its net/http meaning: switched off. + if s.ReadTimeout() != 0 { + t.Errorf("read timeout = %v, want 0 (disabled)", s.ReadTimeout()) + } + if s.WriteTimeout() != 120*time.Second || s.IdleTimeout() != 300*time.Second { + t.Errorf("write/idle = %v/%v, want 120s/300s", s.WriteTimeout(), s.IdleTimeout()) + } + if s.ShutdownTimeout() != time.Second { + t.Errorf("shutdown = %v, want 1s", s.ShutdownTimeout()) + } + if s.BodyLimit() != 65536 { + t.Errorf("body limit = %d, want 65536", s.BodyLimit()) + } + if s.MaxRateLimitBuckets() != 1024 || s.RateLimitCleanup() != time.Minute || s.RateLimitMaxBucketAge() != 10*time.Minute { + t.Errorf("limiter settings = %d/%v/%v, want 1024/1m/10m", + s.MaxRateLimitBuckets(), s.RateLimitCleanup(), s.RateLimitMaxBucketAge()) + } + + f := cfg.Forms[0] + if f.PendingTTL() != time.Hour { + t.Errorf("pending TTL = %v, want 1h", f.PendingTTL()) + } + if f.SMTP.Timeout() != 45*time.Second { + t.Errorf("smtp timeout = %v, want 45s", f.SMTP.Timeout()) + } + if f.EmailSubjectPrefix() != "web" { + t.Errorf("subject prefix = %q, want web", f.EmailSubjectPrefix()) + } + // An explicit empty brand disables the footer. + if f.Brand() != "" { + t.Errorf("brand = %q, want empty (disabled)", f.Brand()) + } + + p := f.Policy() + if p.RequireName { + t.Error("require_name = false must reach the policy") + } + if p.RequireMessage != true { + t.Error("unset require_message must keep the contact preset value") + } + if p.MinNameRunes != 3 || p.MaxNameRunes != 50 || p.MinMessageRunes != 1 || p.MaxMessageRunes != 2000 { + t.Errorf("policy limits = %+v, want 3/50/1/2000", p) + } + if !slices.Contains(p.Services, "consulting") || slices.Contains(p.Services, "architecture") { + t.Errorf("policy services = %v, want the configured list", p.Services) + } +} + +// The services key must also work on non-contact forms: it switches the +// service field into the validation for any type. +func TestFormPolicyServicesOnFeedback(t *testing.T) { + cfg := loadDoc(t, ` +[[forms]] +name = "f" +path = "/f" +type = "feedback" +to = "a@b.c" +services = ["bug", "idea"] + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`) + p := cfg.Forms[0].Policy() + if p.Services == nil { + t.Fatal("feedback with a services key must validate the service field") + } + r := &contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "bug", Message: "A message long enough."} + if errs := contactform.Validate(r, p); len(errs) > 0 { + t.Errorf("listed service must pass, got %v", errs) + } + r2 := &contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."} + if errs := contactform.Validate(r2, p); len(errs) == 0 || errs[0].Field != "service" { + t.Errorf("unlisted service must fail, got %v", errs) + } + // The preset without the key must leave the field unvalidated. + cfg2 := loadDoc(t, ` +[[forms]] +name = "f" +path = "/f" +type = "feedback" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`) + if p2 := cfg2.Forms[0].Policy(); p2.Services != nil { + t.Errorf("feedback without services key = %v, want nil", p2.Services) + } +} + +// An explicit empty services list accepts only the empty value. The empty +// array must decode as a present-but-empty list, not as an omitted key. +func TestFormPolicyEmptyServicesList(t *testing.T) { + cfg := loadDoc(t, ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +services = [] + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`) + f := cfg.Forms[0] + if f.Services == nil { + t.Fatal("services = [] must decode as an explicit empty list, not an omitted key") + } + if len(f.Services) != 0 { + t.Fatalf("services = %v, want empty", f.Services) + } + p := f.Policy() + if errs := contactform.Validate(&contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "anything", Message: "A message long enough."}, p); len(errs) == 0 || errs[0].Field != "service" { + t.Errorf("empty list must reject a service value, got %v", errs) + } + if errs := contactform.Validate(&contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "", Message: "A message long enough."}, p); len(errs) != 0 { + t.Errorf("empty list must keep the empty value valid, got %v", errs) + } +} + +// Nonsense policy values must fail at load time with an error naming the +// offending key. +func TestLoadRejectsInvalidPolicyValues(t *testing.T) { + tests := []struct { + name string + mod string + wantErr string + }{ + {"negative read timeout", "read_timeout_seconds = -1", "server.read_timeout_seconds"}, + {"negative idle timeout", "idle_timeout_seconds = -5", "server.idle_timeout_seconds"}, + {"zero body limit", "max_body_bytes = 0", "server.max_body_bytes"}, + {"negative body limit", "max_body_bytes = -3", "server.max_body_bytes"}, + {"zero bucket cap", "rate_limit_max_buckets = 0", "server.rate_limit_max_buckets"}, + {"zero cleanup tick", "rate_limit_cleanup_seconds = 0", "server.rate_limit_cleanup_seconds"}, + {"zero bucket age", "rate_limit_max_bucket_age_seconds = 0", "server.rate_limit_max_bucket_age_seconds"}, + {"bind with whitespace", "bind = \":: 80\"", "server.bind"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + doc := ` +[server] +` + tc.mod + ` + +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +` + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil { + t.Fatalf("expected an error for %s", tc.name) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q should name %q", err, tc.wantErr) + } + }) + } + + formMods := []struct { + name string + doc string + wantErr string + }{ + {"zero pending ttl", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +pending_ttl_seconds = 0 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "pending_ttl_seconds"}, + {"negative pending ttl", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +pending_ttl_seconds = -3600 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "pending_ttl_seconds"}, + {"zero smtp timeout", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" + timeout_seconds = 0 +`, "smtp.timeout_seconds"}, + {"empty service entry", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +services = ["", "x"] + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "services"}, + {"padded service entry", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +services = [" x"] + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "services"}, + {"name window inverted", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +max_name_runes = 1 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "max_name_runes"}, + {"negative name minimum", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +min_name_runes = -1 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "min_name_runes"}, + {"zero name maximum", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +max_name_runes = 0 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "max_name_runes"}, + {"message window inverted", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +min_message_runes = 6000 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "max_message_runes"}, + {"zero message maximum", ` +[[forms]] +name = "c" +path = "/c" +to = "a@b.c" +max_message_runes = 0 + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`, "max_message_runes"}, + } + for _, tc := range formMods { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(tc.doc), 0o644); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil { + t.Fatalf("expected an error for %s", tc.name) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q should name %q", err, tc.wantErr) + } + }) + } +} + +// The newsletter preset leaves the free-text fields out of the validation +// and the pending TTL default matches the documented 72 hours. +func TestNewsletterPolicyAndPendingDefaults(t *testing.T) { + cfg := loadDoc(t, ` +[[forms]] +name = "n" +path = "/n" +type = "newsletter" +to = "a@b.c" + + [forms.smtp] + host = "h" + port = 587 + user = "u" +`) + f := cfg.Forms[0] + p := f.Policy() + if p.RequireName || p.RequireMessage || p.Services != nil { + t.Errorf("newsletter policy = %+v, want email-only", p) + } + if f.PendingTTL() != 72*time.Hour { + t.Errorf("pending TTL = %v, want 72h", f.PendingTTL()) + } +} + +func TestExpandEnv(t *testing.T) { + t.Setenv("NUNTIUS_TEST_A", "alpha") + t.Setenv("NUNTIUS_TEST_EMPTY", "") + + tests := []struct { + name string + in string + want string + wantErr string + }{ + {"no references", "plain text", "plain text", ""}, + {"braced reference", "${NUNTIUS_TEST_A}", "alpha", ""}, + {"bare reference", "$NUNTIUS_TEST_A!", "alpha!", ""}, + {"both syntaxes", "${NUNTIUS_TEST_A}/$NUNTIUS_TEST_A", "alpha/alpha", ""}, + { + name: "unset braced variable errors", + in: "pw = ${NUNTIUS_UNSET_X}", + want: "", + wantErr: "NUNTIUS_UNSET_X", + }, + { + name: "unset bare variable errors", + in: "$UNSET_Y", + want: "", + wantErr: "UNSET_Y", + }, + {"set but empty expands to empty", "[${NUNTIUS_TEST_EMPTY}]", "[]", ""}, + {"lone dollar stays literal", "100$ total", "100$ total", ""}, + {"dollar before space stays literal", "$ is money", "$ is money", ""}, + {"double dollar stays literal", "$$", "$$", ""}, + {"unterminated brace keeps dollar", "${UNCLOSED", "${UNCLOSED", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := expandEnv(tt.in) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expandEnv(%q) = %q, want error containing %q", tt.in, got, tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q should contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("expandEnv(%q): %v", tt.in, err) + } + if got != tt.want { + t.Errorf("expandEnv(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// FuzzLoadConfig throws arbitrary documents at the loader. Anything that +// loads must satisfy the invariants validate() applies: a usable port, +// at least one form, known types, a recipient per form, and no +// whitespace inside a redirect_url. Documents that fail to load are +// uninteresting by definition. +func FuzzLoadConfig(f *testing.F) { + seeds := []string{ + "", + "not toml at all }}}", + "[server]\nport = 70000\n", + "[[forms]]\nname = \"a\"\n", + "data_dir = \"./data\"\n[server]\nport = 9000\n[[forms]]\nname = \"a\"\npath = \"/a\"\nto = \"o@example.com\"\n[forms.smtp]\nhost = \"h\"\nport = 587\nuser = \"u\"\n", + "[[forms]]\nname = \"a\"\npath = \"/a\"\ntype = \"contact\"\nto = \"o@example.com\"\nredirect_url = \"https://example.com/thanks\"\nsmtp = {host = \"h\", port = 587, user = \"u\"}\n", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, doc string) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(doc), 0644); err != nil { + t.Skip() + } + cfg, err := Load(path) + if err != nil { + return + } + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + t.Fatalf("loaded config with port %d", cfg.Server.Port) + } + if len(cfg.Forms) == 0 { + t.Fatalf("loaded config without forms") + } + for _, form := range cfg.Forms { + if !IsValidFormType(form.Type) { + t.Fatalf("loaded form %q with unknown type %q", form.Name, form.Type) + } + if form.To == "" { + t.Fatalf("loaded form %q without a recipient", form.Name) + } + if strings.ContainsAny(form.RedirectURL, " \t\r\n") { + t.Fatalf("loaded form %q with whitespace in redirect_url", form.Name) + } + if form.Archive && form.Type == "newsletter" { + t.Fatalf("loaded newsletter form %q with archive", form.Name) + } + if form.AutoReply && form.Type == "newsletter" { + t.Fatalf("loaded newsletter form %q with auto_reply", form.Name) + } + } + }) +} diff --git a/internal/contactform/types.go b/internal/contactform/types.go new file mode 100644 index 0000000..50d3e6e --- /dev/null +++ b/internal/contactform/types.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +// Package contactform provides the types and the declarative validation +// the form pipeline runs on. +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"` +} + +// 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/internal/contactform/validate.go b/internal/contactform/validate.go new file mode 100644 index 0000000..996daa8 --- /dev/null +++ b/internal/contactform/validate.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +package contactform + +import ( + "fmt" + "net/mail" + "slices" + "strings" + "unicode/utf8" +) + +// MinNameRunes is the default minimum allowed length of a name. +const MinNameRunes = 2 + +// MaxNameRunes is the default maximum allowed length of a name. +const MaxNameRunes = 100 + +// MinMessageRunes is the default minimum allowed length of a message body. +const MinMessageRunes = 10 + +// MaxMessageRunes is the default maximum allowed length of a message body. +const MaxMessageRunes = 5000 + +// ServiceAny is the services entry that accepts any service value. +const ServiceAny = "*" + +// DefaultServices is the built-in service allow-list the contact preset +// applies when a form does not define its own list. The empty value is +// always accepted on top of whatever this list holds, because a form that +// offers no choice never sends the field at all. +var DefaultServices = []string{ + "architecture", + "ai", + "infrastructure", + "software", + "unix", + "other", +} + +// Policy is the declarative validation rule set for one form. The server +// builds it from the form's configuration; a library caller writes it +// directly. The zero value accepts any name and message, so every limit +// that matters must be set explicitly. +type Policy struct { + // RequireName and RequireMessage switch the length checks for the two + // free-text fields on and off. The email address is always required + // and always checked: every form delivers mail and needs a reply-to. + RequireName bool + RequireMessage bool + + MinNameRunes int + MaxNameRunes int + MinMessageRunes int + MaxMessageRunes int + + // Services is the allow-list for the optional service field. A nil + // list means the field is not validated at all; a non-nil list holds + // the accepted values, with the empty value always accepted and the + // ServiceAny entry lifting the restriction entirely. + Services []string +} + +// Preset returns the built-in policy for a form type. An empty or unknown +// type falls back to the contact preset, which is also how the server +// treats an unconfigured type. Every preset carries the default length +// limits; the newsletter preset simply leaves both free-text fields out +// of the checks, so switching them on later starts from sane limits. +func Preset(formType string) Policy { + switch formType { + case "newsletter": + return Policy{ + MinNameRunes: MinNameRunes, + MaxNameRunes: MaxNameRunes, + MinMessageRunes: MinMessageRunes, + MaxMessageRunes: MaxMessageRunes, + } + case "feedback", "generic": + return Policy{ + RequireName: true, + RequireMessage: true, + MinNameRunes: MinNameRunes, + MaxNameRunes: MaxNameRunes, + MinMessageRunes: MinMessageRunes, + MaxMessageRunes: MaxMessageRunes, + } + default: // contact, and the fallback for every unknown type + return Policy{ + RequireName: true, + RequireMessage: true, + MinNameRunes: MinNameRunes, + MaxNameRunes: MaxNameRunes, + MinMessageRunes: MinMessageRunes, + MaxMessageRunes: MaxMessageRunes, + Services: slices.Clone(DefaultServices), + } + } +} + +// Validate normalises the request in place (trims whitespace from every +// text field) and checks it against p. It returns one entry per failed +// field; a nil slice means the request is valid. +func Validate(r *Request, p Policy) []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 p.RequireName { + errs = append(errs, checkRunes("name", r.Name, p.MinNameRunes, p.MaxNameRunes)...) + } + + if _, err := mail.ParseAddress(r.Email); err != nil { + errs = append(errs, FieldError{Field: "email", Message: "email is invalid"}) + } + + if p.Services != nil && !serviceAllowed(p.Services, r.Service) { + errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"}) + } + + if p.RequireMessage { + errs = append(errs, checkRunes("message", r.Message, p.MinMessageRunes, p.MaxMessageRunes)...) + } + + return errs +} + +// NormalizeAndValidate trims whitespace from all text fields and then +// checks the request against the built-in preset for the form type. +// Supported types: contact, feedback, newsletter, generic; an empty or +// unknown type falls back to contact. Callers that need their own limits +// or their own service allow-list build a Policy and call Validate. +func NormalizeAndValidate(r *Request, formType string) []FieldError { + return Validate(r, Preset(formType)) +} + +// checkRunes reports the length errors for one field in rune counts. +func checkRunes(field, value string, minRunes, maxRunes int) []FieldError { + n := utf8.RuneCountInString(value) + var errs []FieldError + if n < minRunes { + errs = append(errs, FieldError{ + Field: field, + Message: fmt.Sprintf("%s must be at least %d characters", field, minRunes), + }) + } else if n > maxRunes { + errs = append(errs, FieldError{ + Field: field, + Message: fmt.Sprintf("%s must be at most %d characters", field, maxRunes), + }) + } + return errs +} + +// serviceAllowed reports whether s passes the allow-list. The empty value +// is always legitimate: petrbalvin.org and every other frontend is free to +// post a payload without a service field. The ServiceAny entry accepts any +// non-empty value. +func serviceAllowed(list []string, s string) bool { + if s == "" { + return true + } + if slices.Contains(list, ServiceAny) { + return true + } + return slices.Contains(list, s) +} diff --git a/internal/contactform/validate_test.go b/internal/contactform/validate_test.go new file mode 100644 index 0000000..efe73a7 --- /dev/null +++ b/internal/contactform/validate_test.go @@ -0,0 +1,372 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +package contactform + +import ( + "slices" + "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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 { + t.Fatalf("expected no errors, got %v", errs) + } +} + +func TestValidateNewsletter_RequiresEmail(t *testing.T) { + r := &Request{Email: "garbage"} + errs := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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) + } +} + +// --------------------------------------------------------------------------- +// Policy-driven validation +// --------------------------------------------------------------------------- + +// The presets must reproduce the exact rules the fixed validators enforced +// before the policy layer existed. +func TestPresets(t *testing.T) { + contact := Preset("contact") + if !contact.RequireName || !contact.RequireMessage { + t.Error("contact preset must require name and message") + } + if contact.MinNameRunes != MinNameRunes || contact.MaxNameRunes != MaxNameRunes { + t.Errorf("contact name limits = %d/%d, want %d/%d", + contact.MinNameRunes, contact.MaxNameRunes, MinNameRunes, MaxNameRunes) + } + if contact.MinMessageRunes != MinMessageRunes || contact.MaxMessageRunes != MaxMessageRunes { + t.Errorf("contact message limits = %d/%d, want %d/%d", + contact.MinMessageRunes, contact.MaxMessageRunes, MinMessageRunes, MaxMessageRunes) + } + if !slices.Contains(contact.Services, "architecture") || !slices.Contains(contact.Services, "other") { + t.Errorf("contact preset services = %v, want the built-in list", contact.Services) + } + + for _, typ := range []string{"feedback", "generic"} { + p := Preset(typ) + if !p.RequireName || !p.RequireMessage { + t.Errorf("%s preset must require name and message", typ) + } + if p.Services != nil { + t.Errorf("%s preset must not validate service, got %v", typ, p.Services) + } + } + + news := Preset("newsletter") + if news.RequireName || news.RequireMessage || news.Services != nil { + t.Errorf("newsletter preset = %+v, want email-only", news) + } + + // Unknown and empty types fall back to contact. + fallback := Preset("nonsense") + if !fallback.RequireName || fallback.Services == nil { + t.Errorf("unknown type preset = %+v, want the contact preset", fallback) + } +} + +// A payload without a service field is legitimate under the contact +// preset: the field is optional, never a mandatory category. +func TestValidateContactWithoutServiceField(t *testing.T) { + r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "A plain hello for you."} + if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 { + t.Fatalf("payload without service must pass, got %v", errs) + } +} + +// A nil Services list means the field is not validated at all, so any +// value rides through untouched. +func TestPolicyNilServicesSkipsValidation(t *testing.T) { + p := Preset("feedback") + r := &Request{Name: "Jane", Email: "jane@example.com", Service: "anything-goes", Message: "A message long enough."} + if errs := Validate(r, p); len(errs) > 0 { + t.Fatalf("nil services list must skip the field, got %v", errs) + } +} + +func TestPolicyServices(t *testing.T) { + base := func(services []string, service string) []FieldError { + p := Policy{ + RequireName: true, + RequireMessage: true, + MinNameRunes: 2, MaxNameRunes: 100, + MinMessageRunes: 10, MaxMessageRunes: 5000, + Services: services, + } + r := &Request{Name: "Jane", Email: "jane@example.com", Service: service, Message: "A message long enough."} + return Validate(r, p) + } + + tests := []struct { + name string + services []string + service string + wantErr bool + }{ + {"empty service always passes", []string{"consulting"}, "", false}, + {"listed value passes", []string{"consulting", "support"}, "support", false}, + {"unlisted value fails", []string{"consulting"}, "hairstyling", true}, + {"explicit empty list rejects any value", []string{}, "consulting", true}, + {"explicit empty list keeps empty value", []string{}, "", false}, + {"wildcard accepts anything", []string{ServiceAny}, "hairstyling", false}, + {"wildcard beside entries still accepts anything", []string{"consulting", ServiceAny}, "anything", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := base(tt.services, tt.service) + if gotErr := len(errs) > 0; gotErr != tt.wantErr { + t.Fatalf("Validate(service=%q, list=%v) errors = %v, wantErr %v", + tt.service, tt.services, errs, tt.wantErr) + } + if tt.wantErr && errs[0].Field != "service" { + t.Errorf("error field = %q, want service", errs[0].Field) + } + }) + } +} + +// Custom limits are honoured and the error messages carry the configured +// numbers, not the built-in defaults. +func TestPolicyCustomLimits(t *testing.T) { + p := Policy{ + RequireName: true, + RequireMessage: true, + MinNameRunes: 5, + MaxNameRunes: 10, + MinMessageRunes: 20, + MaxMessageRunes: 40, + } + r := &Request{Name: "Ja", Email: "jane@example.com", Message: "too short"} + errs := Validate(r, p) + if len(errs) != 2 { + t.Fatalf("errors = %v, want two length failures", errs) + } + if errs[0].Field != "name" || !strings.Contains(errs[0].Message, "at least 5 characters") { + t.Errorf("name error = %+v, want the configured minimum", errs[0]) + } + if errs[1].Field != "message" || !strings.Contains(errs[1].Message, "at least 20 characters") { + t.Errorf("message error = %+v, want the configured minimum", errs[1]) + } + + r2 := &Request{Name: "A very long name over the limit", Email: "jane@example.com", + Message: strings.Repeat("x", 41)} + errs = Validate(r2, p) + if len(errs) != 2 { + t.Fatalf("errors = %v, want two maximum failures", errs) + } + if !strings.Contains(errs[0].Message, "at most 10 characters") || + !strings.Contains(errs[1].Message, "at most 40 characters") { + t.Errorf("errors = %v, want the configured maxima", errs) + } +} + +// A false Require flag takes the whole field out of the checks; the field +// is still trimmed so the email body sees clean text. +func TestPolicyOptionalFields(t *testing.T) { + p := Policy{RequireName: false, RequireMessage: false, Services: []string{ServiceAny}} + r := &Request{Name: "", Email: "jane@example.com", Message: "", Service: "x"} + if errs := Validate(r, p); len(errs) > 0 { + t.Fatalf("optional fields must pass empty, got %v", errs) + } + if r.Name != "" || r.Message != "" || r.Service != "x" { + t.Errorf("fields should stay trimmed, got %+v", r) + } +} + +// The email address is never optional: it is the reply-to of the mail the +// form produces. +func TestPolicyEmailAlwaysRequired(t *testing.T) { + p := Policy{} + r := &Request{Name: "Jane", Email: "not-an-email", Message: "hello"} + errs := Validate(r, p) + if len(errs) != 1 || errs[0].Field != "email" { + t.Fatalf("errors = %v, want exactly the email failure", errs) + } +} + +// Validate must produce the same verdicts as the type dispatcher did +// before the policy layer: every case the old tests covered still holds +// through the wrapper. +func TestNormalizeAndValidateBackwardCompatible(t *testing.T) { + cases := []struct { + name string + formType string + req Request + wantErr bool + }{ + {"contact happy", "contact", Request{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss a project."}, false}, + {"contact bad service", "contact", Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}, true}, + {"feedback happy", "feedback", Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}, false}, + {"generic short message", "generic", Request{Name: "Jane", Email: "jane@example.com", Message: "short"}, true}, + {"newsletter happy", "newsletter", Request{Email: "jane@example.com"}, false}, + {"newsletter bad email", "newsletter", Request{Email: "garbage"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := tc.req + errs := NormalizeAndValidate(&req, tc.formType) + if got := len(errs) > 0; got != tc.wantErr { + t.Fatalf("NormalizeAndValidate(%q) errors = %v, wantErr %v", tc.formType, errs, tc.wantErr) + } + }) + } +} + +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 := NormalizeAndValidate(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) + } + } +} + +// FuzzValidate explores the validator with arbitrary payloads and +// policies. The invariants: it never panics, every reported error names +// one of the four known fields with a non-empty message, and validation +// is deterministic in the trimmed request. +func FuzzValidate(f *testing.F) { + seeds := []Request{ + {Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss an engagement."}, + {Name: "", Email: "", Service: "", Message: ""}, + {Name: " J ", Email: " j@example.com ", Service: " unix ", Message: "x"}, + {Name: "