Files
nuntius/CONTRIBUTING.md

16 KiB

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 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 for the full procedure.

Development Setup

Requirements

  • Go 1.26+ (uses Go 1.22+ http.ServeMux method patterns and log/slog)
  • 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

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

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/storageAppend / 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:

gofmt -l cmd internal pkg   # must print nothing
go vet ./...

Code Style

  • Follow Effective Go and the Go Code Review Comments.
  • 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

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.

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.

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

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 credentialsemail.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 rejectedinterpres.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:

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:
    -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:
    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.