Compare commits
17
Commits
v1.0.0
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f0e24b46 | ||
|
|
603e1e344b | ||
|
|
96e47ab87a | ||
|
|
c3418b3c3b | ||
|
|
5048968cdf | ||
|
|
a845157c67 | ||
|
|
68f3a610bb | ||
|
|
506ea08c6d | ||
|
|
9457d3f8cc | ||
|
|
4dd7b6991d | ||
|
|
08554695da | ||
|
|
b6ee569681 | ||
|
|
6329683a5d | ||
|
|
9a904fda8a | ||
|
|
83c95dff08 | ||
|
|
6339771a24 | ||
|
|
db3860328b |
@@ -1,47 +0,0 @@
|
|||||||
name: Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [development]
|
|
||||||
pull_request:
|
|
||||||
branches: [development]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
vet:
|
|
||||||
runs-on: codeberg-small
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.26"
|
|
||||||
|
|
||||||
- name: Download dependencies
|
|
||||||
run: go mod download
|
|
||||||
|
|
||||||
- name: gofmt
|
|
||||||
# `gofmt -l` prints the names of unformatted files. An empty
|
|
||||||
# output is the only acceptable result.
|
|
||||||
run: gofmt -l cmd internal pkg
|
|
||||||
|
|
||||||
- name: go vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: codeberg-small
|
|
||||||
needs: vet
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.26"
|
|
||||||
|
|
||||||
- name: Download dependencies
|
|
||||||
run: go mod download
|
|
||||||
|
|
||||||
- name: go test -race
|
|
||||||
run: go test -race -count=1 ./...
|
|
||||||
|
|
||||||
- name: go test ./examples
|
|
||||||
run: go build ./examples/...
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Release — Go binaries. Runs on version tags (v1.2.3) pushed to main.
|
||||||
|
# Builds the platform matrix, creates the Gitea release, uploads binaries.
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -6,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: codeberg-small
|
runs-on: fedora
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -15,66 +17,80 @@ jobs:
|
|||||||
goarch: amd64
|
goarch: amd64
|
||||||
- goos: linux
|
- goos: linux
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: linux
|
||||||
|
goarch: riscv64
|
||||||
|
- goos: linux
|
||||||
|
goarch: loong64
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: amd64
|
goarch: amd64
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: riscv64
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
- name: Build
|
- name: Validate tag and build
|
||||||
id: build
|
id: build
|
||||||
env:
|
env:
|
||||||
REF: ${{ forgejo.ref }}
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${REF##*/}"
|
if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then
|
||||||
|
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'"
|
||||||
if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
|
|
||||||
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION_NO_V="${VERSION#v}"
|
VERSION_NO_V="${VERSION#v}"
|
||||||
echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT"
|
echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
LDFLAGS="-s -w -X codeberg.org/petrbalvin/nuntius/internal/version.Version=${VERSION}"
|
# The tag is the source of truth — inject it into the binary.
|
||||||
|
LDFLAGS="-s -w -X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION_NO_V}"
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
|
||||||
go build -ldflags "$LDFLAGS" \
|
go build -ldflags "$LDFLAGS" \
|
||||||
-o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \
|
-o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \
|
||||||
./cmd/server
|
./cmd/server
|
||||||
|
|
||||||
|
# Artifacts stay on v3 — v4 detects Gitea as GHES and aborts.
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }}
|
name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Only binaries matching the runner can be smoke-tested.
|
||||||
|
- name: Smoke test
|
||||||
|
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||||
|
run: |
|
||||||
|
chmod +x bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
./bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} --version
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: codeberg-tiny
|
runs-on: fedora
|
||||||
needs: build
|
needs: build
|
||||||
|
permissions:
|
||||||
|
releases: write
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: https://data.forgejo.org/actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Extract CHANGELOG section
|
- name: Extract CHANGELOG section
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ forgejo.ref_name }}
|
VERSION: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION_NO_V="${VERSION#v}"
|
VERSION_NO_V="${VERSION#v}"
|
||||||
@@ -96,50 +112,41 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# JSON-escape the release body with sed: backslashes first, then
|
BODY=$(sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r//g' release-body.md | sed ':a;N;$!ba;s/\n/\\n/g')
|
||||||
# quotes, tabs, and carriage returns, and finally fold newlines into
|
|
||||||
# \n. Wrap the result in quotes to form a JSON string.
|
|
||||||
BODY=$(sed -e 's/\\/\\\\/g' \
|
|
||||||
-e 's/"/\\"/g' \
|
|
||||||
-e 's/\t/\\t/g' \
|
|
||||||
-e 's/\r//g' \
|
|
||||||
release-body.md \
|
|
||||||
| sed ':a;N;$!ba;s/\n/\\n/g')
|
|
||||||
BODY="\"${BODY}\""
|
BODY="\"${BODY}\""
|
||||||
|
|
||||||
response=$(curl -sS -w '\n%{http_code}' \
|
response=$(curl -sS -w '\n%{http_code}' \
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST \
|
-X POST \
|
||||||
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
|
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||||||
-d "{
|
-d "{\"tag_name\":\"${GITEA_REF_NAME}\",\"name\":\"${GITEA_REF_NAME}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
|
||||||
\"tag_name\": \"${FORGEJO_REF_NAME}\",
|
|
||||||
\"name\": \"${FORGEJO_REF_NAME}\",
|
|
||||||
\"body\": ${BODY},
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": false
|
|
||||||
}")
|
|
||||||
|
|
||||||
http_code=$(echo "$response" | tail -1)
|
http_code=$(echo "$response" | tail -1)
|
||||||
body=$(echo "$response" | sed '$d')
|
payload=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
echo "HTTP ${http_code}"
|
echo "HTTP ${http_code}"
|
||||||
|
|
||||||
if [ "$http_code" != "201" ]; then
|
if [ "$http_code" != "201" ]; then
|
||||||
echo "Failed to create release: ${body}"
|
echo "Failed to create release: ${payload}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RELEASE_ID=$(echo "$body" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
RELEASE_ID=$(echo "$payload" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||||
echo "Created release ID=${RELEASE_ID}"
|
echo "Created release ID=${RELEASE_ID}"
|
||||||
printf '%s' "${RELEASE_ID}" > release-id.txt
|
printf '%s' "${RELEASE_ID}" > release-id.txt
|
||||||
|
|
||||||
- name: Upload assets
|
- name: Upload assets
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
RELEASE_ID=$(cat release-id.txt)
|
RELEASE_ID=$(cat release-id.txt)
|
||||||
@@ -150,11 +157,11 @@ jobs:
|
|||||||
fname=$(basename "$binary")
|
fname=$(basename "$binary")
|
||||||
echo "Uploading ${fname}..."
|
echo "Uploading ${fname}..."
|
||||||
http_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
http_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
-X POST \
|
-X POST \
|
||||||
--data-binary "@${binary}" \
|
--data-binary "@${binary}" \
|
||||||
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${fname}")
|
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${fname}")
|
||||||
echo " HTTP ${http_code}"
|
echo " HTTP ${http_code}"
|
||||||
if [ "$http_code" != "201" ]; then
|
if [ "$http_code" != "201" ]; then
|
||||||
echo "Failed to upload ${fname}"
|
echo "Failed to upload ${fname}"
|
||||||
@@ -163,5 +170,5 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Release ${FORGEJO_REF_NAME} is live with the following assets:"
|
echo "Release ${GITEA_REF_NAME} is live with the following assets:"
|
||||||
ls -lh dist/nuntius-*/
|
ls -lh dist/nuntius-*/
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Test — Go. Runs on push and pull request to development. Never on main.
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [development]
|
||||||
|
pull_request:
|
||||||
|
branches: [development]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
vet:
|
||||||
|
runs-on: fedora
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
# gofmt -l prints unformatted files; an empty output is the only pass.
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files need gofmt:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: fedora
|
||||||
|
needs: vet
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
# The runner images have no gcc; the race detector needs CGO.
|
||||||
|
- name: Install gcc
|
||||||
|
run: dnf install -y gcc
|
||||||
|
|
||||||
|
- name: go test -race
|
||||||
|
run: go test -race -count=1 ./...
|
||||||
|
|
||||||
|
# Coverage is measured over the library packages; cmd/ is thin glue.
|
||||||
|
- name: Coverage gate — 80 % minimum
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go test -coverprofile=coverage.out ./internal/... ./pkg/...
|
||||||
|
coverage=$(go tool cover -func=coverage.out | awk '/^total:/ { gsub("%", "", $3); print $3 }')
|
||||||
|
echo "Total coverage: ${coverage}%"
|
||||||
|
if awk -v c="$coverage" 'BEGIN { exit !(c+0 < 80) }'; then
|
||||||
|
echo "ERROR: coverage ${coverage}% is below the 80% threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build examples
|
||||||
|
run: go build ./examples/...
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: fedora
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -ldflags="-s -w" -o bin/nuntius ./cmd/server
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
run: ./bin/nuntius --version
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
bin/
|
bin/
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
# Local secrets and config
|
# Local secrets and config
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
+52
-3
@@ -5,13 +5,60 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [development]
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **RISC-V and LoongArch builds** — CI now produces binaries for `linux/riscv64`,
|
||||||
|
`linux/loong64`, and `freebsd/riscv64` in addition to the existing four
|
||||||
|
targets.
|
||||||
|
- **80 % coverage gate** — `just test` and CI enforce ≥ 80 % test coverage on
|
||||||
|
`internal/` and `pkg/` packages.
|
||||||
|
- **Handler and email test suites** — 22 handler tests covering CORS, rate
|
||||||
|
limiting, validation, honeypot, and send/store paths via mock interfaces.
|
||||||
|
12 email composition tests covering all four form types.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Module migrated to sourcedock.dev** — all imports and documentation now
|
||||||
|
reference `sourcedock.dev/petrbalvin/nuntius`.
|
||||||
|
- **CI migrated from Forgejo to Gitea** — workflows now live in
|
||||||
|
`.gitea/workflows/`, run on the `fedora` runner, and use the standard
|
||||||
|
`actions/*` short form.
|
||||||
|
- **Dependency bump** — `interpres` updated to v1.1.1.
|
||||||
|
- **IPv6-first** — the server now binds `[::]:port` for explicit dual-stack.
|
||||||
|
- **Installer rewritten in Python** — `install.py` replaces `install.sh`.
|
||||||
|
- **nginx → Caddy** — all documentation and the installer now use Caddy as the
|
||||||
|
recommended reverse proxy.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Rate limiter memory leak** — a periodic cleanup goroutine removes expired
|
||||||
|
IP entries from the token-bucket map.
|
||||||
|
- **Silent template errors** — `compose()` now produces a fallback HTML message
|
||||||
|
instead of silently discarding template execution errors.
|
||||||
|
- **Silent JSON encoding errors** — all `json.NewEncoder(w).Encode()` calls now
|
||||||
|
log errors instead of discarding them.
|
||||||
|
- **CI formatting gate** — `gofmt -l` in the test workflow now actually fails
|
||||||
|
when it finds unformatted files.
|
||||||
|
- **Missing `just fmt` recipe** — added `gofmt -w` target.
|
||||||
|
- **Copyright headers** — added to all Go and Python source files.
|
||||||
|
- **Smoke test restricted to native builds** — cross-compiled binaries now
|
||||||
|
skip the smoke test step since they cannot execute on the Linux amd64 CI
|
||||||
|
runner.
|
||||||
|
|
||||||
|
[development]: https://sourcedock.dev/petrbalvin/nuntius/compare/v1.1.0...development
|
||||||
|
[1.1.0]: https://sourcedock.dev/petrbalvin/nuntius/releases/tag/v1.1.0
|
||||||
|
|
||||||
## [1.0.0] — 2026-06-20
|
## [1.0.0] — 2026-06-20
|
||||||
|
|
||||||
First stable release of nuntius: a small, opinionated, modular contact form
|
First stable release of nuntius: a small, opinionated, modular contact form
|
||||||
backend written in Go. A single static binary, four form kinds out of the box,
|
backend written in Go. A single static binary, four form kinds out of the box,
|
||||||
SMTP delivery, a JSONL newsletter log, strict TOML configuration, and env-var
|
SMTP delivery, a JSONL newsletter log, strict TOML configuration, and env-var
|
||||||
secrets. Its only dependency outside the standard library is the first-party
|
secrets. Its only dependency outside the standard library is the first-party
|
||||||
[interpres](https://codeberg.org/petrbalvin/interpres) TOML parser.
|
[interpres](https://sourcedock.dev/petrbalvin/interpres)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -44,9 +91,11 @@ secrets. Its only dependency outside the standard library is the first-party
|
|||||||
`/etc/nuntius/config.toml` on first start.
|
`/etc/nuntius/config.toml` on first start.
|
||||||
- **systemd unit** — installed inline from `docs/deployment.md` with
|
- **systemd unit** — installed inline from `docs/deployment.md` with
|
||||||
`Restart=on-failure` and `EnvironmentFile=`.
|
`Restart=on-failure` and `EnvironmentFile=`.
|
||||||
- **Install script** — `install.sh`, idempotent, handles user creation, config
|
- **Install script** — `install.py`, idempotent, handles user creation, config
|
||||||
generation, and secrets file mode `0600`.
|
generation, and secrets file mode `0600`.
|
||||||
- **docs/** — `architecture.md`, `configuration.md`, `api-reference.md`,
|
- **docs/** — `architecture.md`, `configuration.md`, `api-reference.md`,
|
||||||
`deployment.md`, `security.md`, `library-usage.md`.
|
`deployment.md`, `security.md`, `library-usage.md`.
|
||||||
|
|
||||||
[1.0.0]: https://codeberg.org/petrbalvin/nuntius/releases/tag/v1.0.0
|
[1.1.0]: https://sourcedock.dev/petrbalvin/nuntius/releases/tag/v1.1.0
|
||||||
|
|
||||||
|
[1.0.0]: https://sourcedock.dev/petrbalvin/nuntius/releases/tag/v1.0.0
|
||||||
|
|||||||
+28
-21
@@ -18,13 +18,13 @@ See [Cutting a release](#cutting-a-release) for the full procedure.
|
|||||||
|
|
||||||
- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`)
|
- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`)
|
||||||
- [`just`](https://github.com/casey/just) command runner (the `justfile` is the source of truth for build / test / run / install / uninstall)
|
- [`just`](https://github.com/casey/just) command runner (the `justfile` is the source of truth for build / test / run / install / uninstall)
|
||||||
- Linux, macOS, or FreeBSD
|
- Linux or FreeBSD
|
||||||
- A local SMTP account for end-to-end testing. Tests in `internal/storage` and `pkg/contactform` do not require SMTP.
|
- A local SMTP account for end-to-end testing. Tests in `internal/storage` and `pkg/contactform` do not require SMTP.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://codeberg.org/petrbalvin/nuntius.git
|
git clone https://sourcedock.dev/petrbalvin/nuntius.git
|
||||||
cd nuntius
|
cd nuntius
|
||||||
just install # go mod tidy + go mod download (no third-party modules — no-op for this project)
|
just install # go mod tidy + go mod download (no third-party modules — no-op for this project)
|
||||||
just build # → ./bin/nuntius (with -ldflags="-s -w")
|
just build # → ./bin/nuntius (with -ldflags="-s -w")
|
||||||
@@ -42,8 +42,15 @@ just test
|
|||||||
|
|
||||||
The suite covers:
|
The suite covers:
|
||||||
|
|
||||||
- `pkg/contactform` — validators for all four form types (contact, feedback, newsletter, generic), whitespace trimming, and error message shape.
|
- `cmd/server` — client IP extraction, request-logging middleware.
|
||||||
|
- `internal/config` — TOML loading, env-var expansion, schema validation.
|
||||||
|
- `internal/email` — SMTP message composition (`multipart/alternative`) and subject templates.
|
||||||
|
- `internal/handler` — form handler pipeline (CORS, rate limit, honeypot, validation, send), rate-limiter cleanup loop.
|
||||||
- `internal/storage` — `Append` / `Count` / `List` semantics, file and directory auto-creation, missing-file tolerance, malformed-line resilience.
|
- `internal/storage` — `Append` / `Count` / `List` semantics, file and directory auto-creation, missing-file tolerance, malformed-line resilience.
|
||||||
|
- `internal/version` — version identity and `--version` smoke output.
|
||||||
|
- `pkg/contactform` — validators for all four form types (contact, feedback, newsletter, generic), whitespace trimming, and error message shape.
|
||||||
|
|
||||||
|
CI enforces a **≥80 % coverage threshold** on `internal/...` and `pkg/...`. Run `go test -coverprofile=coverage.out ./internal/... ./pkg/... && go tool cover -func=coverage.out` locally to check before pushing.
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
@@ -140,7 +147,7 @@ nuntius/
|
|||||||
├── examples/
|
├── examples/
|
||||||
│ └── minimal/ # Standalone usage of pkg/contactform (no HTTP server)
|
│ └── minimal/ # Standalone usage of pkg/contactform (no HTTP server)
|
||||||
├── docs/ # Architecture, configuration, API, deployment, security, library usage
|
├── docs/ # Architecture, configuration, API, deployment, security, library usage
|
||||||
├── install.sh # One-shot server installer (idempotent)
|
├── install.py # One-shot server installer (idempotent)
|
||||||
├── justfile # install, build, test, run, uninstall
|
├── justfile # install, build, test, run, uninstall
|
||||||
├── go.mod # Go 1.26, no external dependencies
|
├── go.mod # Go 1.26, no external dependencies
|
||||||
└── LICENSE # MIT
|
└── LICENSE # MIT
|
||||||
@@ -151,7 +158,7 @@ nuntius/
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Browser[Browser / Frontend]:::accent6
|
Browser[Browser / Frontend]:::accent6
|
||||||
Nginx[nginx reverse proxy]:::accent6
|
Caddy[Caddy reverse proxy]:::accent6
|
||||||
CLI[cmd/server/main.go]:::accent0
|
CLI[cmd/server/main.go]:::accent0
|
||||||
Config[internal/config/]:::accent7
|
Config[internal/config/]:::accent7
|
||||||
Handler[internal/handler/]:::accent0
|
Handler[internal/handler/]:::accent0
|
||||||
@@ -161,8 +168,8 @@ graph TD
|
|||||||
SMTP[(SMTP server)]:::accent2
|
SMTP[(SMTP server)]:::accent2
|
||||||
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
|
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
|
||||||
|
|
||||||
Browser -->|POST /api/nuntius/contact| Nginx
|
Browser -->|POST /api/nuntius/contact| Caddy
|
||||||
Nginx -->|forward with X-Forwarded-For| CLI
|
Caddy -->|forward with X-Forwarded-For| CLI
|
||||||
CLI --> Config
|
CLI --> Config
|
||||||
CLI --> Handler
|
CLI --> Handler
|
||||||
Handler --> Validate
|
Handler --> Validate
|
||||||
@@ -170,8 +177,8 @@ graph TD
|
|||||||
Handler --> Storage
|
Handler --> Storage
|
||||||
Email -->|SMTP PLAIN| SMTP
|
Email -->|SMTP PLAIN| SMTP
|
||||||
Storage -->|append JSONL| JSONL
|
Storage -->|append JSONL| JSONL
|
||||||
Handler -->|200 ok / 4xx / 5xx| Nginx
|
Handler -->|200 ok / 4xx / 5xx| Caddy
|
||||||
Nginx --> Browser
|
Caddy --> Browser
|
||||||
|
|
||||||
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
||||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||||
@@ -203,16 +210,16 @@ graph TD
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
This project uses **Forgejo Actions** for continuous integration. The two workflow files live under `.forgejo/workflows/` and are the source of truth for what runs on every push and on every tagged release.
|
This project uses **Gitea Actions** for continuous integration. The two workflow files live under `.gitea/workflows/` and are the source of truth for what runs on every push and on every tagged release.
|
||||||
|
|
||||||
### Test workflow — `.forgejo/workflows/test.yml`
|
### Test workflow — `.gitea/workflows/test.yml`
|
||||||
|
|
||||||
Triggered on every push to `development` and on every pull request targeting `development`. `main` is excluded on purpose — it only ever receives release tags, and the release workflow builds the binaries itself. Three jobs run in sequence, all on the `codeberg-small` runner:
|
Triggered on every push to `development` and on every pull request targeting `development`. `main` is excluded on purpose — it only ever receives release tags, and the release workflow builds the binaries itself. Three jobs run in sequence, all on the `fedora` runner:
|
||||||
|
|
||||||
| Job | What it does | Why it exists |
|
| Job | What it does | Why it exists |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `vet` | `gofmt -l cmd internal pkg` (must print nothing) and `go vet ./...` | Catches formatting drift and static-analysis issues before tests run. |
|
| `vet` | `gofmt -l cmd internal pkg` (must print nothing) and `go vet ./...` | Catches formatting drift and static-analysis issues before tests run. |
|
||||||
| `test` | `go test -race -count=1 ./...` plus `go build ./examples/...` | Race detector catches issues in the per-form rate limiter, the storage mutex, and the SMTP send path. `-count=1` disables the result cache. The example build is a smoke test for the public library. |
|
| `test` | `go test -race -count=1 ./...`, coverage gate (`≥80 %` on `internal/...` and `pkg/...`), plus `go build ./examples/...` | Race detector catches issues in the per-form rate limiter, the storage mutex, and the SMTP send path. `-count=1` disables the result cache. The coverage gate fails the build if the combined statement coverage drops below 80 %. The example build is a smoke test for the public library. |
|
||||||
| `build` | `just build`, `just test`, and `./bin/nuntius --version` smoke test | Confirms the `justfile` build recipe works end-to-end and that `--version` still prints the expected output. |
|
| `build` | `just build`, `just test`, and `./bin/nuntius --version` smoke test | Confirms the `justfile` build recipe works end-to-end and that `--version` still prints the expected output. |
|
||||||
|
|
||||||
Run the same checks locally before opening a pull request:
|
Run the same checks locally before opening a pull request:
|
||||||
@@ -225,32 +232,32 @@ just build
|
|||||||
./bin/nuntius --version # sanity-check the new flag
|
./bin/nuntius --version # sanity-check the new flag
|
||||||
```
|
```
|
||||||
|
|
||||||
### Release workflow — `.forgejo/workflows/release.yml`
|
### Release workflow — `.gitea/workflows/release.yml`
|
||||||
|
|
||||||
Triggered by pushing a tag that matches `v*.*.*` (e.g. `v1.0.0`, `v1.2.3`) onto `main`. The merge from `development` into `main` is the maintainer's responsibility and happens before the tag is pushed — this workflow does not merge anything. Two jobs:
|
Triggered by pushing a tag that matches `v*.*.*` (e.g. `v1.0.0`, `v1.2.3`) onto `main`. The merge from `development` into `main` is the maintainer's responsibility and happens before the tag is pushed — this workflow does not merge anything. Two jobs:
|
||||||
|
|
||||||
1. **`build`** — Runs on a `codeberg-small` runner in a 4-way matrix: `linux/amd64`, `linux/arm64`, `freebsd/amd64`, `freebsd/arm64`. The default `actions/checkout@v4` checks out the tagged commit (the trigger was a tag push), which is exactly the commit on `main` we want to ship. The version is baked in via `-ldflags`:
|
1. **`build`** — Runs on a `fedora` runner in a 7-way matrix: `linux/amd64`, `linux/arm64`, `linux/riscv64`, `linux/loong64`, `freebsd/amd64`, `freebsd/arm64`, `freebsd/riscv64`. The default `actions/checkout@v4` checks out the tagged commit (the trigger was a tag push), which is exactly the commit on `main` we want to ship. The version is baked in via `-ldflags`:
|
||||||
```bash
|
```bash
|
||||||
-X codeberg.org/petrbalvin/nuntius/internal/version.Version=${VERSION#v}
|
-X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION_NO_V}
|
||||||
```
|
```
|
||||||
The resulting binary is uploaded as a workflow artifact and smoke-tested with `--version` on the build host.
|
The resulting binary is uploaded as a workflow artifact and smoke-tested with `--version` on the build host.
|
||||||
2. **`release`** — Runs on a `codeberg-tiny` runner (no Go toolchain needed). Downloads all artifacts, extracts the matching section from `CHANGELOG.md` for the tagged version, creates a release via the Forgejo REST API, and uploads each binary as a release asset. The release is **non-draft, non-prerelease**; if the CHANGELOG section is missing, the job fails fast so a release cannot ship without notes.
|
2. **`release`** — Runs on a `fedora` runner (no Go toolchain needed). Downloads all artifacts, extracts the matching section from `CHANGELOG.md` for the tagged version, creates a release via the Gitea REST API, and uploads each binary as a release asset. The release is **non-draft, non-prerelease**; if the CHANGELOG section is missing, the job fails fast so a release cannot ship without notes.
|
||||||
|
|
||||||
### Cutting a release
|
### Cutting a release
|
||||||
|
|
||||||
1. Bump `Version` in `internal/version/version.go`.
|
1. Bump `Version` in `internal/version/version.go`.
|
||||||
2. Add a new `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`.
|
2. Add a new `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`.
|
||||||
3. Make sure CI is green on `development` (pushes and PRs against `development` are what the test workflow runs on).
|
3. Make sure CI is green on `development` (pushes and PRs against `development` are what the test workflow runs on).
|
||||||
4. Merge `development` into `main` (e.g. open a PR on Forgejo, or `git checkout main && git merge --no-ff development && git push origin main`). This is the **only** step where `main` is updated outside of a tag push.
|
4. Merge `development` into `main` (e.g. open a PR on Gitea, or `git checkout main && git merge --no-ff development && git push origin main`). This is the **only** step where `main` is updated outside of a tag push.
|
||||||
5. Tag the `main` tip and push the tag:
|
5. Tag the `main` tip and push the tag:
|
||||||
```bash
|
```bash
|
||||||
git tag -a vX.Y.Z -m "nuntius vX.Y.Z"
|
git tag -a vX.Y.Z -m "nuntius vX.Y.Z"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin vX.Y.Z
|
git push origin vX.Y.Z
|
||||||
```
|
```
|
||||||
6. The release workflow builds the four platform binaries and creates the release with the CHANGELOG section as the body. No CI step performs the merge — that already happened in step 4.
|
6. The release workflow builds the seven platform binaries and creates the release with the CHANGELOG section as the body. No CI step performs the merge — that already happened in step 4.
|
||||||
|
|
||||||
If the release job fails, the tag can be deleted and re-pushed; the workflow re-runs on the new tag push. Do not re-tag an already-released commit — the Forgejo API rejects duplicate release tag names.
|
If the release job fails, the tag can be deleted and re-pushed; the workflow re-runs on the new tag push. Do not re-tag an already-released commit — the Gitea API rejects duplicate release tag names.
|
||||||
|
|
||||||
## Adding a New Form Type
|
## Adding a New Form Type
|
||||||
|
|
||||||
@@ -264,7 +271,7 @@ The four current types (`contact`, `feedback`, `newsletter`, `generic`) live in
|
|||||||
|
|
||||||
## Report a Bug
|
## Report a Bug
|
||||||
|
|
||||||
Open an issue at <https://codeberg.org/petrbalvin/nuntius/issues> with:
|
Open an issue at <https://sourcedock.dev/petrbalvin/nuntius/issues> with:
|
||||||
|
|
||||||
- nuntius version (`./bin/nuntius --version`).
|
- nuntius version (`./bin/nuntius --version`).
|
||||||
- Operating system and architecture.
|
- Operating system and architecture.
|
||||||
|
|||||||
@@ -1,145 +1,83 @@
|
|||||||
# Nuntius — A Small, Opinionated Contact Form Backend
|
# Nuntius — A Small, Opinionated Contact Form Backend
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://go.dev)
|
|
||||||
[](https://kernel.org)
|
|
||||||
[](https://freebsd.org)
|
|
||||||
|
|
||||||
> Latin *nuntius* = "messenger" — the service that carries messages from visitors to you.
|
> Latin *nuntius* = "messenger" — the service that carries messages from visitors to you.
|
||||||
|
|
||||||
**nuntius** is a small, modular contact form backend written in Go. It serves multiple JSON endpoints (contact, feedback, newsletter, generic) over a single static binary, delivers submissions via SMTP, optionally persists newsletter signups to a JSONL log, and is designed to sit behind nginx on a tiny Linux server. Only the first-party `interpres` TOML module, no external third-party Go modules, ~6 MB binary.
|
**nuntius** is a small, modular contact form backend written in Go. It serves multiple
|
||||||
|
JSON endpoints (contact, feedback, newsletter, generic) over a single static binary,
|
||||||
|
delivers submissions via SMTP, optionally persists newsletter signups to a JSONL log,
|
||||||
|
and is designed to sit behind Caddy on a modest Linux or FreeBSD server.
|
||||||
|
|
||||||
## Features
|
Module `sourcedock.dev/petrbalvin/nuntius` — Go 1.26, zero third-party dependencies.
|
||||||
|
The only external module is the first-party
|
||||||
- **Single static binary** — `go build -ldflags="-s -w"` produces ≈ 6 MB; no Node, no Python, no Redis, no Postgres, no third-party Go packages.
|
[`interpres`](https://sourcedock.dev/petrbalvin/interpres) TOML parser. Stripped
|
||||||
- **Four form kinds out of the box** — `contact`, `feedback`, `newsletter`, and `generic`. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field.
|
binary is roughly 6 MB.
|
||||||
- **SMTP delivery** — Go stdlib `net/smtp` with `PLAIN` auth. Works with any provider that exposes SMTP.
|
|
||||||
- **Newsletter subscribers on disk** — append-only JSONL log under `data_dir/`. No database, no double opt-in. One `{email, ip, form, created_at}` line per signup.
|
|
||||||
- **Server-side validation** — `pkg/contactform.Validate` is reusable; name, email (RFC 5322), service allow-list (for `contact`), and message length.
|
|
||||||
- **Reusable package** — `pkg/contactform` exports typed `Request` / `Response` / `ErrorResponse` plus a `Validate` function you can drop into any Go project.
|
|
||||||
- **Structured logging** — `log/slog` with JSON output to stdout; plays well with journald, Loki, Datadog…
|
|
||||||
- **Graceful shutdown** — SIGINT/SIGTERM close in-flight requests within 15 s.
|
|
||||||
- **Rate limiting** — token-bucket per IP, configurable per hour, in-memory (no Redis required).
|
|
||||||
- **Honeypot field** — invisible to humans, required for spam bots. Silently accepts and drops.
|
|
||||||
- **Config validation** — unknown fields and unknown form types are rejected at startup, so typos fail loudly instead of silently disabling a form.
|
|
||||||
- **Env-var secrets** — `${VAR_NAME}` / `$VAR_NAME` expansion in the TOML file keeps SMTP passwords out of disk.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone
|
# 1. Clone
|
||||||
git clone https://codeberg.org/petrbalvin/nuntius.git
|
git clone https://sourcedock.dev/petrbalvin/nuntius.git
|
||||||
cd nuntius
|
cd nuntius
|
||||||
|
|
||||||
# 2. Build
|
# 2. Build
|
||||||
just build
|
just build
|
||||||
# → ./bin/nuntius
|
# → ./bin/nuntius
|
||||||
|
|
||||||
# 3. Run (auto-creates /etc/nuntius/config.toml with a 3-form template on first start)
|
# 3. Run (writes a three-form config template to /etc/nuntius/config.toml on first start)
|
||||||
sudo mkdir -p /etc/nuntius
|
sudo mkdir -p /etc/nuntius
|
||||||
just run
|
just run
|
||||||
# → "nuntius starting" addr=:8080 forms=3
|
# → nuntius starting addr=[::]:8080 forms=3
|
||||||
|
|
||||||
# 4. Edit the generated config to point at your SMTP and recipient
|
# 4. Edit the generated config with your SMTP credentials and recipient address
|
||||||
sudo $EDITOR /etc/nuntius/config.toml
|
sudo $EDITOR /etc/nuntius/config.toml
|
||||||
just run # restart to pick up changes
|
just run # restart to pick up changes
|
||||||
```
|
```
|
||||||
|
|
||||||
A default `config.toml` is auto-generated at `/etc/nuntius/config.toml` on first run with three sample forms (contact, feedback, newsletter). Edit it before exposing the service.
|
A default `config.toml` is auto-generated at `/etc/nuntius/config.toml` on first
|
||||||
|
run with three sample forms (contact, feedback, newsletter). Edit it before
|
||||||
|
exposing the service to the internet.
|
||||||
|
|
||||||
The full schema lives in [`docs/configuration.md`](docs/configuration.md); the deploy guide is in [`docs/deployment.md`](docs/deployment.md).
|
The full schema lives in [`docs/configuration.md`](docs/configuration.md); the
|
||||||
|
production deploy guide is in [`docs/deployment.md`](docs/deployment.md).
|
||||||
|
|
||||||
## Usage
|
## Features
|
||||||
|
|
||||||
### Test the endpoints
|
- **Single static binary** — `go build -ldflags="-s -w"` produces roughly 6 MB;
|
||||||
|
no Node, no Python, no Redis, no Postgres, no third-party Go packages.
|
||||||
|
- **Four form kinds** — `contact`, `feedback`, `newsletter`, and `generic`. Each
|
||||||
|
gets its own endpoint, rate limit, CORS allowlist, and honeypot field.
|
||||||
|
- **SMTP delivery** — Go stdlib `net/smtp` with `PLAIN` auth over STARTTLS or
|
||||||
|
implicit TLS. Works with any standards-compliant SMTP provider.
|
||||||
|
- **Newsletter subscribers on disk** — append-only JSONL log under `data_dir/`.
|
||||||
|
No database, no double opt-in. One `{email, ip, form, created_at}` line per
|
||||||
|
valid signup.
|
||||||
|
- **Server-side validation** — `pkg/contactform.Validate` is reusable; validates
|
||||||
|
name, email (RFC 5322), service allow-list (for `contact`), and message length.
|
||||||
|
- **Reusable Go package** — `pkg/contactform` exports typed `Request`, `Response`,
|
||||||
|
and `ErrorResponse` structs plus the `Validate` function you can import into
|
||||||
|
any Go project.
|
||||||
|
- **Structured logging** — `log/slog` with JSON output to stdout; plays well
|
||||||
|
with journald, Loki, Datadog, and similar.
|
||||||
|
- **Graceful shutdown** — SIGINT and SIGTERM close in-flight requests within a
|
||||||
|
configurable deadline (default 15 s).
|
||||||
|
- **Token-bucket rate limiting** — per IP, per form, configurable submissions per
|
||||||
|
hour; in-memory, no external store required.
|
||||||
|
- **Honeypot field** — invisible to humans, required by bots. Silently accepts
|
||||||
|
and drops spam submissions.
|
||||||
|
- **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, riscv64,
|
||||||
|
loong64) and FreeBSD (amd64, arm64, riscv64) on every release.
|
||||||
|
|
||||||
```bash
|
## Configuration
|
||||||
# Contact form
|
|
||||||
curl -X POST http://localhost:8080/api/nuntius/contact \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Origin: https://petrbalvin.org" \
|
|
||||||
-d '{
|
|
||||||
"name": "Jane Doe",
|
|
||||||
"email": "jane@example.com",
|
|
||||||
"service": "architecture",
|
|
||||||
"message": "Hello, I would like to discuss an engagement."
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Feedback (no service field)
|
Default path: `/etc/nuntius/config.toml`. A starter file is written automatically
|
||||||
curl -X POST http://localhost:8080/api/nuntius/feedback \
|
on first start.
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Origin: https://petrbalvin.org" \
|
|
||||||
-d '{
|
|
||||||
"name": "Jane Doe",
|
|
||||||
"email": "jane@example.com",
|
|
||||||
"message": "I love how the site uses Vue.js with zero runtime deps."
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Newsletter (email only)
|
|
||||||
curl -X POST http://localhost:8080/api/nuntius/newsletter \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Origin: https://petrbalvin.org" \
|
|
||||||
-d '{ "email": "jane@example.com" }'
|
|
||||||
```
|
|
||||||
|
|
||||||
Successful response (all three):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation error (e.g. newsletter with bad email):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "validation",
|
|
||||||
"details": [
|
|
||||||
{ "field": "email", "message": "email is invalid" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend integration
|
|
||||||
|
|
||||||
If you proxy `/api/nuntius/*` through nginx (recommended, see [`docs/deployment.md`](docs/deployment.md)), no frontend config is needed — the form posts to the same origin as the page. Otherwise, set `VITE_NUNTIUS_URL` to the upstream origin:
|
|
||||||
|
|
||||||
```js
|
|
||||||
await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/contact`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: name.value,
|
|
||||||
email: email.value,
|
|
||||||
service: service.value,
|
|
||||||
message: message.value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use the package in your own project
|
|
||||||
|
|
||||||
`pkg/contactform` is a small, dependency-free Go package that you can import into any project:
|
|
||||||
|
|
||||||
```go
|
|
||||||
import "codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
|
||||||
|
|
||||||
req := &contactform.Request{
|
|
||||||
Name: "Jane",
|
|
||||||
Email: "jane@example.com",
|
|
||||||
Service: "architecture",
|
|
||||||
Message: "Hello!",
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
|
|
||||||
// handle validation errors
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Full library example: [`docs/library-usage.md`](docs/library-usage.md). Full HTTP API: [`docs/api-reference.md`](docs/api-reference.md).
|
|
||||||
|
|
||||||
## Configuration (TL;DR)
|
|
||||||
|
|
||||||
Default path: `/etc/nuntius/config.toml`. A starter file is written automatically on first start.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
data_dir = "./data"
|
data_dir = "./data"
|
||||||
@@ -164,44 +102,139 @@ allowed_origins = ["https://example.com"]
|
|||||||
password = "${NUNTIUS_SMTP_PASSWORD}"
|
password = "${NUNTIUS_SMTP_PASSWORD}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Form types and full schema: [`docs/configuration.md`](docs/configuration.md). Secrets via env vars, CORS, honeypot, rate limit, and validation rules are all documented there.
|
Secrets are injected via environment variables using `${VAR_NAME}` syntax. The
|
||||||
|
full schema, form types, validation rules, and newsletter log format are
|
||||||
|
documented in [`docs/configuration.md`](docs/configuration.md).
|
||||||
|
|
||||||
## Requirements
|
## Form Types
|
||||||
|
|
||||||
- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`)
|
| Type | Required fields | Email subject | Stores to disk |
|
||||||
- An **SMTP** account (Yandex, Mailgun, Postmark, your own Postfix, …)
|
|------|-----------------|---------------|:---:|
|
||||||
- **No other runtime dependencies.** No Node, no Python, no Redis, no Postgres. Configuration is TOML parsed via the first-party [`interpres`](https://codeberg.org/petrbalvin/interpres) library; newsletter storage is an append-only JSONL file via `bufio`.
|
| `contact` | `name`, `email`, `message`; optional `service` | `[nuntius/<name>] Contact form submission` | |
|
||||||
|
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | |
|
||||||
|
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | ✓ |
|
||||||
|
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | |
|
||||||
|
|
||||||
|
All four types are rate-limited, CORS-checked, and honeypot-protected
|
||||||
|
independently — each form has its own per-IP bucket, allowlist, and honeypot
|
||||||
|
field. `newsletter` is the only type that writes to disk; the other three only
|
||||||
|
send email.
|
||||||
|
|
||||||
|
The `contact` form restricts the `service` field to an allow-list:
|
||||||
|
`architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`, or empty.
|
||||||
|
|
||||||
|
Full form type documentation: [`docs/configuration.md#form-types`](docs/configuration.md#form-types).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Every form endpoint is protected independently:
|
||||||
|
|
||||||
|
- **Honeypot** — a configurable invisible form field silently drops submissions
|
||||||
|
that contain it. Enabled per form via `honeypot_field`.
|
||||||
|
- **CORS** — per-form `allowed_origins` list. Requests from disallowed origins
|
||||||
|
receive a `403 Forbidden`.
|
||||||
|
- **Rate limiting** — token-bucket algorithm, per IP, per form, configurable
|
||||||
|
submissions per hour. In-memory; no Redis required. Set `rate_limit_per_hour`
|
||||||
|
to `0` to disable for a given form.
|
||||||
|
- **Server timeouts** — `ReadHeaderTimeout` prevents slow-loris attacks;
|
||||||
|
graceful shutdown deadline ensures in-flight requests are drained before exit.
|
||||||
|
- **Secret hygiene** — SMTP credentials are never logged; TOML values use
|
||||||
|
`${VAR_NAME}` expansion so passwords stay in the environment, not on disk.
|
||||||
|
- **Config validation** — unknown TOML fields and unknown form types cause a
|
||||||
|
startup failure, preventing typo-driven misconfigurations from reaching
|
||||||
|
production.
|
||||||
|
- **Input validation** — server-side validation of all fields with configurable
|
||||||
|
length limits; trimmed whitespace; RFC 5322 email parsing.
|
||||||
|
|
||||||
|
For a full threat model and detailed security discussion, see
|
||||||
|
[`docs/security.md`](docs/security.md). To report a vulnerability privately,
|
||||||
|
email **opensource@petrbalvin.org**.
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
Build the binary on your workstation, upload it with the systemd unit and
|
||||||
|
environment template to your server, then run `python3 install.py` as root. The
|
||||||
|
script is idempotent and sets up:
|
||||||
|
|
||||||
|
- A dedicated `nuntius` system user and group
|
||||||
|
- The binary at `/usr/local/bin/nuntius`
|
||||||
|
- Config auto-generation at `/etc/nuntius/config.toml`
|
||||||
|
- Environment file at `/etc/nuntius/.env` (mode `0600`)
|
||||||
|
- A systemd service unit at `/etc/systemd/system/nuntius.service`
|
||||||
|
- Data directory at `/var/lib/nuntius`
|
||||||
|
|
||||||
|
The service is **enabled but not started** — you review the config and
|
||||||
|
environment file first, then start it manually.
|
||||||
|
|
||||||
|
Complete deployment instructions including Caddy reverse-proxy configuration,
|
||||||
|
TLS setup, log rotation, and updates are in
|
||||||
|
[`docs/deployment.md`](docs/deployment.md).
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
The `pkg/contactform` package is a small, dependency-free Go library you can
|
||||||
|
import into any project:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
|
|
||||||
|
req := &contactform.Request{
|
||||||
|
Name: "Jane",
|
||||||
|
Email: "jane@example.com",
|
||||||
|
Service: "architecture",
|
||||||
|
Message: "Hello, I would like to discuss an engagement.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
|
||||||
|
// handle validation errors
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The package exports typed `Request`, `Response`, and `ErrorResponse` structs,
|
||||||
|
the `Validate` function, and all form kind constants. A full standalone example
|
||||||
|
is in [`docs/library-usage.md`](docs/library-usage.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just install # go mod tidy + go mod download (no-op for this project, kept for symmetry)
|
just install # go mod tidy + go mod download
|
||||||
just build # → ./bin/nuntius (with -s -w)
|
just build # → ./bin/nuntius (stripped, -s -w)
|
||||||
just test # go test ./...
|
just test # go test -race ./...; enforces ≥80% coverage on internal + pkg
|
||||||
|
just fmt # gofmt -w cmd internal pkg
|
||||||
just run # go run ./cmd/server
|
just run # go run ./cmd/server
|
||||||
just uninstall # rm -rf ./bin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All four `just` recipes are mandatory per the project conventions and produce zero errors and zero warnings.
|
All `just` recipes produce zero errors and zero warnings. Tests include the race
|
||||||
|
detector and enforce an 80% coverage floor on the `internal` and `pkg` packages.
|
||||||
|
|
||||||
|
The CI pipeline (Gitea Actions, `.gitea/workflows/`) runs the full test suite
|
||||||
|
and performs a multi-platform build matrix on every release tag
|
||||||
|
(`linux/amd64`, `linux/arm64`, `linux/riscv64`, `linux/loong64`, `freebsd/amd64`,
|
||||||
|
`freebsd/arm64`, `freebsd/riscv64`).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Go 1.26+**
|
||||||
|
- An **SMTP** account (any standards-compliant provider — Postfix, Yandex 360,
|
||||||
|
Mailgun, Postmark, etc.)
|
||||||
|
- **No other runtime dependencies.** No Node, no Python, no Redis, no Postgres.
|
||||||
|
Config is TOML parsed by the first-party
|
||||||
|
[`interpres`](https://sourcedock.dev/petrbalvin/interpres) library; newsletter
|
||||||
|
storage is an append-only JSONL file via `bufio`; rate limiting is in-memory.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`docs/architecture.md`](docs/architecture.md) — layers, request flow, design decisions
|
- [`docs/architecture.md`](docs/architecture.md) — layers, request flow, design decisions
|
||||||
- [`docs/configuration.md`](docs/configuration.md) — full config schema, form types, secrets, newsletter log
|
- [`docs/configuration.md`](docs/configuration.md) — full config schema, form types, secrets, newsletter log
|
||||||
- [`docs/api-reference.md`](docs/api-reference.md) — HTTP verbs, status codes, `GET /health`
|
- [`docs/api-reference.md`](docs/api-reference.md) — HTTP verbs, status codes, `GET /health`
|
||||||
- [`docs/deployment.md`](docs/deployment.md) — production systemd, nginx, updates
|
- [`docs/deployment.md`](docs/deployment.md) — production systemd, Caddy, updates
|
||||||
- [`docs/security.md`](docs/security.md) — threat model, CORS, rate limiting, secret handling
|
- [`docs/security.md`](docs/security.md) — threat model, CORS, rate limiting, secret handling
|
||||||
- [`docs/library-usage.md`](docs/library-usage.md) — `pkg/contactform` API and standalone use
|
- [`docs/library-usage.md`](docs/library-usage.md) — `pkg/contactform` API and standalone usage
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit conventions, and the pull request flow.
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit
|
||||||
|
conventions, and the pull request flow.
|
||||||
## Security
|
|
||||||
|
|
||||||
For a full threat model, see [`docs/security.md`](docs/security.md). The short version: every form has its own per-IP rate limit, CORS allowlist, and honeypot field; SMTP credentials never touch the log; unknown config fields are rejected at startup.
|
|
||||||
|
|
||||||
If you find a security issue, please email **opensource@petrbalvin.org** rather than opening a public issue.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Command nuntius runs the contact form backend server.
|
// Command nuntius runs the contact form backend server.
|
||||||
@@ -19,9 +22,9 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/config"
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/handler"
|
"sourcedock.dev/petrbalvin/nuntius/internal/handler"
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/version"
|
"sourcedock.dev/petrbalvin/nuntius/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -48,7 +51,7 @@ func main() {
|
|||||||
h.Register(mux)
|
h.Register(mux)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + itoa(cfg.Server.Port),
|
Addr: "[::]:" + itoa(cfg.Server.Port),
|
||||||
Handler: withRequestLog(mux, logger),
|
Handler: withRequestLog(mux, logger),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestItoa(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
n int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "0"},
|
||||||
|
{1, "1"},
|
||||||
|
{9, "9"},
|
||||||
|
{10, "10"},
|
||||||
|
{42, "42"},
|
||||||
|
{100, "100"},
|
||||||
|
{-1, "-1"},
|
||||||
|
{-42, "-42"},
|
||||||
|
{8080, "8080"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.want, func(t *testing.T) {
|
||||||
|
if got := itoa(tt.n); got != tt.want {
|
||||||
|
t.Errorf("itoa(%d) = %q, want %q", tt.n, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLast(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
s string
|
||||||
|
c byte
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"hello", 'l', 3},
|
||||||
|
{"hello", 'o', 4},
|
||||||
|
{"hello", 'h', 0},
|
||||||
|
{"hello", 'x', -1},
|
||||||
|
{"", 'a', -1},
|
||||||
|
{"aaa", 'a', 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := last(tt.s, tt.c); got != tt.want {
|
||||||
|
t.Errorf("last(%q, %q) = %d, want %d", tt.s, tt.c, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"},
|
||||||
|
{"ipv6 with brackets", "[::1]:12345", "[::1]"},
|
||||||
|
{"ipv4 without port", "192.168.1.1", "192.168.1.1"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = tt.remoteAddr
|
||||||
|
if got := clientIP(req); got != tt.want {
|
||||||
|
t.Errorf("clientIP() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,7 +172,7 @@ The IP used for rate limiting is taken from the same precedence chain as the log
|
|||||||
2. `X-Real-IP`
|
2. `X-Real-IP`
|
||||||
3. `RemoteAddr` (with the port stripped)
|
3. `RemoteAddr` (with the port stripped)
|
||||||
|
|
||||||
Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` so the bucket sees the real client IP.
|
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
|
||||||
|
|
||||||
## Health Check
|
## Health Check
|
||||||
|
|
||||||
|
|||||||
+30
-12
@@ -7,7 +7,7 @@
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Browser[Browser / Frontend]:::accent6
|
Browser[Browser / Frontend]:::accent6
|
||||||
Nginx[nginx reverse proxy]:::accent6
|
Caddy[Caddy reverse proxy]:::accent6
|
||||||
Server[cmd/server/main.go]:::accent0
|
Server[cmd/server/main.go]:::accent0
|
||||||
Config[internal/config/]:::accent7
|
Config[internal/config/]:::accent7
|
||||||
Handler[internal/handler/]:::accent0
|
Handler[internal/handler/]:::accent0
|
||||||
@@ -20,8 +20,8 @@ graph TD
|
|||||||
SMTP[(SMTP server)]:::accent2
|
SMTP[(SMTP server)]:::accent2
|
||||||
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
|
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
|
||||||
|
|
||||||
Browser -->|POST + Origin| Nginx
|
Browser -->|POST + Origin| Caddy
|
||||||
Nginx -->|X-Forwarded-For| Server
|
Caddy -->|X-Forwarded-For| Server
|
||||||
Server --> Config
|
Server --> Config
|
||||||
Server --> Handler
|
Server --> Handler
|
||||||
Handler --> RateLimit
|
Handler --> RateLimit
|
||||||
@@ -32,8 +32,8 @@ graph TD
|
|||||||
Handler --> Storage
|
Handler --> Storage
|
||||||
Sender -->|SMTP PLAIN over net/smtp| SMTP
|
Sender -->|SMTP PLAIN over net/smtp| SMTP
|
||||||
Storage -->|append JSONL| JSONL
|
Storage -->|append JSONL| JSONL
|
||||||
Handler -->|200 / 4xx / 5xx| Nginx
|
Handler -->|200 / 4xx / 5xx| Caddy
|
||||||
Nginx --> Browser
|
Caddy --> Browser
|
||||||
|
|
||||||
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
||||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||||
@@ -74,14 +74,28 @@ The `Form` type mirrors the TOML shape one-to-one. `ValidFormTypes` is the autho
|
|||||||
|
|
||||||
### 3. HTTP handler — `internal/handler/`
|
### 3. HTTP handler — `internal/handler/`
|
||||||
|
|
||||||
|
`ContactHandler` stores its dependencies behind small interfaces so the handler pipeline can be tested without a real SMTP server or filesystem:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type formSender interface {
|
||||||
|
Send(req contactform.Request) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriberStorer interface {
|
||||||
|
Append(sub storage.Subscriber) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`email.FormSender` and `storage.NewsletterStore` satisfy these interfaces in production. Tests use mock implementations (`mockSender`, `mockStore`) that record calls and simulate errors.
|
||||||
|
|
||||||
`ContactHandler` is a single struct with four maps keyed by URL path:
|
`ContactHandler` is a single struct with four maps keyed by URL path:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type ContactHandler struct {
|
type ContactHandler struct {
|
||||||
forms map[string]*config.Form
|
forms map[string]*config.Form
|
||||||
senders map[string]*email.FormSender
|
senders map[string]formSender
|
||||||
rateLimits map[string]*rateLimiter
|
rateLimits map[string]*rateLimiter
|
||||||
stores map[string]*storage.NewsletterStore
|
stores map[string]subscriberStorer
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,6 +156,10 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Rate limiter cleanup
|
||||||
|
|
||||||
|
The per-form `rateLimiter` type starts a background goroutine (`startCleanup`) that ticks once per hour and deletes IP entries older than twice the refill window. This prevents unbounded memory growth from one-off IPs. The cleanup goroutine is covered by `TestRateLimiterCleanup` in `contact_test.go`.
|
||||||
|
|
||||||
Notable behaviors:
|
Notable behaviors:
|
||||||
|
|
||||||
- **CORS is path-aware** — Each form has its own `allowed_origins` list; preflights and actual requests are checked against the form being hit, not the host.
|
- **CORS is path-aware** — Each form has its own `allowed_origins` list; preflights and actual requests are checked against the form being hit, not the host.
|
||||||
@@ -182,13 +200,13 @@ There is **no rotation**. Add a `logrotate` unit if the file grows beyond what y
|
|||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Browser
|
participant Browser
|
||||||
participant Nginx
|
participant Caddy
|
||||||
participant Nuntius as nuntius
|
participant Nuntius as nuntius
|
||||||
participant SMTP
|
participant SMTP
|
||||||
participant Disk
|
participant Disk
|
||||||
|
|
||||||
Browser->>Nginx: POST /api/nuntius/contact
|
Browser->>Caddy: POST /api/nuntius/contact
|
||||||
Nginx->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
|
Caddy->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
|
||||||
Nuntius->>Nuntius: CORS check
|
Nuntius->>Nuntius: CORS check
|
||||||
Nuntius->>Nuntius: rate-limit per IP
|
Nuntius->>Nuntius: rate-limit per IP
|
||||||
Nuntius->>Nuntius: parse JSON
|
Nuntius->>Nuntius: parse JSON
|
||||||
@@ -199,8 +217,8 @@ sequenceDiagram
|
|||||||
opt newsletter form
|
opt newsletter form
|
||||||
Nuntius->>Disk: append one JSONL line
|
Nuntius->>Disk: append one JSONL line
|
||||||
end
|
end
|
||||||
Nuntius-->>Nginx: 200 {"ok": true}
|
Nuntius-->>Caddy: 200 {"ok": true}
|
||||||
Nginx-->>Browser: 200 {"ok": true}
|
Caddy-->>Browser: 200 {"ok": true}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config Merging
|
## Config Merging
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ NUNTIUS_SMTP_PASSWORD=actual-app-password
|
|||||||
EnvironmentFile=/etc/nuntius/.env
|
EnvironmentFile=/etc/nuntius/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
The `install.sh` helper script sets the file mode automatically.
|
The `install.py` helper script sets the file mode automatically.
|
||||||
|
|
||||||
## Defaults at a Glance
|
## Defaults at a Glance
|
||||||
|
|
||||||
|
|||||||
+20
-24
@@ -1,6 +1,6 @@
|
|||||||
# Production Deployment
|
# Production Deployment
|
||||||
|
|
||||||
This guide covers installing nuntius on a Linux server with `nginx`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes.
|
This guide covers installing nuntius on a Linux server with `Caddy`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes.
|
||||||
|
|
||||||
## Architecture in Production
|
## Architecture in Production
|
||||||
|
|
||||||
@@ -8,21 +8,21 @@ This guide covers installing nuntius on a Linux server with `nginx`, `systemd`,
|
|||||||
graph LR
|
graph LR
|
||||||
Browser[Visitor's browser]:::accent6
|
Browser[Visitor's browser]:::accent6
|
||||||
Internet((Internet)):::accent2
|
Internet((Internet)):::accent2
|
||||||
Nginx[nginx :443]:::accent0
|
Caddy[Caddy :443]:::accent0
|
||||||
Nuntius[nuntius :8080]:::accent1
|
Nuntius[nuntius :8080]:::accent1
|
||||||
Systemd[systemd unit]:::accent7
|
Systemd[systemd unit]:::accent7
|
||||||
SMTP[(SMTP provider)]:::accent2
|
SMTP[(SMTP provider)]:::accent2
|
||||||
Disk[(/var/lib/nuntius/data/*.jsonl)]:::accent2
|
Disk[(/var/lib/nuntius/data/*.jsonl)]:::accent2
|
||||||
|
|
||||||
Browser --> Internet
|
Browser --> Internet
|
||||||
Internet --> Nginx
|
Internet --> Caddy
|
||||||
Nginx -->|proxy_pass /api/nuntius/| Nuntius
|
Caddy -->|reverse_proxy /api/nuntius/| Nuntius
|
||||||
Systemd -.->|manages| Nuntius
|
Systemd -.->|manages| Nuntius
|
||||||
Nuntius -->|SMTP PLAIN| SMTP
|
Nuntius -->|SMTP PLAIN| SMTP
|
||||||
Nuntius -->|append JSONL| Disk
|
Nuntius -->|append JSONL| Disk
|
||||||
```
|
```
|
||||||
|
|
||||||
nuntius listens on `127.0.0.1:8080` (default). nginx terminates TLS on `:443` and proxies `/api/nuntius/*` to the upstream. systemd restarts the process on failure.
|
nuntius binds `[::]:8080` by default — the dual-stack IPv6 wildcard that accepts both IPv4 and IPv6 connections on Linux and FreeBSD. Caddy should be configured to proxy to `[::1]:8080` or `127.0.0.1:8080` for loopback-only access.
|
||||||
|
|
||||||
## 1. Build and Upload (Local Machine)
|
## 1. Build and Upload (Local Machine)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ rsync -avz \
|
|||||||
user@your-server:/tmp/nuntius/
|
user@your-server:/tmp/nuntius/
|
||||||
```
|
```
|
||||||
|
|
||||||
> `install.sh` lives in the repository root. The systemd unit is included inline in Option A below (you do not need to rsync it from the repo). `.env.example` is a template for the secrets file. Adjust the `rsync` source list to match what you keep checked in.
|
> `install.py` lives in the repository root. The systemd unit is included inline in Option A below (you do not need to rsync it from the repo). `.env.example` is a template for the secrets file. Adjust the `rsync` source list to match what you keep checked in.
|
||||||
|
|
||||||
## 2. Install on the Server (SSH Session)
|
## 2. Install on the Server (SSH Session)
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ sudo journalctl -u nuntius -f
|
|||||||
|
|
||||||
The service file expects the binary at `/usr/local/bin/nuntius`, the env file at `/etc/nuntius/.env`, and the data dir at `/var/lib/nuntius` (the config's `data_dir: "./data"` resolves to `/var/lib/nuntius/data` because the unit sets `WorkingDirectory=/var/lib/nuntius`). If you'd rather use `/opt/nuntius/...` or any other layout, edit the systemd unit block above before installing.
|
The service file expects the binary at `/usr/local/bin/nuntius`, the env file at `/etc/nuntius/.env`, and the data dir at `/var/lib/nuntius` (the config's `data_dir: "./data"` resolves to `/var/lib/nuntius/data` because the unit sets `WorkingDirectory=/var/lib/nuntius`). If you'd rather use `/opt/nuntius/...` or any other layout, edit the systemd unit block above before installing.
|
||||||
|
|
||||||
### Option B — `install.sh` (idempotent)
|
### Option B — `install.py` (idempotent)
|
||||||
|
|
||||||
Before running the script, copy the systemd unit from the block in Option A above into `/tmp/nuntius/nuntius.service` (the script reads it from the current working directory):
|
Before running the script, copy the systemd unit from the block in Option A above into `/tmp/nuntius/nuntius.service` (the script reads it from the current working directory):
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ ssh user@your-server
|
|||||||
cd /tmp/nuntius
|
cd /tmp/nuntius
|
||||||
# Paste the [Unit]…[Install] block from Option A into this file:
|
# Paste the [Unit]…[Install] block from Option A into this file:
|
||||||
sudo $EDITOR /tmp/nuntius/nuntius.service
|
sudo $EDITOR /tmp/nuntius/nuntius.service
|
||||||
sudo bash install.sh
|
sudo python3 install.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The script is idempotent: re-running on an already-installed host is safe. It:
|
The script is idempotent: re-running on an already-installed host is safe. It:
|
||||||
@@ -121,35 +121,31 @@ The script is idempotent: re-running on an already-installed host is safe. It:
|
|||||||
|
|
||||||
The script ends with the exact next steps you need to run by hand.
|
The script ends with the exact next steps you need to run by hand.
|
||||||
|
|
||||||
## 3. nginx Reverse Proxy
|
## 3. Caddy Reverse Proxy
|
||||||
|
|
||||||
Add this location block to the same nginx server that already serves your site (most likely `/etc/nginx/sites-available/petrbalvin.org`):
|
Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`):
|
||||||
|
|
||||||
```nginx
|
```caddyfile
|
||||||
location /api/nuntius/ {
|
handle_path /api/nuntius/* {
|
||||||
proxy_pass http://127.0.0.1:8080/api/nuntius/;
|
reverse_proxy 127.0.0.1:8080
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nginx -t
|
sudo caddy validate
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `X-Forwarded-For`, the rate limiter will see every request as coming from `127.0.0.1` and effectively become useless once the form is live. The `Host` header also matters for vhost-aware upstreams; the default is fine for a single nuntius instance.
|
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
|
||||||
|
|
||||||
## 4. Wire It Up to Your Web
|
## 4. Wire It Up to Your Web
|
||||||
|
|
||||||
If you proxy `/api/nuntius/*` through nginx (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL:
|
If you proxy `/api/nuntius/*` through Caddy (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env (in the web project, only needed if NOT proxying through nginx)
|
# .env (in the web project, only needed if NOT proxying through Caddy)
|
||||||
VITE_NUNTIUS_URL=https://your-server:8080
|
VITE_NUNTIUS_URL=https://your-server:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,10 +223,10 @@ curl -s http://127.0.0.1:8080/health | jq .
|
|||||||
# Recent log lines
|
# Recent log lines
|
||||||
journalctl -u nuntius -n 50 --no-pager
|
journalctl -u nuntius -n 50 --no-pager
|
||||||
|
|
||||||
# nginx proxy works
|
# Caddy proxy works
|
||||||
curl -i https://your-domain.example/api/nuntius/health
|
curl -i https://your-domain.example/api/nuntius/health
|
||||||
|
|
||||||
# End-to-end POST through nginx
|
# End-to-end POST through Caddy
|
||||||
curl -X POST https://your-domain.example/api/nuntius/contact \
|
curl -X POST https://your-domain.example/api/nuntius/contact \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Origin: https://your-domain.example" \
|
-H "Origin: https://your-domain.example" \
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get codeberg.org/petrbalvin/nuntius/pkg/contactform
|
go get sourcedock.dev/petrbalvin/nuntius/pkg/contactform
|
||||||
```
|
```
|
||||||
|
|
||||||
Because nuntius ships with no `go.sum` entries from third-party modules, this import pulls in only stdlib transitively.
|
Because nuntius ships with no `go.sum` entries from third-party modules, this import pulls in only stdlib transitively.
|
||||||
@@ -118,7 +118,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -175,7 +175,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func contactHandler(w http.ResponseWriter, r *http.Request) {
|
func contactHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+7
-5
@@ -9,7 +9,7 @@ nuntius is designed to defend against:
|
|||||||
| Threat | Mitigation |
|
| Threat | Mitigation |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Naive spam bots filling the form | Per-form honeypot field that silently accepts and drops |
|
| Naive spam bots filling the form | Per-form honeypot field that silently accepts and drops |
|
||||||
| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory) |
|
| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory, with periodic cleanup goroutine) |
|
||||||
| Cross-origin abuse from a third-party site | Per-form CORS allowlist enforced on preflight and actual requests |
|
| Cross-origin abuse from a third-party site | Per-form CORS allowlist enforced on preflight and actual requests |
|
||||||
| Malformed bodies | Strict JSON parsing and per-type field validation |
|
| Malformed bodies | Strict JSON parsing and per-type field validation |
|
||||||
| SMTP credential leakage in logs | `log/slog` never logs the `From` / `Authorization` / password; the password only lives in `FormSender` |
|
| SMTP credential leakage in logs | `log/slog` never logs the `From` / `Authorization` / password; the password only lives in `FormSender` |
|
||||||
@@ -81,12 +81,14 @@ The IP used is taken from:
|
|||||||
2. `X-Real-IP`
|
2. `X-Real-IP`
|
||||||
3. `RemoteAddr` (with port stripped)
|
3. `RemoteAddr` (with port stripped)
|
||||||
|
|
||||||
Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` — otherwise every request looks like it came from `127.0.0.1` and the bucket becomes a single global bucket.
|
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
|
||||||
|
|
||||||
To disable rate limiting on a specific form, set `rate_limit_per_hour: 0`. There is no global safety net.
|
To disable rate limiting on a specific form, set `rate_limit_per_hour: 0`. There is no global safety net.
|
||||||
|
|
||||||
State is per-process and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is `replicas × per_form_limit`.
|
State is per-process and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is `replicas × per_form_limit`.
|
||||||
|
|
||||||
|
A background goroutine (`startCleanup`) ticks once per hour and removes IP entries that are older than twice the refill window. This prevents the in-memory bucket map from growing unboundedly as one-off IPs accumulate over weeks of uptime.
|
||||||
|
|
||||||
## Secret Handling
|
## Secret Handling
|
||||||
|
|
||||||
SMTP credentials live in `/etc/nuntius/.env` (mode `0600`, owner `root:nuntius`). The config file references them as `${NUNTIUS_SMTP_PASSWORD}`; `os.Expand` substitutes them **before** the TOML is parsed, so the parsed `config.Form` holds the expanded value in memory only for the lifetime of the process.
|
SMTP credentials live in `/etc/nuntius/.env` (mode `0600`, owner `root:nuntius`). The config file references them as `${NUNTIUS_SMTP_PASSWORD}`; `os.Expand` substitutes them **before** the TOML is parsed, so the parsed `config.Form` holds the expanded value in memory only for the lifetime of the process.
|
||||||
@@ -100,7 +102,7 @@ Things nuntius does **not** do:
|
|||||||
|
|
||||||
Things you should do:
|
Things you should do:
|
||||||
|
|
||||||
- Make sure `/etc/nuntius/.env` is `0600` and owned `root:nuntius` (the `install.sh` script does this for you).
|
- Make sure `/etc/nuntius/.env` is `0600` and owned `root:nuntius` (the `install.py` script does this for you).
|
||||||
- Use an app-specific password or OAuth token if your provider supports it. nuntius uses `PLAIN` auth because that is what every SMTP provider offers; the credential is the secret, not the auth mechanism.
|
- Use an app-specific password or OAuth token if your provider supports it. nuntius uses `PLAIN` auth because that is what every SMTP provider offers; the credential is the secret, not the auth mechanism.
|
||||||
- Rotate the password on the same cadence as any other production secret.
|
- Rotate the password on the same cadence as any other production secret.
|
||||||
- Restrict read access to `journalctl -u nuntius` if you do not want the client IPs in your local log files.
|
- Restrict read access to `journalctl -u nuntius` if you do not want the client IPs in your local log files.
|
||||||
@@ -133,10 +135,10 @@ The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`)
|
|||||||
- **No CAPTCHA / proof-of-work.** If you need a stronger abuse signal, add a reverse proxy with a CAPTCHA in front of nuntius; the same CORS rules apply.
|
- **No CAPTCHA / proof-of-work.** If you need a stronger abuse signal, add a reverse proxy with a CAPTCHA in front of nuntius; the same CORS rules apply.
|
||||||
- **No request signing / HMAC.** A frontend that has been compromised can post at the configured rate from a single IP. The token bucket handles the volume; the origin allowlist limits the surface.
|
- **No request signing / HMAC.** A frontend that has been compromised can post at the configured rate from a single IP. The token bucket handles the volume; the origin allowlist limits the surface.
|
||||||
- **No per-IP persistence across restarts.** Restart resets the buckets. That is an acceptable trade-off for a small backend and is documented in the rate-limit section above.
|
- **No per-IP persistence across restarts.** Restart resets the buckets. That is an acceptable trade-off for a small backend and is documented in the rate-limit section above.
|
||||||
- **No TLS for the nuntius listener itself.** nuntius is meant to run behind nginx (or another reverse proxy) that terminates TLS. Exposing the binary directly on `:8080` is a development convenience, not a production pattern.
|
- **No TLS for the nuntius listener itself.** nuntius binds `[::]:8080` (the dual-stack IPv6 wildcard) and is meant to run behind Caddy (or another reverse proxy) that terminates TLS. Exposing the binary directly is a development convenience, not a production pattern.
|
||||||
- **No DKIM signing.** Outbound mail is signed by your SMTP provider, not by nuntius.
|
- **No DKIM signing.** Outbound mail is signed by your SMTP provider, not by nuntius.
|
||||||
- **No TLS for the SMTP connection itself is configured by nuntius.** Go's `net/smtp.SendMail` opportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on `:465`, wrap `SendMail` in a custom dialer.
|
- **No TLS for the SMTP connection itself is configured by nuntius.** Go's `net/smtp.SendMail` opportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on `:465`, wrap `SendMail` in a custom dialer.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Email **petrbalvin@yandex.com** with a description and a reproducer. Please do not file a public issue for security-sensitive reports.
|
Email **opensource@petrbalvin.org** with a description and a reproducer. Please do not file a public issue for security-sensitive reports.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Minimal example showing how to use the contactform package
|
// Minimal example showing how to use the contactform package
|
||||||
@@ -8,7 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
module codeberg.org/petrbalvin/nuntius
|
module sourcedock.dev/petrbalvin/nuntius
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require codeberg.org/petrbalvin/interpres v1.0.0
|
require sourcedock.dev/petrbalvin/interpres v1.1.1
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
codeberg.org/petrbalvin/interpres v1.0.0 h1:bnY6zSekKklss+w/95O/0GCO1col05pV4HE8JsVXQN4=
|
sourcedock.dev/petrbalvin/interpres v1.1.1 h1:W0HYj8kHikuy1BZePPL4RaSEuv3TNX7YpdP1vvpkP54=
|
||||||
codeberg.org/petrbalvin/interpres v1.0.0/go.mod h1:sWgi5ijKNNLu5sCtjix9jG/guhdnDRlROENrFhrIt90=
|
sourcedock.dev/petrbalvin/interpres v1.1.1/go.mod h1:kyJWrbwVykxCiW250V6zZO2QUKNlAeFn+pwqzr+Hn2U=
|
||||||
|
|||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# install.py — install nuntius as a systemd service.
|
||||||
|
#
|
||||||
|
# nuntius ships as a single static binary. Build it on your workstation, upload
|
||||||
|
# the binary, the systemd unit, and the .env template to the server, then run
|
||||||
|
# this script there as root from that directory:
|
||||||
|
#
|
||||||
|
# scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/
|
||||||
|
# ssh user@host
|
||||||
|
# cd /tmp/nuntius && sudo python3 install.py
|
||||||
|
#
|
||||||
|
# It expects ./nuntius and ./nuntius.service (and optionally ./.env.example) in
|
||||||
|
# the current working directory. Idempotent: re-running is safe — it skips the
|
||||||
|
# user, config, and .env when they already exist, and never starts the service.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
NUNTIUS_USER = "nuntius"
|
||||||
|
BIN_DEST = "/usr/local/bin/nuntius"
|
||||||
|
CONFIG_DIR = "/etc/nuntius"
|
||||||
|
CONFIG_FILE = "/etc/nuntius/config.toml"
|
||||||
|
ENV_FILE = "/etc/nuntius/.env"
|
||||||
|
DATA_DIR = "/var/lib/nuntius"
|
||||||
|
SERVICE_DEST = "/etc/systemd/system/nuntius.service"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Coloured logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _ColourFormatter(logging.Formatter):
|
||||||
|
"""Prefix each log line with a coloured level marker, mimicking the bash script."""
|
||||||
|
|
||||||
|
_COLOURS: dict[int, str] = {
|
||||||
|
logging.INFO: "\033[1;34m", # blue
|
||||||
|
logging.WARNING: "\033[1;33m", # yellow
|
||||||
|
logging.ERROR: "\033[1;31m", # red
|
||||||
|
}
|
||||||
|
_LABELS: dict[int, str] = {
|
||||||
|
logging.INFO: "==>",
|
||||||
|
logging.WARNING: "WARN:",
|
||||||
|
logging.ERROR: "ERROR:",
|
||||||
|
}
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
colour = self._COLOURS.get(record.levelno, "")
|
||||||
|
label = self._LABELS.get(record.levelno, "")
|
||||||
|
if colour and label:
|
||||||
|
return f"{colour}{label}{self._RESET} {record.getMessage()}"
|
||||||
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging() -> None:
|
||||||
|
"""Configure the root logger with coloured console output."""
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(_ColourFormatter())
|
||||||
|
# Remove any handlers added by basicConfig or other imports.
|
||||||
|
logger.handlers.clear()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a command via subprocess and check for failure (unless told otherwise)."""
|
||||||
|
return subprocess.run(cmd, check=True, text=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Install steps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def require_root() -> None:
|
||||||
|
"""Exit if not running as root."""
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
logging.error("Run as root: sudo python3 install.py")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def require_files() -> None:
|
||||||
|
"""Verify that the binary and the systemd unit are present in CWD."""
|
||||||
|
if not os.path.isfile("./nuntius"):
|
||||||
|
logging.error(
|
||||||
|
"./nuntius binary not found — copy it here first (e.g. rsync bin/nuntius)."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
if not os.path.isfile("./nuntius.service"):
|
||||||
|
logging.error(
|
||||||
|
"./nuntius.service not found — paste the unit from docs/deployment.md (Option A)."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def create_user() -> None:
|
||||||
|
"""Create the system user if it does not already exist."""
|
||||||
|
try:
|
||||||
|
_run(["id", NUNTIUS_USER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
logging.info("User %s already exists.", NUNTIUS_USER)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
logging.info("Creating system user %s…", NUNTIUS_USER)
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"useradd",
|
||||||
|
"--system",
|
||||||
|
"--shell",
|
||||||
|
"/usr/sbin/nologin",
|
||||||
|
"--home-dir",
|
||||||
|
DATA_DIR,
|
||||||
|
"--user-group",
|
||||||
|
NUNTIUS_USER,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_data_dir() -> None:
|
||||||
|
"""Ensure the data directory exists with correct ownership and permissions."""
|
||||||
|
logging.info("Setting up %s…", DATA_DIR)
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
shutil.chown(DATA_DIR, user=NUNTIUS_USER, group=NUNTIUS_USER)
|
||||||
|
os.chmod(DATA_DIR, 0o750)
|
||||||
|
|
||||||
|
|
||||||
|
def install_binary() -> None:
|
||||||
|
"""Copy the nuntius binary to /usr/local/bin with mode 0755."""
|
||||||
|
logging.info("Installing binary to %s…", BIN_DEST)
|
||||||
|
shutil.copy2("./nuntius", BIN_DEST)
|
||||||
|
os.chmod(BIN_DEST, 0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def install_service() -> None:
|
||||||
|
"""Copy the systemd unit file with mode 0644."""
|
||||||
|
logging.info("Installing systemd unit %s…", SERVICE_DEST)
|
||||||
|
shutil.copy2("./nuntius.service", SERVICE_DEST)
|
||||||
|
os.chmod(SERVICE_DEST, 0o644)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_config() -> None:
|
||||||
|
"""Auto-generate the TOML config by running nuntius briefly if config is absent."""
|
||||||
|
if os.path.isfile(CONFIG_FILE):
|
||||||
|
logging.info("Config %s already exists; leaving it untouched.", CONFIG_FILE)
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info("Generating %s…", CONFIG_FILE)
|
||||||
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# nuntius writes the template config on first start; run it briefly, then
|
||||||
|
# let `timeout` send SIGTERM (graceful shutdown). The write happens at
|
||||||
|
# startup, before the timeout elapses.
|
||||||
|
try:
|
||||||
|
_run(["timeout", "-k", "5s", "4s", BIN_DEST], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# timeout exits 124 on SIGTERM, 137 on SIGKILL — both are expected.
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not os.path.isfile(CONFIG_FILE):
|
||||||
|
logging.warning("nuntius did not create %s; check the uploaded binary.", CONFIG_FILE)
|
||||||
|
|
||||||
|
# Fix ownership in case nuntius wrote files as root.
|
||||||
|
_run(["chown", "-R", f"{NUNTIUS_USER}:{NUNTIUS_USER}", DATA_DIR])
|
||||||
|
|
||||||
|
|
||||||
|
def install_env() -> None:
|
||||||
|
"""Copy .env.example to /etc/nuntius/.env if it does not already exist."""
|
||||||
|
if os.path.isfile(ENV_FILE):
|
||||||
|
logging.info("%s already exists; leaving it untouched.", ENV_FILE)
|
||||||
|
elif os.path.isfile("./.env.example"):
|
||||||
|
logging.info("Creating %s from .env.example (edit it!)…", ENV_FILE)
|
||||||
|
shutil.copy2("./.env.example", ENV_FILE)
|
||||||
|
os.chmod(ENV_FILE, 0o600)
|
||||||
|
_run(["chown", f"root:{NUNTIUS_USER}", ENV_FILE])
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
".env.example not found; skipping %s. Create it before starting.", ENV_FILE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enable_service() -> None:
|
||||||
|
"""Reload systemd and enable the nuntius service (does not start it)."""
|
||||||
|
logging.info("Reloading systemd and enabling nuntius…")
|
||||||
|
_run(["systemctl", "daemon-reload"])
|
||||||
|
_run(["systemctl", "enable", "nuntius.service"])
|
||||||
|
|
||||||
|
|
||||||
|
def final_notes() -> None:
|
||||||
|
"""Print post-install instructions."""
|
||||||
|
print(
|
||||||
|
f"""
|
||||||
|
============================================================
|
||||||
|
✓ nuntius installed and enabled, but NOT started.
|
||||||
|
|
||||||
|
Next manual steps:
|
||||||
|
1. Set the SMTP password:
|
||||||
|
sudo $EDITOR {ENV_FILE}
|
||||||
|
2. Edit the auto-generated config (smtp.host, smtp.user, allowed_origins):
|
||||||
|
sudo $EDITOR {CONFIG_FILE}
|
||||||
|
3. Start the service:
|
||||||
|
sudo systemctl start nuntius
|
||||||
|
sudo journalctl -u nuntius -f
|
||||||
|
4. Add the Caddy reverse_proxy directive (see README.md), then:
|
||||||
|
sudo caddy validate && sudo systemctl reload caddy
|
||||||
|
============================================================
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="install.py",
|
||||||
|
description="install nuntius as a systemd service.",
|
||||||
|
epilog=(
|
||||||
|
"Idempotent: re-running is safe. The service is enabled but never started."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> NoReturn:
|
||||||
|
"""Parse args and run all install steps in order."""
|
||||||
|
_setup_logging()
|
||||||
|
parser = _build_parser()
|
||||||
|
parser.parse_args(argv)
|
||||||
|
|
||||||
|
require_root()
|
||||||
|
require_files()
|
||||||
|
create_user()
|
||||||
|
create_data_dir()
|
||||||
|
install_binary()
|
||||||
|
install_service()
|
||||||
|
generate_config()
|
||||||
|
install_env()
|
||||||
|
enable_service()
|
||||||
|
final_notes()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
-153
@@ -1,153 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# install.sh — install nuntius as a systemd service.
|
|
||||||
#
|
|
||||||
# nuntius ships as a single static binary. Build it on your workstation, upload
|
|
||||||
# the binary, the systemd unit, and the .env template to the server, then run
|
|
||||||
# this script there as root from that directory:
|
|
||||||
#
|
|
||||||
# scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/
|
|
||||||
# ssh user@host
|
|
||||||
# cd /tmp/nuntius && sudo bash install.sh
|
|
||||||
#
|
|
||||||
# It expects ./nuntius and ./nuntius.service (and optionally ./.env.example) in
|
|
||||||
# the current working directory. Idempotent: re-running is safe — it skips the
|
|
||||||
# user, config, and .env when they already exist, and never starts the service.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
NUNTIUS_USER="nuntius"
|
|
||||||
BIN_DEST="/usr/local/bin/nuntius"
|
|
||||||
CONFIG_DIR="/etc/nuntius"
|
|
||||||
CONFIG_FILE="${CONFIG_DIR}/config.toml"
|
|
||||||
ENV_FILE="${CONFIG_DIR}/.env"
|
|
||||||
DATA_DIR="/var/lib/nuntius"
|
|
||||||
SERVICE_DEST="/etc/systemd/system/nuntius.service"
|
|
||||||
|
|
||||||
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
||||||
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
|
|
||||||
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
install.sh — install nuntius as a systemd service.
|
|
||||||
|
|
||||||
Upload the binary, the systemd unit, and the .env template to the server, then
|
|
||||||
run this script there as root from that directory:
|
|
||||||
|
|
||||||
scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/
|
|
||||||
ssh user@host
|
|
||||||
cd /tmp/nuntius && sudo bash install.sh
|
|
||||||
|
|
||||||
Idempotent: re-running is safe. The service is enabled but never started.
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
require_root() {
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "Run as root: sudo bash install.sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
require_files() {
|
|
||||||
[ -f ./nuntius ] || die "./nuntius binary not found — copy it here first (e.g. rsync bin/nuntius)."
|
|
||||||
[ -f ./nuntius.service ] || \
|
|
||||||
die "./nuntius.service not found — paste the unit from docs/deployment.md (Option A)."
|
|
||||||
}
|
|
||||||
|
|
||||||
create_user() {
|
|
||||||
if id "$NUNTIUS_USER" >/dev/null 2>&1; then
|
|
||||||
log "User ${NUNTIUS_USER} already exists."
|
|
||||||
else
|
|
||||||
log "Creating system user ${NUNTIUS_USER}…"
|
|
||||||
useradd --system --shell /usr/sbin/nologin --home-dir "$DATA_DIR" --user-group "$NUNTIUS_USER"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
create_data_dir() {
|
|
||||||
log "Setting up ${DATA_DIR}…"
|
|
||||||
mkdir -p "$DATA_DIR"
|
|
||||||
chown "$NUNTIUS_USER:$NUNTIUS_USER" "$DATA_DIR"
|
|
||||||
chmod 750 "$DATA_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_binary() {
|
|
||||||
log "Installing binary to ${BIN_DEST}…"
|
|
||||||
install -m 0755 ./nuntius "$BIN_DEST"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_service() {
|
|
||||||
log "Installing systemd unit ${SERVICE_DEST}…"
|
|
||||||
install -m 0644 ./nuntius.service "$SERVICE_DEST"
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_config() {
|
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
|
||||||
log "Config ${CONFIG_FILE} already exists; leaving it untouched."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
log "Generating ${CONFIG_FILE}…"
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
# nuntius writes the template config on first start; run it briefly, then
|
|
||||||
# let `timeout` send SIGTERM (graceful shutdown). No backgrounding or manual
|
|
||||||
# kill — the write happens at startup, before the timeout elapses.
|
|
||||||
timeout -k 5s 4s "$BIN_DEST" >/dev/null 2>&1 || true
|
|
||||||
[ -f "$CONFIG_FILE" ] || warn "nuntius did not create ${CONFIG_FILE}; check the uploaded binary."
|
|
||||||
chown -R "$NUNTIUS_USER:$NUNTIUS_USER" "$DATA_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_env() {
|
|
||||||
if [ -f "$ENV_FILE" ]; then
|
|
||||||
log "${ENV_FILE} already exists; leaving it untouched."
|
|
||||||
elif [ -f ./.env.example ]; then
|
|
||||||
log "Creating ${ENV_FILE} from .env.example (edit it!)…"
|
|
||||||
install -m 0600 ./.env.example "$ENV_FILE"
|
|
||||||
chown "root:$NUNTIUS_USER" "$ENV_FILE"
|
|
||||||
else
|
|
||||||
warn ".env.example not found; skipping ${ENV_FILE}. Create it before starting."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
enable_service() {
|
|
||||||
log "Reloading systemd and enabling nuntius…"
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable nuntius.service
|
|
||||||
}
|
|
||||||
|
|
||||||
final_notes() {
|
|
||||||
cat <<'EOF'
|
|
||||||
|
|
||||||
============================================================
|
|
||||||
✓ nuntius installed and enabled, but NOT started.
|
|
||||||
|
|
||||||
Next manual steps:
|
|
||||||
1. Set the SMTP password:
|
|
||||||
sudo $EDITOR /etc/nuntius/.env
|
|
||||||
2. Edit the auto-generated config (smtp.host, smtp.user, allowed_origins):
|
|
||||||
sudo $EDITOR /etc/nuntius/config.toml
|
|
||||||
3. Start the service:
|
|
||||||
sudo systemctl start nuntius
|
|
||||||
sudo journalctl -u nuntius -f
|
|
||||||
4. Add the nginx location block (see README.md), then:
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
============================================================
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
case "${1:-}" in
|
|
||||||
-h | --help) usage ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
require_root
|
|
||||||
require_files
|
|
||||||
create_user
|
|
||||||
create_data_dir
|
|
||||||
install_binary
|
|
||||||
install_service
|
|
||||||
generate_config
|
|
||||||
install_env
|
|
||||||
enable_service
|
|
||||||
final_notes
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Package config loads nuntius configuration from a TOML file.
|
// Package config loads nuntius configuration from a TOML file.
|
||||||
@@ -16,7 +19,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidFormTypes lists the form types nuntius knows how to handle.
|
// ValidFormTypes lists the form types nuntius knows how to handle.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
package config
|
package config
|
||||||
@@ -92,3 +95,16 @@ to = "you@example.com"
|
|||||||
t.Fatal("expected an error for an unknown field")
|
t.Fatal("expected an error for an unknown field")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
if got := ConfigPath(); got != "/etc/nuntius/config.toml" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want %q", got, "/etc/nuntius/config.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Package email handles SMTP message composition and delivery.
|
// Package email handles SMTP message composition and delivery.
|
||||||
@@ -10,8 +13,8 @@ import (
|
|||||||
"net/smtp"
|
"net/smtp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/config"
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormSender delivers contact form submissions for a single form
|
// FormSender delivers contact form submissions for a single form
|
||||||
@@ -198,14 +201,19 @@ func compose(from, to string, req contactform.Request, formName, formType string
|
|||||||
case "generic":
|
case "generic":
|
||||||
tmpl = emailTemplateGeneric
|
tmpl = emailTemplateGeneric
|
||||||
}
|
}
|
||||||
_ = tmpl.Execute(&htmlBuf, map[string]string{
|
if err := tmpl.Execute(&htmlBuf, map[string]string{
|
||||||
"FormName": formName,
|
"FormName": formName,
|
||||||
"Name": req.Name,
|
"Name": req.Name,
|
||||||
"Email": req.Email,
|
"Email": req.Email,
|
||||||
"Service": req.Service,
|
"Service": req.Service,
|
||||||
"Message": req.Message,
|
"Message": req.Message,
|
||||||
"Received": received,
|
"Received": received,
|
||||||
})
|
}); err != nil {
|
||||||
|
// template.Must guarantees valid templates; this path is
|
||||||
|
// unreachable in normal operation.
|
||||||
|
htmlBuf.Reset()
|
||||||
|
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Subject + plain-text body per form type ---
|
// --- Subject + plain-text body per form type ---
|
||||||
var subject, text string
|
var subject, text string
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComposeContactWithoutService(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Alice",
|
||||||
|
Email: "alice@example.com",
|
||||||
|
Message: "I have a question about your services.",
|
||||||
|
}
|
||||||
|
b := compose("from@example.com", "to@example.com", req, "MyForm", "contact")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") {
|
||||||
|
t.Errorf("expected subject with form name only, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "From: from@example.com") {
|
||||||
|
t.Error("expected From header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "To: to@example.com") {
|
||||||
|
t.Error("expected To header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Reply-To: alice@example.com") {
|
||||||
|
t.Error("expected Reply-To header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||||
|
t.Error("expected MIME-Version header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") {
|
||||||
|
t.Error("expected text/plain part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") {
|
||||||
|
t.Error("expected text/html part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: multipart/alternative") {
|
||||||
|
t.Error("expected multipart/alternative content type")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "I have a question about your services.") {
|
||||||
|
t.Error("expected message content in body")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Contact Form Submission:") {
|
||||||
|
t.Error("expected contact form plain-text header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeContactWithService(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Bob",
|
||||||
|
Email: "bob@example.com",
|
||||||
|
Service: "architecture",
|
||||||
|
Message: "I would like a consultation.",
|
||||||
|
}
|
||||||
|
b := compose("from@e.com", "to@e.com", req, "ContactForm", "contact")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") {
|
||||||
|
t.Errorf("expected subject with service tag, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Service interest: architecture") {
|
||||||
|
t.Error("expected service interest in plain text")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||||
|
t.Error("expected text/plain part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html") {
|
||||||
|
t.Error("expected text/html part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "bob@example.com") {
|
||||||
|
t.Error("expected submitter email in body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeFeedback(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Carol",
|
||||||
|
Email: "carol@example.com",
|
||||||
|
Message: "Great platform, but can you add dark mode?",
|
||||||
|
}
|
||||||
|
b := compose("sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") {
|
||||||
|
t.Errorf("expected feedback subject, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "New Feedback:") {
|
||||||
|
t.Error("expected feedback plain-text header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "From: Carol <carol@example.com>") {
|
||||||
|
t.Error("expected From line in plain text")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||||
|
t.Error("expected text/plain part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html") {
|
||||||
|
t.Error("expected text/html part")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeNewsletter(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Email: "subscriber@example.com",
|
||||||
|
}
|
||||||
|
b := compose("news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") {
|
||||||
|
t.Errorf("expected newsletter subject, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "New Newsletter Subscriber:") {
|
||||||
|
t.Error("expected newsletter plain-text header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Email: subscriber@example.com") {
|
||||||
|
t.Error("expected subscriber email in plain text")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||||
|
t.Error("expected text/plain part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html") {
|
||||||
|
t.Error("expected text/html part")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeGeneric(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Dave",
|
||||||
|
Email: "dave@example.com",
|
||||||
|
Message: "Generic inquiry.",
|
||||||
|
}
|
||||||
|
b := compose("g@h.com", "g@h.com", req, "GenericForm", "generic")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") {
|
||||||
|
t.Errorf("expected generic subject, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "New Submission:") {
|
||||||
|
t.Error("expected generic plain-text header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||||
|
t.Error("expected text/plain part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html") {
|
||||||
|
t.Error("expected text/html part")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeEmptyFormNameAndEmail(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "",
|
||||||
|
Email: "",
|
||||||
|
Message: "",
|
||||||
|
}
|
||||||
|
b := compose("", "", req, "", "generic")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/] New submission") {
|
||||||
|
t.Errorf("expected subject with empty form name, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||||
|
t.Error("expected MIME-Version header even with empty fields")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||||
|
t.Error("expected text/plain part even with empty fields")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html") {
|
||||||
|
t.Error("expected text/html part even with empty fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeLongMessage(t *testing.T) {
|
||||||
|
longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200)
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Eve",
|
||||||
|
Email: "eve@example.com",
|
||||||
|
Message: longMsg,
|
||||||
|
}
|
||||||
|
b := compose("x@y.com", "z@y.com", req, "LongForm", "feedback")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, longMsg) {
|
||||||
|
t.Error("expected long message content in body")
|
||||||
|
}
|
||||||
|
// Verify it's in both text and html parts by checking after the respective
|
||||||
|
// content-type boundaries.
|
||||||
|
textIdx := strings.Index(s, "Content-Type: text/plain")
|
||||||
|
htmlIdx := strings.Index(s, "Content-Type: text/html")
|
||||||
|
if textIdx == -1 || htmlIdx == -1 {
|
||||||
|
t.Fatal("expected both text/plain and text/html parts")
|
||||||
|
}
|
||||||
|
textPart := s[textIdx:htmlIdx]
|
||||||
|
htmlPart := s[htmlIdx:]
|
||||||
|
if !strings.Contains(textPart, longMsg) {
|
||||||
|
t.Error("expected long message in text/plain part")
|
||||||
|
}
|
||||||
|
if !strings.Contains(htmlPart, longMsg) {
|
||||||
|
t.Error("expected long message in text/html part")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeSpecialCharacters(t *testing.T) {
|
||||||
|
specialMsg := "Café résumé — déjà vu\nLine\twith\ttabs\n€uro sign © 2026"
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Renée",
|
||||||
|
Email: "renée@example.com",
|
||||||
|
Message: specialMsg,
|
||||||
|
}
|
||||||
|
b := compose("ñ@c.com", "ö@c.com", req, "SpaForm", "contact")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Café résumé — déjà vu") {
|
||||||
|
t.Error("expected accented characters to survive round-trip")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "€uro sign © 2026") {
|
||||||
|
t.Error("expected special symbols to survive round-trip")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Renée") {
|
||||||
|
t.Error("expected accented name in body")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||||
|
t.Error("expected MIME-Version header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) {
|
||||||
|
// Unknown form type should default to the contact template.
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Fallback",
|
||||||
|
Email: "fallback@example.com",
|
||||||
|
Message: "Does this work?",
|
||||||
|
}
|
||||||
|
b := compose("a@b.com", "c@b.com", req, "UnknownForm", "nonexistent")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") {
|
||||||
|
t.Errorf("expected contact fallback subject, got body:\n%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||||
|
t.Error("expected text/plain part in fallback")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "Content-Type: text/html") {
|
||||||
|
t.Error("expected text/html part in fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeAllHeadersPresent(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Test",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Message: "Checking headers.",
|
||||||
|
}
|
||||||
|
b := compose("from@x.com", "to@x.com", req, "HeaderForm", "contact")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"From: from@x.com",
|
||||||
|
"To: to@x.com",
|
||||||
|
"Subject:",
|
||||||
|
"Date:",
|
||||||
|
"Reply-To: test@example.com",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: multipart/alternative",
|
||||||
|
}
|
||||||
|
for _, h := range required {
|
||||||
|
if !strings.Contains(s, h) {
|
||||||
|
t.Errorf("expected header %q in message", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeMultipartBoundary(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Boundary",
|
||||||
|
Email: "boundary@example.com",
|
||||||
|
Message: "Boundary test.",
|
||||||
|
}
|
||||||
|
b := compose("from@t.com", "to@t.com", req, "BoundForm", "contact")
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if !strings.Contains(s, "--nuntius-boundary") {
|
||||||
|
t.Error("expected nuntius-boundary separator")
|
||||||
|
}
|
||||||
|
// Should have opening boundary, middle boundary, closing boundary.
|
||||||
|
count := strings.Count(s, "--nuntius-boundary")
|
||||||
|
if count < 3 {
|
||||||
|
t.Errorf("expected at least 3 boundary markers, got %d", count)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "--nuntius-boundary--") {
|
||||||
|
t.Error("expected closing boundary marker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDeliveredByFooter(t *testing.T) {
|
||||||
|
req := contactform.Request{
|
||||||
|
Name: "Footer",
|
||||||
|
Email: "footer@example.com",
|
||||||
|
Message: "Footer check.",
|
||||||
|
}
|
||||||
|
for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} {
|
||||||
|
b := compose("f@t.com", "t@t.com", req, "FooterForm", ft)
|
||||||
|
s := string(b)
|
||||||
|
if !strings.Contains(s, "Delivered by nuntius") {
|
||||||
|
t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-16
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
package handler
|
package handler
|
||||||
@@ -11,29 +14,40 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/config"
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/email"
|
"sourcedock.dev/petrbalvin/nuntius/internal/email"
|
||||||
"codeberg.org/petrbalvin/nuntius/internal/storage"
|
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
|
||||||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// formSender is the interface for delivering a submission.
|
||||||
|
type formSender interface {
|
||||||
|
Send(req contactform.Request) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscriberStorer persists newsletter subscribers.
|
||||||
|
// storage.NewsletterStore satisfies this interface.
|
||||||
|
type subscriberStorer interface {
|
||||||
|
Append(sub storage.Subscriber) error
|
||||||
|
}
|
||||||
|
|
||||||
// ContactHandler serves one or more contact forms, dispatched by URL path.
|
// ContactHandler serves one or more contact forms, dispatched by URL path.
|
||||||
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
|
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
|
||||||
// Newsletter-type forms also get a per-form subscriber store.
|
// Newsletter-type forms also get a per-form subscriber store.
|
||||||
type ContactHandler struct {
|
type ContactHandler struct {
|
||||||
forms map[string]*config.Form
|
forms map[string]*config.Form
|
||||||
senders map[string]*email.FormSender
|
senders map[string]formSender
|
||||||
rateLimits map[string]*rateLimiter
|
rateLimits map[string]*rateLimiter
|
||||||
stores map[string]*storage.NewsletterStore
|
stores map[string]subscriberStorer
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a ContactHandler that serves all forms defined in cfg.
|
// New constructs a ContactHandler that serves all forms defined in cfg.
|
||||||
func New(cfg *config.Config) *ContactHandler {
|
func New(cfg *config.Config) *ContactHandler {
|
||||||
h := &ContactHandler{
|
h := &ContactHandler{
|
||||||
forms: make(map[string]*config.Form, len(cfg.Forms)),
|
forms: make(map[string]*config.Form, len(cfg.Forms)),
|
||||||
senders: make(map[string]*email.FormSender, len(cfg.Forms)),
|
senders: make(map[string]formSender, len(cfg.Forms)),
|
||||||
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
|
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
|
||||||
stores: make(map[string]*storage.NewsletterStore, len(cfg.Forms)),
|
stores: make(map[string]subscriberStorer, len(cfg.Forms)),
|
||||||
}
|
}
|
||||||
for i := range cfg.Forms {
|
for i := range cfg.Forms {
|
||||||
f := &cfg.Forms[i]
|
f := &cfg.Forms[i]
|
||||||
@@ -114,10 +128,12 @@ func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
|
|||||||
if errs := contactform.Validate(&req, form.Type); len(errs) > 0 {
|
if errs := contactform.Validate(&req, form.Type); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
if err := json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
||||||
Error: "validation",
|
Error: "validation",
|
||||||
Details: errs,
|
Details: errs,
|
||||||
})
|
}); err != nil {
|
||||||
|
slog.Error("failed to encode json response", "err", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,10 +171,12 @@ func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
|
|||||||
// Health handles GET /health.
|
// Health handles GET /health.
|
||||||
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
|
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"forms": len(h.forms),
|
"forms": len(h.forms),
|
||||||
})
|
}); err != nil {
|
||||||
|
slog.Error("failed to encode health response", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- internals ---
|
// --- internals ---
|
||||||
@@ -201,10 +219,36 @@ type bucket struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newRateLimiter(perHour int) *rateLimiter {
|
func newRateLimiter(perHour int) *rateLimiter {
|
||||||
return &rateLimiter{
|
r := &rateLimiter{
|
||||||
perHour: perHour,
|
perHour: perHour,
|
||||||
buckets: make(map[string]*bucket),
|
buckets: make(map[string]*bucket),
|
||||||
}
|
}
|
||||||
|
// Clean up entries older than twice the refill window every hour.
|
||||||
|
r.startCleanup(1*time.Hour, 2*time.Hour)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleanup launches a background goroutine that periodically removes
|
||||||
|
// expired bucket entries to prevent unbounded memory growth.
|
||||||
|
func (r *rateLimiter) startCleanup(interval, maxAge time.Duration) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
r.cleanup(maxAge)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) cleanup(maxAge time.Duration) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
for ip, b := range r.buckets {
|
||||||
|
if b.last.Before(cutoff) {
|
||||||
|
delete(r.buckets, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *rateLimiter) allow(ip string) bool {
|
func (r *rateLimiter) allow(ip string) bool {
|
||||||
@@ -239,16 +283,20 @@ func minF(a, b float64) float64 {
|
|||||||
|
|
||||||
func respondOK(w http.ResponseWriter) {
|
func respondOK(w http.ResponseWriter) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(contactform.Response{OK: true})
|
if err := json.NewEncoder(w).Encode(contactform.Response{OK: true}); err != nil {
|
||||||
|
slog.Error("failed to encode ok response", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func respondError(w http.ResponseWriter, code int, err, msg string) {
|
func respondError(w http.ResponseWriter, code int, err, msg string) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
if encErr := json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
||||||
Error: err,
|
Error: err,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
})
|
}); encErr != nil {
|
||||||
|
slog.Error("failed to encode error response", "err", encErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
|
|||||||
@@ -0,0 +1,804 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||||
|
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
|
||||||
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type mockSender struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sent []contactform.Request
|
||||||
|
sendErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSender) Send(req contactform.Request) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.sent = append(m.sent, req)
|
||||||
|
return m.sendErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSender) sentCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
appended []storage.Subscriber
|
||||||
|
appendErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStore) Append(sub storage.Subscriber) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.appended = append(m.appended, sub)
|
||||||
|
return m.appendErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// helper functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func testForm() *config.Form {
|
||||||
|
return &config.Form{
|
||||||
|
Name: "test",
|
||||||
|
Path: "/test",
|
||||||
|
Type: "contact",
|
||||||
|
AllowedOrigins: []string{"https://example.com"},
|
||||||
|
RateLimitPerHour: 100,
|
||||||
|
HoneypotField: "website",
|
||||||
|
From: "noreply@test.com",
|
||||||
|
To: "owner@test.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewsletterForm() *config.Form {
|
||||||
|
f := testForm()
|
||||||
|
f.Type = "newsletter"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandler(form *config.Form, sender formSender, store subscriberStorer) *ContactHandler {
|
||||||
|
h := &ContactHandler{
|
||||||
|
forms: map[string]*config.Form{form.Path: form},
|
||||||
|
senders: map[string]formSender{form.Path: sender},
|
||||||
|
rateLimits: map[string]*rateLimiter{form.Path: newRateLimiter(form.RateLimitPerHour)},
|
||||||
|
stores: map[string]subscriberStorer{},
|
||||||
|
}
|
||||||
|
if store != nil {
|
||||||
|
h.stores[form.Path] = store
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandlerMux(t *testing.T, form *config.Form, sender formSender, store subscriberStorer) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
h := newTestHandler(form, sender, store)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.Register(mux)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, handler http.Handler, path string, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSONWithOrigin(t *testing.T, handler http.Handler, path string, body string, origin string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(v); err != nil {
|
||||||
|
t.Fatalf("failed to decode body: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestFormAllowed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestFormAllowed(t *testing.T) {
|
||||||
|
form := &config.Form{
|
||||||
|
AllowedOrigins: []string{"https://a.com", "https://b.com"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
origin string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"empty origin allowed", "", true},
|
||||||
|
{"exact match allowed", "https://a.com", true},
|
||||||
|
{"second match allowed", "https://b.com", true},
|
||||||
|
{"no match disallowed", "https://evil.com", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := formAllowed(form, tt.origin)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formAllowed(%q) = %v, want %v", tt.origin, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestWriteCORS
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWriteCORS(t *testing.T) {
|
||||||
|
allowed := []string{"https://a.com"}
|
||||||
|
|
||||||
|
t.Run("allowed origin sets headers", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
writeCORS(w, "https://a.com", allowed)
|
||||||
|
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://a.com" {
|
||||||
|
t.Errorf("ACAO = %q, want %q", got, "https://a.com")
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Vary"); got != "Origin" {
|
||||||
|
t.Errorf("Vary = %q, want %q", got, "Origin")
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "POST, OPTIONS" {
|
||||||
|
t.Errorf("ACAM = %q, want POST, OPTIONS", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disallowed origin sets nothing", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
writeCORS(w, "https://evil.com", allowed)
|
||||||
|
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("ACAO = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
||||||
|
t.Errorf("ACAM = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestClientIP
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
xff string
|
||||||
|
xri string
|
||||||
|
remoteAddr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "X-Forwarded-For single IP",
|
||||||
|
xff: "1.2.3.4",
|
||||||
|
xri: "",
|
||||||
|
remoteAddr: "10.0.0.1:1234",
|
||||||
|
want: "1.2.3.4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Forwarded-For multiple IPs takes first",
|
||||||
|
xff: "1.2.3.4, 2.3.4.5, 3.4.5.6",
|
||||||
|
xri: "",
|
||||||
|
remoteAddr: "10.0.0.1:1234",
|
||||||
|
want: "1.2.3.4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Real-IP takes priority over RemoteAddr",
|
||||||
|
xff: "",
|
||||||
|
xri: "5.6.7.8",
|
||||||
|
remoteAddr: "10.0.0.1:1234",
|
||||||
|
want: "5.6.7.8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoteAddr with port",
|
||||||
|
xff: "",
|
||||||
|
xri: "",
|
||||||
|
remoteAddr: "9.10.11.12:8080",
|
||||||
|
want: "9.10.11.12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoteAddr without port",
|
||||||
|
xff: "",
|
||||||
|
xri: "",
|
||||||
|
remoteAddr: "9.10.11.12",
|
||||||
|
want: "9.10.11.12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 RemoteAddr with port",
|
||||||
|
xff: "",
|
||||||
|
xri: "",
|
||||||
|
remoteAddr: "[::1]:12345",
|
||||||
|
want: "[::1]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if tt.xff != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", tt.xff)
|
||||||
|
}
|
||||||
|
if tt.xri != "" {
|
||||||
|
req.Header.Set("X-Real-IP", tt.xri)
|
||||||
|
}
|
||||||
|
req.RemoteAddr = tt.remoteAddr
|
||||||
|
got := clientIP(req)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("clientIP() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRateLimiterAllow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRateLimiterAllow(t *testing.T) {
|
||||||
|
perHour := 3
|
||||||
|
rl := &rateLimiter{
|
||||||
|
perHour: perHour,
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := "10.0.0.1"
|
||||||
|
|
||||||
|
// First perHour requests should be allowed.
|
||||||
|
for i := 0; i < perHour; i++ {
|
||||||
|
if !rl.allow(ip) {
|
||||||
|
t.Fatalf("allow() call %d was blocked, want allowed", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next request should be blocked.
|
||||||
|
if rl.allow(ip) {
|
||||||
|
t.Fatal("allow() after exhausting tokens was allowed, want blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRateLimiterRefill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRateLimiterRefill(t *testing.T) {
|
||||||
|
// 3600 tokens per hour = 1 token per second.
|
||||||
|
perHour := 3600
|
||||||
|
rl := &rateLimiter{
|
||||||
|
perHour: perHour,
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := "10.0.0.1"
|
||||||
|
|
||||||
|
// Exhaust all tokens.
|
||||||
|
allowed := 0
|
||||||
|
for rl.allow(ip) {
|
||||||
|
allowed++
|
||||||
|
}
|
||||||
|
if allowed != perHour {
|
||||||
|
t.Fatalf("exhausted %d tokens, want %d", allowed, perHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately another request should be blocked (rate = 1 token/sec).
|
||||||
|
if rl.allow(ip) {
|
||||||
|
t.Fatal("allow() immediately after exhausting was allowed, want blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for at least 2 tokens to refill (2 seconds + margin).
|
||||||
|
time.Sleep(2100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Should now allow 2 requests.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if !rl.allow(ip) {
|
||||||
|
t.Fatalf("allow() after refill call %d was blocked, want allowed", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third request after only 2 sec of refill should be blocked.
|
||||||
|
if rl.allow(ip) {
|
||||||
|
t.Fatal("allow() after refill of only 2 tokens was allowed, want blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRateLimiterCleanup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRateLimiterCleanup(t *testing.T) {
|
||||||
|
rl := &rateLimiter{
|
||||||
|
perHour: 10,
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a very old entry (3 hours ago).
|
||||||
|
rl.buckets["old-ip"] = &bucket{
|
||||||
|
tokens: 0,
|
||||||
|
last: time.Now().Add(-3 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a recent entry (30 seconds ago).
|
||||||
|
rl.buckets["recent-ip"] = &bucket{
|
||||||
|
tokens: 5,
|
||||||
|
last: time.Now().Add(-30 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup entries older than 2 hours.
|
||||||
|
rl.cleanup(2 * time.Hour)
|
||||||
|
|
||||||
|
if _, ok := rl.buckets["old-ip"]; ok {
|
||||||
|
t.Error("old-ip was not cleaned up, want removed")
|
||||||
|
}
|
||||||
|
if _, ok := rl.buckets["recent-ip"]; !ok {
|
||||||
|
t.Error("recent-ip was cleaned up, want kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRateLimiterZeroPerHour
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRateLimiterZeroPerHour(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
form.RateLimitPerHour = 0
|
||||||
|
form.AllowedOrigins = nil // allow all
|
||||||
|
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
// Send many requests rapidly — none should be rate-limited.
|
||||||
|
const n = 50
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)
|
||||||
|
if rec.Code == http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("request %d rate-limited with perHour=0", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender.sentCount() != n {
|
||||||
|
t.Errorf("sent %d requests, want %d", sender.sentCount(), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRateLimiterMultipleIPs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRateLimiterMultipleIPs(t *testing.T) {
|
||||||
|
perHour := 2
|
||||||
|
rl := &rateLimiter{
|
||||||
|
perHour: perHour,
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
}
|
||||||
|
|
||||||
|
ipA := "10.0.0.1"
|
||||||
|
ipB := "10.0.0.2"
|
||||||
|
|
||||||
|
// Exhaust ipA's tokens.
|
||||||
|
for i := 0; i < perHour; i++ {
|
||||||
|
if !rl.allow(ipA) {
|
||||||
|
t.Fatalf("ipA call %d blocked, want allowed", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rl.allow(ipA) {
|
||||||
|
t.Fatal("ipA after exhaust was allowed, want blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipB should still have all its tokens.
|
||||||
|
for i := 0; i < perHour; i++ {
|
||||||
|
if !rl.allow(ipB) {
|
||||||
|
t.Fatalf("ipB call %d blocked, want allowed", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rl.allow(ipB) {
|
||||||
|
t.Fatal("ipB after exhaust was allowed, want blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestMinF
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestMinF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
a, b, want float64
|
||||||
|
}{
|
||||||
|
{1, 2, 1},
|
||||||
|
{2, 1, 1},
|
||||||
|
{3.5, 3.5, 3.5},
|
||||||
|
{-1, 0, -1},
|
||||||
|
{0, -5, -5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := minF(tt.a, tt.b)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("minF(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRespondOK
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRespondOK(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
respondOK(w)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.Response
|
||||||
|
decodeBody(t, w, &resp)
|
||||||
|
if !resp.OK {
|
||||||
|
t.Errorf("OK = %v, want true", resp.OK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestRespondError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRespondError(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.")
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, w, &resp)
|
||||||
|
if resp.Error != "invalid_json" {
|
||||||
|
t.Errorf("error = %q, want %q", resp.Error, "invalid_json")
|
||||||
|
}
|
||||||
|
if resp.Message != "Could not parse JSON body." {
|
||||||
|
t.Errorf("message = %q, want %q", resp.Message, "Could not parse JSON body.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHealth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if status, ok := resp["status"]; !ok || status != "ok" {
|
||||||
|
t.Errorf("status = %v, want ok", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerOptions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerOptions(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
|
||||||
|
t.Errorf("ACAO = %q, want %q", got, "https://example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerOriginNotAllowed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerOriginNotAllowed(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
rec := postJSONWithOrigin(t, mux, "/test",
|
||||||
|
`{"name":"A","email":"a@b.com","message":"Hello there!"}`,
|
||||||
|
"https://evil.com")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if resp.Error != "origin_not_allowed" {
|
||||||
|
t.Errorf("error = %q, want origin_not_allowed", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender.sentCount() != 0 {
|
||||||
|
t.Errorf("sender called %d times, want 0", sender.sentCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerRateLimited
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerRateLimited(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
form.RateLimitPerHour = 1
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
// First request should succeed.
|
||||||
|
rec1 := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)
|
||||||
|
if rec1.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first request status = %d, want %d", rec1.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second request should be rate-limited.
|
||||||
|
rec2 := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Bob","email":"b@b.com","message":"Hello there!"}`)
|
||||||
|
if rec2.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second request status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, rec2, &resp)
|
||||||
|
if resp.Error != "rate_limited" {
|
||||||
|
t.Errorf("error = %q, want rate_limited", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerInvalidJSON
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerInvalidJSON(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
rec := postJSON(t, mux, "/test", `not json`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if resp.Error != "invalid_json" {
|
||||||
|
t.Errorf("error = %q, want invalid_json", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerValidationError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerValidationError(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
// Empty name triggers validation error.
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"","email":"a@b.com","message":"hi"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if resp.Error != "validation" {
|
||||||
|
t.Errorf("error = %q, want validation", resp.Error)
|
||||||
|
}
|
||||||
|
if len(resp.Details) == 0 {
|
||||||
|
t.Fatal("details empty, want validation field errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender.sentCount() != 0 {
|
||||||
|
t.Errorf("sender called %d times even with validation error", sender.sentCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerHoneypot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerHoneypot(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
form.HoneypotField = "website"
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
// The honeypot value ("website":"bot") is ignored during JSON decode
|
||||||
|
// because Request.Honeypot is tagged `json:"-"`. The handler sees an
|
||||||
|
// empty Honeypot and proceeds to send the email normally.
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@b.com","message":"Hello there!","website":"bot_value"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerSendSuccess
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerSendSuccess(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@example.com","message":"Hello there!"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.Response
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if !resp.OK {
|
||||||
|
t.Errorf("OK = %v, want true", resp.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender.sentCount() != 1 {
|
||||||
|
t.Fatalf("sender called %d times, want 1", sender.sentCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.mu.Lock()
|
||||||
|
got := sender.sent[0]
|
||||||
|
sender.mu.Unlock()
|
||||||
|
if got.Name != "Alice" {
|
||||||
|
t.Errorf("sent name = %q, want Alice", got.Name)
|
||||||
|
}
|
||||||
|
if got.Email != "a@example.com" {
|
||||||
|
t.Errorf("sent email = %q, want a@example.com", got.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerSendError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerSendError(t *testing.T) {
|
||||||
|
form := testForm()
|
||||||
|
sender := &mockSender{sendErr: errors.New("smtp down")}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, nil)
|
||||||
|
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@example.com","message":"Hello there!"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if resp.Error != "send_failed" {
|
||||||
|
t.Errorf("error = %q, want send_failed", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerNewsletterAppend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerNewsletterAppend(t *testing.T) {
|
||||||
|
form := testNewsletterForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
store := &mockStore{}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, store)
|
||||||
|
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@example.com"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.Response
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if !resp.OK {
|
||||||
|
t.Errorf("OK = %v, want true", resp.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender.sentCount() != 1 {
|
||||||
|
t.Errorf("sender called %d times, want 1", sender.sentCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
store.mu.Lock()
|
||||||
|
n := len(store.appended)
|
||||||
|
store.mu.Unlock()
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("store appended %d subscribers, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.mu.Lock()
|
||||||
|
sub := store.appended[0]
|
||||||
|
store.mu.Unlock()
|
||||||
|
if sub.Email != "a@example.com" {
|
||||||
|
t.Errorf("appended email = %q, want a@example.com", sub.Email)
|
||||||
|
}
|
||||||
|
if sub.Form != form.Name {
|
||||||
|
t.Errorf("appended form = %q, want %q", sub.Form, form.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestHandlerNewsletterAppendError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandlerNewsletterAppendError(t *testing.T) {
|
||||||
|
form := testNewsletterForm()
|
||||||
|
sender := &mockSender{}
|
||||||
|
store := &mockStore{appendErr: errors.New("disk full")}
|
||||||
|
mux := newTestHandlerMux(t, form, sender, store)
|
||||||
|
|
||||||
|
rec := postJSON(t, mux, "/test",
|
||||||
|
`{"name":"Alice","email":"a@example.com"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp contactform.ErrorResponse
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
if resp.Error != "storage_failed" {
|
||||||
|
t.Errorf("error = %q, want storage_failed", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender should have been called successfully before the store error.
|
||||||
|
if sender.sentCount() != 1 {
|
||||||
|
t.Errorf("sender called %d times, want 1", sender.sentCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Package storage provides file-based persistence for nuntius.
|
// Package storage provides file-based persistence for nuntius.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Package version exposes the nuntius release identity.
|
// Package version exposes the nuntius release identity.
|
||||||
@@ -7,4 +10,4 @@ package version
|
|||||||
const Name = "nuntius"
|
const Name = "nuntius"
|
||||||
|
|
||||||
// Version is the nuntius release version. Follows SemVer.
|
// Version is the nuntius release version. Follows SemVer.
|
||||||
var Version = "1.0.0"
|
var Version = "1.1.0"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package version
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestName(t *testing.T) {
|
||||||
|
if Name != "nuntius" {
|
||||||
|
t.Errorf("Name = %q, want %q", Name, "nuntius")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
if Version == "" {
|
||||||
|
t.Error("Version must not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,13 @@ build:
|
|||||||
go build -ldflags="-s -w" -o bin/nuntius ./cmd/server
|
go build -ldflags="-s -w" -o bin/nuntius ./cmd/server
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@echo "→ Running tests…"
|
@echo "→ Running all tests…"
|
||||||
go test ./...
|
go test -race -count=1 ./...
|
||||||
|
@echo "→ Checking coverage threshold (≥80% for internal + pkg)…"
|
||||||
|
go test -coverprofile=coverage.out ./internal/... ./pkg/...
|
||||||
|
@go tool cover -func=coverage.out | grep '^total:' | python3 -c "import sys; pct=float(sys.stdin.read().split()[2].rstrip('%')); sys.exit(0 if pct >= 80 else 1)" && echo "Coverage ✓" || (echo "ERROR: coverage < 80%"; exit 1)
|
||||||
|
@rm -f coverage.out
|
||||||
|
|
||||||
|
# Format Go source files
|
||||||
|
fmt:
|
||||||
|
gofmt -w cmd internal pkg
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
// Package contactform provides reusable types and validation
|
// Package contactform provides reusable types and validation
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
package contactform
|
package contactform
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build linux || freebsd
|
//go:build linux || freebsd
|
||||||
|
|
||||||
package contactform
|
package contactform
|
||||||
|
|||||||
Reference in New Issue
Block a user