10 Commits
Author SHA1 Message Date
petrbalvin 506ea08c6d chore: prepare release v1.1.0
Release / build (amd64, freebsd) (push) Failing after 1m2s
Release / build (arm64, freebsd) (push) Canceled after 0s
Release / build (arm64, linux) (push) Canceled after 0s
Release / build (loong64, linux) (push) Canceled after 0s
Release / build (riscv64, freebsd) (push) Canceled after 0s
Release / build (riscv64, linux) (push) Canceled after 0s
Release / release (push) Canceled after 0s
Release / build (amd64, linux) (push) Canceled after 17s
2026-07-26 21:13:02 +02:00
petrbalvin 9457d3f8cc docs: replace nginx with Caddy across all documentation and installer 2026-07-26 21:10:45 +02:00
petrbalvin 4dd7b6991d test: add tests for handler, email, cmd/server, version and enforce 80% coverage 2026-07-26 20:59:56 +02:00
petrbalvin 08554695da chore: remove copyright header from justfile 2026-07-26 20:47:30 +02:00
petrbalvin b6ee569681 chore: add copyright headers to all source files 2026-07-26 20:46:50 +02:00
petrbalvin 6329683a5d chore: add __pycache__ to gitignore 2026-07-26 20:44:28 +02:00
petrbalvin 9a904fda8a refactor: replace install.sh with Python install.py 2026-07-26 20:44:10 +02:00
petrbalvin 83c95dff08 fix: apply code-review and insight findings across nuntius 2026-07-26 20:40:16 +02:00
petrbalvin 6339771a24 feat(ci): add riscv64 and loong64 build targets 2026-07-26 20:31:11 +02:00
petrbalvin db3860328b chore: migrate CI to Gitea actions and update module path 2026-07-26 20:29:01 +02:00
34 changed files with 2084 additions and 459 deletions
-47
View File
@@ -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/...
@@ -2,11 +2,11 @@ name: Release
on: on:
push: push:
tags: ["v*"] tags: ["v*.*.*"]
jobs: jobs:
build: build:
runs-on: codeberg-small runs-on: fedora
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -15,14 +15,20 @@ 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@v4
- uses: https://data.forgejo.org/actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.26" go-version: "1.26"
@@ -32,7 +38,7 @@ jobs:
- name: Build - name: Build
id: build id: build
env: env:
REF: ${{ forgejo.ref }} REF: ${{ gitea.ref }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -45,36 +51,41 @@ jobs:
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}" 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
- 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
- name: Smoke test
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
steps: steps:
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: actions/checkout@v4
- 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,7 +107,7 @@ jobs:
- name: Create release - name: Create release
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
# JSON-escape the release body with sed: backslashes first, then # JSON-escape the release body with sed: backslashes first, then
@@ -111,13 +122,13 @@ jobs:
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\": \"${FORGEJO_REF_NAME}\", \"tag_name\": \"${GITEA_REF_NAME}\",
\"name\": \"${FORGEJO_REF_NAME}\", \"name\": \"${GITEA_REF_NAME}\",
\"body\": ${BODY}, \"body\": ${BODY},
\"draft\": false, \"draft\": false,
\"prerelease\": false \"prerelease\": false
@@ -139,7 +150,7 @@ jobs:
- name: Upload assets - name: Upload assets
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
RELEASE_ID=$(cat release-id.txt) RELEASE_ID=$(cat release-id.txt)
@@ -150,11 +161,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 +174,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-*/
+83
View File
@@ -0,0 +1,83 @@
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
jobs:
vet:
runs-on: fedora
steps:
- uses: actions/checkout@v4
- uses: 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: |
unformatted=$(gofmt -l cmd internal pkg)
if [ -n "$unformatted" ]; then
echo "The following files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: go vet
run: go vet ./...
test:
runs-on: fedora
needs: vet
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Download dependencies
run: go mod download
- name: Install build tools
run: dnf install -y gcc
- name: go test -race
run: go test -race -count=1 ./...
- name: Check coverage threshold
run: |
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 "ERROR: coverage < 80%"; exit 1)
- name: go test ./examples
run: go build ./examples/...
build:
runs-on: fedora
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
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: Test
run: go test ./...
- name: Smoke test
run: ./bin/nuntius --version
+1
View File
@@ -2,6 +2,7 @@
bin/ bin/
*.test *.test
*.out *.out
__pycache__/
# Local secrets and config # Local secrets and config
.env* .env*
+49 -3
View File
@@ -5,13 +5,57 @@ 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.
[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 +88,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
View File
@@ -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.
+167 -134
View File
@@ -1,145 +1,83 @@
# Nuntius — A Small, Opinionated Contact Form Backend # Nuntius — A Small, Opinionated Contact Form Backend
[![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
[![Go](https://img.shields.io/badge/Go-00ADD8?labelColor=00ADD8&logo=go&logoColor=fff)](https://go.dev)
[![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=000)](https://kernel.org)
[![FreeBSD](https://img.shields.io/badge/FreeBSD-AB2B28?logo=freebsd&logoColor=fff)](https://freebsd.org)
> Latin *nuntius* = "messenger" — the service that carries messages from visitors to you. > 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 run # go run ./cmd/server just fmt # gofmt -w cmd internal pkg
just uninstall # rm -rf ./bin just run # go run ./cmd/server
``` ```
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
View File
@@ -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,
+118
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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" \
+3 -3
View File
@@ -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
View File
@@ -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.
+4 -1
View File
@@ -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() {
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"
+4 -1
View File
@@ -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.
+16
View File
@@ -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")
}
}
+12 -4
View File
@@ -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
+314
View File
@@ -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
View File
@@ -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 {
+804
View File
@@ -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())
}
}
+3
View File
@@ -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.
+3
View File
@@ -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
+4 -1
View File
@@ -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"
+20
View File
@@ -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")
}
}
+10 -2
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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