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
mainholds released code only. It is never committed to directly.developmentis 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.ServeMuxmethod patterns andlog/slog) justcommand runner (thejustfileis 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/storageandpkg/contactformdo 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/storage—Append/Count/Listsemantics, file and directory auto-creation, missing-file tolerance, malformed-line resilience.internal/version— version identity and--versionsmoke 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
camelCasefor variables and functions,PascalCasefor 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
interpresTOML library — no other external imports. If you need a feature that is not in stdlib orinterpres, 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.gofiles. - 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
-shortcheck so the defaultgo 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, notadded). - No trailing period, max 72 characters.
- No version numbers in commit messages — versions belong to tags.
Pull Request Flow
- Create a feature branch from
development. - Make your changes with clear commit messages following Conventional Commits.
- Ensure
gofmt -l cmd internal pkgprints nothing andgo vet ./...passes. - Ensure
just testpasses (go test ./...). - Add or update tests for your changes — both
pkg/contactformandinternal/storageare the canonical places for new tests; integration coverage belongs inexamples/or in a new top-level*_test.goundercmd/. - Update documentation if the public API, configuration schema, or behavior changes:
README.mdfor the high-level overview.docs/configuration.mdfor any new / changed config keys.docs/api-reference.mdfor any new / changed HTTP behaviour.docs/architecture.mdfor any structural change.
- Open a pull request against
developmentdescribing 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 (fornewsletter) subscriber store. - Per-form SMTP credentials —
email.FormSenderis 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 okresponse. 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 byos.Expandbefore the TOML is parsed byinterpres, 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.DisallowUnknownFieldscatches typos at startup rather than silently ignoring them. - No global state outside the binary — Everything that varies per form is owned by
ContactHandlerand the maps it builds inNew(). There are no package-level singletons beyond the implicitslogdefault.
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:
build— Runs on afedorarunner in a 7-way matrix:linux/amd64,linux/arm64,linux/riscv64,linux/loong64,freebsd/amd64,freebsd/arm64,freebsd/riscv64. The defaultactions/checkout@v4checks out the tagged commit (the trigger was a tag push), which is exactly the commit onmainwe want to ship. The version is baked in via-ldflags:The resulting binary is uploaded as a workflow artifact and smoke-tested with-X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION_NO_V}--versionon the build host.release— Runs on afedorarunner (no Go toolchain needed). Downloads all artifacts, extracts the matching section fromCHANGELOG.mdfor 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
- Bump
Versionininternal/version/version.go. - Add a new
## [X.Y.Z] — YYYY-MM-DDsection at the top ofCHANGELOG.md. - Make sure CI is green on
development(pushes and PRs againstdevelopmentare what the test workflow runs on). - Merge
developmentintomain(e.g. open a PR on Gitea, orgit checkout main && git merge --no-ff development && git push origin main). This is the only step wheremainis updated outside of a tag push. - Tag the
maintip 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 - 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:
- Add the new type to
ValidFormTypesininternal/config/config.go. - Add a
validateXxxfunction inpkg/contactform/validate.goand dispatch it fromValidate. - Add an
emailTemplateXxxand acompose()branch ininternal/email/sender.go. - Add tests in
pkg/contactform/validate_test.go. - Update
docs/configuration.md(form type table) anddocs/api-reference.mdif 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.