282 lines
16 KiB
Markdown
282 lines
16 KiB
Markdown
# Contributing
|
|
|
|
Thank you for considering contributing to **nuntius** — a small, opinionated, modular contact form backend written in Go, with zero runtime dependencies and only the first-party `interpres` TOML module (no external third-party Go modules).
|
|
|
|
## Branches and releases
|
|
|
|
- **`main`** holds released code only. It is never committed to directly.
|
|
- **`development`** is the integration branch where work lands.
|
|
|
|
Open pull requests against `development`. Releases follow
|
|
[Semantic Versioning](https://semver.org) and are cut by merging `development`
|
|
into `main` and pushing a `vX.Y.Z` tag to `main`; CI does not perform the merge.
|
|
See [Cutting a release](#cutting-a-release) for the full procedure.
|
|
|
|
## Development Setup
|
|
|
|
### Requirements
|
|
|
|
- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`)
|
|
- [`just`](https://github.com/casey/just) command runner (the `justfile` is the source of truth for build / test / run / install / uninstall)
|
|
- Linux or FreeBSD
|
|
- A local SMTP account for end-to-end testing. Tests in `internal/storage` and `pkg/contactform` do not require SMTP.
|
|
|
|
### Quick Start
|
|
|
|
```bash
|
|
git clone https://sourcedock.dev/petrbalvin/nuntius.git
|
|
cd nuntius
|
|
just install # go mod tidy + go mod download (no third-party modules — no-op for this project)
|
|
just build # → ./bin/nuntius (with -ldflags="-s -w")
|
|
just test # go test ./...
|
|
just run # go run ./cmd/server, listens on :8080
|
|
```
|
|
|
|
The first `just run` writes a template `config.toml` to `/etc/nuntius/config.toml`. To run without root, point `config.ConfigPath` at a writable location or copy the template from `internal/config/config.go` and edit it.
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
just test
|
|
```
|
|
|
|
The suite covers:
|
|
|
|
- `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/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
|
|
|
|
This project intentionally avoids third-party Go modules, so there is no `golangci-lint` step. CI enforces the two checks below — run them locally before opening a pull request:
|
|
|
|
```bash
|
|
gofmt -l cmd internal pkg # must print nothing
|
|
go vet ./...
|
|
```
|
|
|
|
## Code Style
|
|
|
|
- Follow [Effective Go](https://go.dev/doc/effective_go) and the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments).
|
|
- Keep lines under 100 characters where practical.
|
|
- Use `camelCase` for variables and functions, `PascalCase` for exported symbols.
|
|
- Prefix every file with a one-line comment describing its purpose.
|
|
- Use long flags / struct field names — no single-letter aliases.
|
|
- Prefer the Go standard library over external dependencies. **This project allows only stdlib plus the first-party `interpres` TOML library** — no other external imports. If you need a feature that is not in stdlib or `interpres`, implement it on top of stdlib or open an issue first.
|
|
- Error messages in English, lowercase, no trailing period, always wrapped with context (`fmt.Errorf("read %s: %w", path, err)`).
|
|
- Do not log SMTP credentials or full request bodies.
|
|
|
|
### Formatting
|
|
|
|
```bash
|
|
gofmt -w cmd internal pkg
|
|
```
|
|
|
|
There is no `make fmt` target because `gofmt` is the canonical formatter and is enforced by CI.
|
|
|
|
### Testing
|
|
|
|
- Write tests alongside the code in `_test.go` files.
|
|
- Use table-driven tests for multiple cases.
|
|
- Include both happy path and error path tests.
|
|
- Name tests following the Go convention: `TestFunctionName_Scenario`.
|
|
- Use `t.TempDir()` for any test that touches the filesystem; never assume a clean working directory.
|
|
- Network-dependent tests (if any are added in the future) must be guarded by a build tag or a `-short` check so the default `go test ./...` stays hermetic.
|
|
|
|
## Commit Conventions
|
|
|
|
We follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
|
|
| Type | Use |
|
|
|------------|----------------------------------------------------|
|
|
| `feat` | New feature |
|
|
| `fix` | Bug fix |
|
|
| `refactor` | Code restructuring without behavior change |
|
|
| `docs` | Documentation changes |
|
|
| `test` | Adding or updating tests |
|
|
| `chore` | Maintenance, CI, tooling |
|
|
| `perf` | Performance improvement |
|
|
| `security` | Hardening without a user-visible fix |
|
|
|
|
```
|
|
feat: add generic form type with allow-list validation
|
|
fix: trim leading/trailing whitespace on every validated field
|
|
refactor: split handler.go into per-form files
|
|
docs: add architecture overview diagram
|
|
test: cover malformed-line resilience in newsletter store
|
|
chore: add go 1.26 to go.mod
|
|
```
|
|
|
|
- English, lowercase subject, imperative mood (`add`, not `added`).
|
|
- No trailing period, max 72 characters.
|
|
- No version numbers in commit messages — versions belong to tags.
|
|
|
|
## Pull Request Flow
|
|
|
|
1. Create a feature branch from `development`.
|
|
2. Make your changes with clear commit messages following Conventional Commits.
|
|
3. Ensure `gofmt -l cmd internal pkg` prints nothing and `go vet ./...` passes.
|
|
4. Ensure `just test` passes (`go test ./...`).
|
|
5. Add or update tests for your changes — both `pkg/contactform` and `internal/storage` are the canonical places for new tests; integration coverage belongs in `examples/` or in a new top-level `*_test.go` under `cmd/`.
|
|
6. Update documentation if the public API, configuration schema, or behavior changes:
|
|
- `README.md` for the high-level overview.
|
|
- `docs/configuration.md` for any new / changed config keys.
|
|
- `docs/api-reference.md` for any new / changed HTTP behaviour.
|
|
- `docs/architecture.md` for any structural change.
|
|
7. Open a pull request against `development` describing the change, the test plan, and any migration notes. Releasing is a separate step handled by the maintainer — see [Cutting a release](#cutting-a-release).
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
nuntius/
|
|
├── cmd/
|
|
│ └── server/ # CLI entry point (main.go): load config, build handler, run HTTP server
|
|
├── internal/
|
|
│ ├── config/ # TOML config loading, env-var expansion, schema validation
|
|
│ ├── email/ # SMTP message composition (multipart/alternative) and delivery
|
|
│ ├── handler/ # HTTP routing: CORS, rate limit, honeypot, validate, send, persist
|
|
│ └── storage/ # Append-only JSONL newsletter subscriber store
|
|
├── pkg/
|
|
│ └── contactform/ # Public, reusable: Request/Response/ErrorResponse types + Validate
|
|
├── examples/
|
|
│ └── minimal/ # Standalone usage of pkg/contactform (no HTTP server)
|
|
├── docs/ # Architecture, configuration, API, deployment, security, library usage
|
|
├── install.py # One-shot server installer (idempotent)
|
|
├── justfile # install, build, test, run, uninstall
|
|
├── go.mod # Go 1.26, no external dependencies
|
|
└── LICENSE # MIT
|
|
```
|
|
|
|
## Architecture Overview
|
|
|
|
```mermaid
|
|
graph TD
|
|
Browser[Browser / Frontend]:::accent6
|
|
Caddy[Caddy reverse proxy]:::accent6
|
|
CLI[cmd/server/main.go]:::accent0
|
|
Config[internal/config/]:::accent7
|
|
Handler[internal/handler/]:::accent0
|
|
Validate[pkg/contactform/Validate]:::accent3
|
|
Email[internal/email/FormSender]:::accent1
|
|
Storage[internal/storage/NewsletterStore]:::accent1
|
|
SMTP[(SMTP server)]:::accent2
|
|
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
|
|
|
|
Browser -->|POST /api/nuntius/contact| Caddy
|
|
Caddy -->|forward with X-Forwarded-For| CLI
|
|
CLI --> Config
|
|
CLI --> Handler
|
|
Handler --> Validate
|
|
Handler --> Email
|
|
Handler --> Storage
|
|
Email -->|SMTP PLAIN| SMTP
|
|
Storage -->|append JSONL| JSONL
|
|
Handler -->|200 ok / 4xx / 5xx| Caddy
|
|
Caddy --> Browser
|
|
|
|
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
|
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
|
classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000
|
|
classDef accent3 fill:#A855F7,stroke:#7C3AED,color:#fff
|
|
classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff
|
|
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
|
|
```
|
|
|
|
### Key Design Decisions
|
|
|
|
- **One process, many forms** — A single nuntius process serves every form defined in `config.toml`. Each form has its own per-IP rate limit, CORS allowlist, honeypot, and (for `newsletter`) subscriber store.
|
|
- **Per-form SMTP credentials** — `email.FormSender` is bound to one form, so credentials never cross tenants inside the same process.
|
|
- **In-memory rate limiting** — Token-bucket per IP, per form. No Redis. Resets on restart; that is an acceptable trade-off for a small backend.
|
|
- **Honeypot is a positive control** — A bot that fills the hidden field gets a `200 ok` response. Humans are never blocked; bots are silently dropped without sending mail.
|
|
- **Append-only JSONL for subscribers** — One JSON object per line under `data_dir/`. Malformed lines are skipped on read so a partial write can never brick the file. No rotation is performed automatically — add logrotate yourself.
|
|
- **Env-var expansion before TOML parse** — `${NUNTIUS_SMTP_PASSWORD}` is substituted by `os.Expand` *before* the TOML is parsed by `interpres`, so the password never lands in the parsed struct as a literal — only its expanded value is held in memory.
|
|
- **Unknown fields are rejected** — `interpres.DisallowUnknownFields` catches typos at startup rather than silently ignoring them.
|
|
- **No global state outside the binary** — Everything that varies per form is owned by `ContactHandler` and the maps it builds in `New()`. There are no package-level singletons beyond the implicit `slog` default.
|
|
|
|
## Testing Strategy
|
|
|
|
| Layer | Approach | Tools |
|
|
|---|---|---|
|
|
| Unit tests | Table-driven tests for validators and per-form rate limiters | `testing` stdlib |
|
|
| Storage tests | `t.TempDir()`-scoped JSONL files, malformed-line fixtures | `testing` + `os` + `bufio` |
|
|
| HTTP layer | Implicit: the per-form `makeHandler` closure is a plain function; tests should be added by calling it with an `httptest.NewRecorder` if/when the surface grows | `net/http/httptest` |
|
|
| Race detection | `go test -race` is encouraged locally for changes touching `handler.go` and `storage` | `go test -race` |
|
|
|
|
## CI
|
|
|
|
This project uses **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 — `.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 `fedora` runner:
|
|
|
|
| Job | What it does | Why it exists |
|
|
|---|---|---|
|
|
| `vet` | `gofmt -l cmd internal pkg` (must print nothing) and `go vet ./...` | Catches formatting drift and static-analysis issues before tests run. |
|
|
| `test` | `go test -race -count=1 ./...`, 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. |
|
|
|
|
Run the same checks locally before opening a pull request:
|
|
|
|
```bash
|
|
gofmt -l cmd internal pkg # must print nothing
|
|
go vet ./...
|
|
just test
|
|
just build
|
|
./bin/nuntius --version # sanity-check the new flag
|
|
```
|
|
|
|
### Release workflow — `.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:
|
|
|
|
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
|
|
-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.
|
|
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
|
|
|
|
1. Bump `Version` in `internal/version/version.go`.
|
|
2. Add a new `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`.
|
|
3. Make sure CI is green on `development` (pushes and PRs against `development` are what the test workflow runs on).
|
|
4. Merge `development` into `main` (e.g. open a PR on 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:
|
|
```bash
|
|
git tag -a vX.Y.Z -m "nuntius vX.Y.Z"
|
|
git push origin main
|
|
git push origin vX.Y.Z
|
|
```
|
|
6. The release workflow builds the 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 Gitea API rejects duplicate release tag names.
|
|
|
|
## Adding a New Form Type
|
|
|
|
The four current types (`contact`, `feedback`, `newsletter`, `generic`) live in `internal/config.ValidFormTypes`. To add one:
|
|
|
|
1. Add the new type to `ValidFormTypes` in `internal/config/config.go`.
|
|
2. Add a `validateXxx` function in `pkg/contactform/validate.go` and dispatch it from `Validate`.
|
|
3. Add an `emailTemplateXxx` and a `compose()` branch in `internal/email/sender.go`.
|
|
4. Add tests in `pkg/contactform/validate_test.go`.
|
|
5. Update `docs/configuration.md` (form type table) and `docs/api-reference.md` if the public surface changes.
|
|
|
|
## Report a Bug
|
|
|
|
Open an issue at <https://sourcedock.dev/petrbalvin/nuntius/issues> with:
|
|
|
|
- nuntius version (`./bin/nuntius --version`).
|
|
- Operating system and architecture.
|
|
- The exact `curl` / frontend snippet that reproduces it.
|
|
- Expected vs actual behaviour, and the relevant slice of `journalctl -u nuntius`.
|
|
|
|
For **security issues**, please email **opensource@petrbalvin.org** rather than opening a public issue.
|