feat: initialize nuntius project with full server implementation

This commit is contained in:
2026-06-20 20:48:09 +02:00
commit 1b700f7975
33 changed files with 4313 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# EditorConfig: https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{rb,erb,gemspec}]
indent_style = space
indent_size = 2
[{Gemfile,Rakefile}]
indent_style = space
indent_size = 2
[*.{js,html,css,vue}]
indent_style = space
indent_size = 2
[*.{yml,yaml,json,toml}]
indent_style = space
indent_size = 2
[*.{rs,cj}]
indent_style = space
indent_size = 4
[*.go]
indent_style = tab
indent_size = 4
[justfile]
indent_style = tab
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
+23
View File
@@ -0,0 +1,23 @@
# nuntius — environment variables for runtime secrets.
#
# Copy this file to /etc/nuntius/.env on the server and fill in real values.
# Permissions: chmod 600 (readable only by root / the nuntius user).
#
# The systemd service (deploy/nuntius.service) loads this file via
# EnvironmentFile= and exports every variable to nuntius at startup.
# The JSON config substitutes ${VAR_NAME} references from this file
# before parsing, so secrets can stay out of config.json.
# Required: SMTP password used by every form. Reference in config.json as
# ${NUNTIUS_SMTP_PASSWORD}.
NUNTIUS_SMTP_PASSWORD=change-me-to-your-smtp-password
# Optional: override the listen port. Config.json `server.port` takes
# precedence; this is only useful if you want to flip the port without
# editing JSON.
# NUNTIUS_PORT=8080
# Optional: override the data directory for newsletter subscribers.
# Config.json `data_dir` takes precedence; this is only useful if you
# want to point storage elsewhere (e.g. a dedicated volume).
# NUNTIUS_DATA_DIR=/var/lib/nuntius/data
+167
View File
@@ -0,0 +1,167 @@
name: Release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: codeberg-small
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: freebsd
goarch: amd64
- goos: freebsd
goarch: arm64
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: Build
id: build
env:
REF: ${{ forgejo.ref }}
run: |
set -euo pipefail
VERSION="${REF##*/}"
if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
exit 1
fi
VERSION_NO_V="${VERSION#v}"
echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT"
LDFLAGS="-s -w -X codeberg.org/petrbalvin/nuntius/internal/version.Version=${VERSION}"
mkdir -p bin
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -ldflags "$LDFLAGS" \
-o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \
./cmd/server
- name: Upload artifact
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
if-no-files-found: error
release:
runs-on: codeberg-tiny
needs: build
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Download all artifacts
uses: https://data.forgejo.org/actions/download-artifact@v3
with:
path: dist
- name: Extract CHANGELOG section
env:
VERSION: ${{ forgejo.ref_name }}
run: |
set -euo pipefail
VERSION_NO_V="${VERSION#v}"
echo "Looking for CHANGELOG section: ${VERSION_NO_V}"
sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
| sed '$d' \
| tail -n +2 \
> release-body.md
if [ ! -s release-body.md ]; then
echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
exit 1
fi
echo "Release body preview:"
head -n 20 release-body.md
- name: Create release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
set -euo pipefail
# JSON-escape the release body with sed: backslashes first, then
# quotes, tabs, and carriage returns, and finally fold newlines into
# \n. Wrap the result in quotes to form a JSON string.
BODY=$(sed -e 's/\\/\\\\/g' \
-e 's/"/\\"/g' \
-e 's/\t/\\t/g' \
-e 's/\r//g' \
release-body.md \
| sed ':a;N;$!ba;s/\n/\\n/g')
BODY="\"${BODY}\""
response=$(curl -sS -w '\n%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
-d "{
\"tag_name\": \"${FORGEJO_REF_NAME}\",
\"name\": \"${FORGEJO_REF_NAME}\",
\"body\": ${BODY},
\"draft\": false,
\"prerelease\": false
}")
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
echo "HTTP ${http_code}"
if [ "$http_code" != "201" ]; then
echo "Failed to create release: ${body}"
exit 1
fi
RELEASE_ID=$(echo "$body" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
echo "Created release ID=${RELEASE_ID}"
printf '%s' "${RELEASE_ID}" > release-id.txt
- name: Upload assets
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
set -euo pipefail
RELEASE_ID=$(cat release-id.txt)
echo "Release ID: ${RELEASE_ID}"
for binary in dist/nuntius-*/nuntius-*; do
[ -f "$binary" ] || continue
fname=$(basename "$binary")
echo "Uploading ${fname}..."
http_code=$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-X POST \
--data-binary "@${binary}" \
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${fname}")
echo " HTTP ${http_code}"
if [ "$http_code" != "201" ]; then
echo "Failed to upload ${fname}"
exit 1
fi
done
echo
echo "Release ${FORGEJO_REF_NAME} is live with the following assets:"
ls -lh dist/nuntius-*/
+47
View File
@@ -0,0 +1,47 @@
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/...
+9
View File
@@ -0,0 +1,9 @@
# Build artifacts
bin/
*.test
*.out
# Local secrets and config
.env*
!.env.example
config.toml
+52
View File
@@ -0,0 +1,52 @@
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] — 2026-06-20
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,
SMTP delivery, a JSONL newsletter log, strict TOML configuration, and env-var
secrets. Its only dependency outside the standard library is the first-party
[interpres](https://codeberg.org/petrbalvin/interpres) TOML parser.
### Added
- **Server** — `cmd/server`, the HTTP entry point. `http.Server` with explicit
`ReadHeaderTimeout` (10 s), `ReadTimeout` (15 s), `WriteTimeout` (30 s), and
`IdleTimeout` (60 s). Graceful shutdown on `SIGINT` / `SIGTERM` with a 15 s
budget. Structured logging via `log/slog` (JSON handler, INFO level, stdout)
with one log line per request. `GET /health` endpoint. `--version` flag.
- **Four form kinds** — `contact`, `feedback`, `newsletter`, and `generic`, each
with its own endpoint, per-IP rate limit, CORS allowlist, and honeypot field.
One `POST` + one `OPTIONS` handler per form on a fresh `http.ServeMux`.
- **SMTP delivery** — stdlib `net/smtp` with `PLAIN` auth, `multipart/alternative`
HTML + plain text bodies, one dedicated template per form type.
- **Newsletter JSONL log** — append-only `data_dir/newsletter-<name>.jsonl` with
crash-safe `O_APPEND` writes; `Count` and `List` skip malformed lines.
- **`pkg/contactform`** — public Go package with `Request` / `Response` /
`FieldError` / `ErrorResponse` types and a `Validate` function for all four
form types.
- **Per-IP rate limiting** — in-memory token bucket per form, configurable per
hour.
- **Per-form honeypot** — silent 200 on bot fill, no mail, no subscriber line.
- **Per-form CORS** — allowlist enforced on preflight and actual requests, with
echoed `Access-Control-Allow-Origin` + `Vary: Origin`.
- **TOML configuration** — parsed by the first-party `interpres` library, with
strict unknown-field rejection and per-type validation that fails loud on
typos at startup.
- **Env-var secrets** — `${VAR_NAME}` / `$VAR_NAME` expansion before the config
is parsed, so SMTP passwords never live in the file.
- **Auto-generated config** — a three-form TOML template is written to
`/etc/nuntius/config.toml` on first start.
- **systemd unit** — installed inline from `docs/deployment.md` with
`Restart=on-failure` and `EnvironmentFile=`.
- **Install script** — `install.sh`, idempotent, handles user creation, config
generation, and secrets file mode `0600`.
- **docs/** — `architecture.md`, `configuration.md`, `api-reference.md`,
`deployment.md`, `security.md`, `library-usage.md`.
[1.0.0]: https://codeberg.org/petrbalvin/nuntius/releases/tag/v1.0.0
+274
View File
@@ -0,0 +1,274 @@
# 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, macOS, 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://codeberg.org/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:
- `pkg/contactform` — validators for all four form types (contact, feedback, newsletter, generic), whitespace trimming, and error message shape.
- `internal/storage``Append` / `Count` / `List` semantics, file and directory auto-creation, missing-file tolerance, malformed-line resilience.
### 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.sh # 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
Nginx[nginx 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| Nginx
Nginx -->|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| Nginx
Nginx --> 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 **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.
### Test workflow — `.forgejo/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:
| 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 ./...` 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. |
| `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 — `.forgejo/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 `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`:
```bash
-X codeberg.org/petrbalvin/nuntius/internal/version.Version=${VERSION#v}
```
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.
### 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 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.
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 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.
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.
## 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://codeberg.org/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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+209
View File
@@ -0,0 +1,209 @@
# 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.
**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.
## Features
- **Single static binary** — `go build -ldflags="-s -w"` produces ≈ 6 MB; no Node, no Python, no Redis, no Postgres, no third-party Go packages.
- **Four form kinds out of the box** — `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. 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
```bash
# 1. Clone
git clone https://codeberg.org/petrbalvin/nuntius.git
cd nuntius
# 2. Build
just build
# → ./bin/nuntius
# 3. Run (auto-creates /etc/nuntius/config.toml with a 3-form template on first start)
sudo mkdir -p /etc/nuntius
just run
# → "nuntius starting" addr=:8080 forms=3
# 4. Edit the generated config to point at your SMTP and recipient
sudo $EDITOR /etc/nuntius/config.toml
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.
The full schema lives in [`docs/configuration.md`](docs/configuration.md); the deploy guide is in [`docs/deployment.md`](docs/deployment.md).
## Usage
### Test the endpoints
```bash
# 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)
curl -X POST http://localhost:8080/api/nuntius/feedback \
-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
data_dir = "./data"
[server]
port = 8080
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
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.
## Requirements
- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`)
- 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`.
## Development
```bash
just install # go mod tidy + go mod download (no-op for this project, kept for symmetry)
just build # → ./bin/nuntius (with -s -w)
just test # go test ./...
just run # go run ./cmd/server
just uninstall # rm -rf ./bin
```
All four `just` recipes are mandatory per the project conventions and produce zero errors and zero warnings.
## Documentation
- [`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/api-reference.md`](docs/api-reference.md) — HTTP verbs, status codes, `GET /health`
- [`docs/deployment.md`](docs/deployment.md) — production systemd, nginx, updates
- [`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
## Contributing
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
MIT — see [LICENSE](./LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+188
View File
@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="nuntius messenger icon">
<title>nuntius</title>
<desc>A circular messenger seal: gold rim with engraved dots, deep blue starfield, and a cream envelope sealed with red wax bearing the letter N.</desc>
<defs>
<linearGradient id="rim" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#fff3c8"/>
<stop offset="25%" stop-color="#f4d27a"/>
<stop offset="55%" stop-color="#c89c4a"/>
<stop offset="80%" stop-color="#8a6520"/>
<stop offset="100%" stop-color="#5a3f15"/>
</linearGradient>
<linearGradient id="rimInner" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#7a5418"/>
<stop offset="50%" stop-color="#d8a957"/>
<stop offset="100%" stop-color="#fff3c8"/>
</linearGradient>
<radialGradient id="bg" cx="0.5" cy="0.4" r="0.75">
<stop offset="0%" stop-color="#2a4a8a"/>
<stop offset="50%" stop-color="#122a55"/>
<stop offset="100%" stop-color="#04081a"/>
</radialGradient>
<radialGradient id="bgHighlight" cx="0.5" cy="0.25" r="0.5">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paper" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#fbf3dc"/>
<stop offset="100%" stop-color="#dac79a"/>
</linearGradient>
<linearGradient id="flap" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#eedbab"/>
<stop offset="100%" stop-color="#a88a58"/>
</linearGradient>
<radialGradient id="wax" cx="0.4" cy="0.32" r="0.7">
<stop offset="0%" stop-color="#f47575"/>
<stop offset="50%" stop-color="#a01818"/>
<stop offset="100%" stop-color="#4a0808"/>
</radialGradient>
<radialGradient id="waxShine" cx="0.3" cy="0.25" r="0.4">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.65"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="envelopeGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stop-color="#f4d27a" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#f4d27a" stop-opacity="0"/>
</radialGradient>
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="5"/>
<feOffset dx="0" dy="6"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Outer gold rim -->
<circle cx="256" cy="256" r="252" fill="url(#rim)"/>
<!-- Engraved dots on the rim (8 at 45 degree steps) -->
<g fill="#5a3f15" opacity="0.6">
<circle cx="496" cy="256" r="3.2"/>
<circle cx="425.7" cy="425.7" r="3.2"/>
<circle cx="256" cy="496" r="3.2"/>
<circle cx="86.3" cy="425.7" r="3.2"/>
<circle cx="16" cy="256" r="3.2"/>
<circle cx="86.3" cy="86.3" r="3.2"/>
<circle cx="256" cy="16" r="3.2"/>
<circle cx="425.7" cy="86.3" r="3.2"/>
</g>
<!-- Inner gold edge (rim lip) -->
<circle cx="256" cy="256" r="226" fill="url(#rimInner)"/>
<!-- Dark blue background field -->
<circle cx="256" cy="256" r="220" fill="url(#bg)"/>
<!-- Soft top highlight on the field -->
<circle cx="256" cy="256" r="220" fill="url(#bgHighlight)"/>
<!-- Thin inner gold border line -->
<circle cx="256" cy="256" r="212" fill="none" stroke="#c89c4a" stroke-width="1" opacity="0.55"/>
<!-- Starfield (avoiding the envelope area) -->
<g fill="#ffffff">
<circle cx="100" cy="130" r="1.6" opacity="0.85"/>
<circle cx="135" cy="92" r="1" opacity="0.6"/>
<circle cx="180" cy="72" r="1.4" opacity="0.75"/>
<circle cx="256" cy="58" r="1.8" opacity="0.9"/>
<circle cx="320" cy="72" r="1.2" opacity="0.7"/>
<circle cx="370" cy="95" r="1.5" opacity="0.8"/>
<circle cx="408" cy="140" r="1" opacity="0.55"/>
<circle cx="82" cy="200" r="1.3" opacity="0.7"/>
<circle cx="424" cy="210" r="1.4" opacity="0.75"/>
<circle cx="90" cy="380" r="1.2" opacity="0.65"/>
<circle cx="420" cy="370" r="1.5" opacity="0.8"/>
<circle cx="160" cy="430" r="1" opacity="0.55"/>
<circle cx="350" cy="425" r="1.3" opacity="0.7"/>
<circle cx="256" cy="448" r="1.1" opacity="0.6"/>
</g>
<!-- Soft warm glow behind the envelope -->
<ellipse cx="256" cy="270" rx="170" ry="100" fill="url(#envelopeGlow)"/>
<!-- Dashed decorative orbit around the envelope -->
<circle cx="256" cy="270" r="158" fill="none" stroke="#c89c4a" stroke-width="0.8" opacity="0.35" stroke-dasharray="2,5"/>
<!-- Envelope (slightly tilted for character) -->
<g transform="rotate(-4 256 270)" filter="url(#softShadow)">
<!-- Back panel of the envelope -->
<rect x="116" y="180" width="280" height="180" rx="3" fill="url(#paper)"/>
<!-- Inner V-fold line -->
<path d="M 116 200 L 256 305 L 396 200" fill="none" stroke="#a88a58" stroke-width="1.2" opacity="0.55"/>
<!-- Side fold triangles (subtle depth) -->
<path d="M 116 180 L 116 360 L 200 360 Z" fill="#c8ad72" opacity="0.32"/>
<path d="M 396 180 L 396 360 L 312 360 Z" fill="#c8ad72" opacity="0.32"/>
<!-- Closed top triangular flap -->
<path d="M 116 180 L 256 290 L 396 180 Z" fill="url(#flap)"/>
<!-- Flap edge accent -->
<path d="M 116 180 L 256 290 L 396 180" fill="none" stroke="#7a5a25" stroke-width="1.5"/>
<!-- Faint horizontal paper creases -->
<line x1="140" y1="222" x2="240" y2="222" stroke="#a88a58" stroke-width="0.6" opacity="0.4"/>
<line x1="280" y1="222" x2="370" y2="222" stroke="#a88a58" stroke-width="0.6" opacity="0.4"/>
<line x1="150" y1="338" x2="362" y2="338" stroke="#a88a58" stroke-width="0.5" opacity="0.3"/>
</g>
<!-- Wax seal -->
<g transform="rotate(-4 256 270)">
<!-- Wax pool (slightly irregular) -->
<ellipse cx="256" cy="298" rx="44" ry="38" fill="url(#wax)"/>
<!-- Small drip on the bottom -->
<ellipse cx="248" cy="335" rx="4" ry="6" fill="url(#wax)"/>
<ellipse cx="266" cy="333" rx="3.5" ry="5" fill="url(#wax)"/>
<!-- Inner ring engraving -->
<circle cx="256" cy="298" r="32" fill="none" stroke="#5a0808" stroke-width="1" opacity="0.6"/>
<circle cx="256" cy="298" r="29" fill="none" stroke="#f4d27a" stroke-width="0.8" opacity="0.55"/>
<!-- Decorative dots around the seal -->
<g fill="#f4d27a" opacity="0.7">
<circle cx="256" cy="272" r="1.4"/>
<circle cx="282" cy="298" r="1.4"/>
<circle cx="256" cy="324" r="1.4"/>
<circle cx="230" cy="298" r="1.4"/>
</g>
<!-- Monogram N -->
<text x="256" y="310" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="36" font-weight="bold" fill="#f4d27a" opacity="0.95">N</text>
<!-- Highlight on the seal -->
<ellipse cx="244" cy="284" rx="15" ry="7" fill="url(#waxShine)"/>
</g>
<!-- Four-pointed stars at the cardinal compass points (on the inner gold ring) -->
<g fill="#f4d27a" opacity="0.9">
<g transform="translate(256 78)">
<path d="M 0 -10 L 2.5 -2.5 L 10 0 L 2.5 2.5 L 0 10 L -2.5 2.5 L -10 0 L -2.5 -2.5 Z"/>
</g>
<g transform="translate(434 256)">
<path d="M 0 -10 L 2.5 -2.5 L 10 0 L 2.5 2.5 L 0 10 L -2.5 2.5 L -10 0 L -2.5 -2.5 Z"/>
</g>
<g transform="translate(256 434)">
<path d="M 0 -10 L 2.5 -2.5 L 10 0 L 2.5 2.5 L 0 10 L -2.5 2.5 L -10 0 L -2.5 -2.5 Z"/>
</g>
<g transform="translate(78 256)">
<path d="M 0 -10 L 2.5 -2.5 L 10 0 L 2.5 2.5 L 0 10 L -2.5 2.5 L -10 0 L -2.5 -2.5 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" role="img" aria-label="nuntius — messenger">
<title>nuntius</title>
<desc>Horizontal logo: a small circular seal with a sealed envelope on the left, the wordmark "nuntius" and the tagline "messenger" on the right.</desc>
<defs>
<radialGradient id="logoBg" cx="0.5" cy="0.4" r="0.75">
<stop offset="0%" stop-color="#2a4a8a"/>
<stop offset="60%" stop-color="#122a55"/>
<stop offset="100%" stop-color="#04081a"/>
</radialGradient>
<linearGradient id="logoRim" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#fff3c8"/>
<stop offset="50%" stop-color="#c89c4a"/>
<stop offset="100%" stop-color="#7a5418"/>
</linearGradient>
<linearGradient id="logoPaper" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#fbf3dc"/>
<stop offset="100%" stop-color="#dac79a"/>
</linearGradient>
<linearGradient id="logoFlap" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#eedbab"/>
<stop offset="100%" stop-color="#a88a58"/>
</linearGradient>
<radialGradient id="logoWax" cx="0.4" cy="0.32" r="0.7">
<stop offset="0%" stop-color="#f47575"/>
<stop offset="50%" stop-color="#a01818"/>
<stop offset="100%" stop-color="#4a0808"/>
</radialGradient>
<radialGradient id="logoWaxShine" cx="0.3" cy="0.25" r="0.4">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.65"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- ===== Simplified circular seal (icon mark) ===== -->
<g transform="translate(90 90)">
<!-- Outer gold rim -->
<circle cx="0" cy="0" r="78" fill="url(#logoRim)"/>
<!-- Inner blue field -->
<circle cx="0" cy="0" r="70" fill="url(#logoBg)"/>
<!-- Thin gold inner line -->
<circle cx="0" cy="0" r="66" fill="none" stroke="#c89c4a" stroke-width="0.8" opacity="0.55"/>
<!-- Soft glow behind envelope -->
<ellipse cx="0" cy="6" rx="56" ry="32" fill="#f4d27a" opacity="0.08"/>
<!-- Envelope (slightly tilted) -->
<g transform="rotate(-4)">
<!-- Back panel -->
<rect x="-44" y="-26" width="88" height="56" rx="2" fill="url(#logoPaper)"/>
<!-- V-fold -->
<path d="M -44 -18 L 0 12 L 44 -18" fill="none" stroke="#a88a58" stroke-width="0.6" opacity="0.55"/>
<!-- Side fold shadows -->
<path d="M -44 -26 L -44 30 L -22 30 Z" fill="#c8ad72" opacity="0.32"/>
<path d="M 44 -26 L 44 30 L 22 30 Z" fill="#c8ad72" opacity="0.32"/>
<!-- Top flap -->
<path d="M -44 -26 L 0 8 L 44 -26 Z" fill="url(#logoFlap)"/>
<!-- Flap edge -->
<path d="M -44 -26 L 0 8 L 44 -26" fill="none" stroke="#7a5a25" stroke-width="0.8"/>
</g>
<!-- Wax seal -->
<g transform="rotate(-4)">
<ellipse cx="0" cy="14" rx="16" ry="13.5" fill="url(#logoWax)"/>
<circle cx="0" cy="14" r="11" fill="none" stroke="#5a0808" stroke-width="0.5" opacity="0.6"/>
<text x="0" y="19" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="bold" fill="#f4d27a" opacity="0.95">N</text>
<ellipse cx="-3.5" cy="9" rx="5.5" ry="2.5" fill="url(#logoWaxShine)"/>
</g>
</g>
<!-- ===== Wordmark ===== -->
<g transform="translate(205 0)">
<!-- Primary wordmark "nuntius" -->
<text x="0" y="108"
font-family="Georgia, 'Times New Roman', 'Liberation Serif', serif"
font-size="82" font-weight="500" fill="#122a55"
letter-spacing="1.5">nuntius</text>
<!-- Decorative gold underline -->
<line x1="3" y1="125" x2="320" y2="125" stroke="#c89c4a" stroke-width="1" opacity="0.75"/>
<circle cx="3" cy="125" r="2" fill="#c89c4a"/>
<circle cx="320" cy="125" r="2" fill="#c89c4a"/>
<!-- Tagline "messenger" -->
<text x="6" y="152"
font-family="Georgia, 'Times New Roman', 'Liberation Serif', serif"
font-size="18" font-weight="400" fill="#8a6520" font-style="italic"
letter-spacing="4">messenger</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+160
View File
@@ -0,0 +1,160 @@
//go:build linux || freebsd
// Command nuntius runs the contact form backend server.
//
// Configuration is loaded from a TOML file (default: /etc/nuntius/config.toml).
// The TOML file may reference environment variables for secrets using
// ${VAR_NAME} or $VAR_NAME syntax.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/petrbalvin/nuntius/internal/config"
"codeberg.org/petrbalvin/nuntius/internal/handler"
"codeberg.org/petrbalvin/nuntius/internal/version"
)
func main() {
showVersion := flag.Bool("version", false, "Print version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("%s version %s\n", version.Name, version.Version)
return
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfgPath := config.ConfigPath()
cfg, err := config.Load(cfgPath)
if err != nil {
logger.Error("config load failed", "path", cfgPath, "err", err)
os.Exit(1)
}
h := handler.New(cfg)
mux := http.NewServeMux()
h.Register(mux)
srv := &http.Server{
Addr: ":" + itoa(cfg.Server.Port),
Handler: withRequestLog(mux, logger),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
logger.Info("nuntius starting",
"version", version.Name+" "+version.Version,
"addr", srv.Addr,
"config", cfgPath,
"forms", len(cfg.Forms),
)
for _, f := range cfg.Forms {
logger.Info("form registered",
"name", f.Name,
"path", f.Path,
"to", f.To,
"smtp", f.SMTP.Host,
)
}
// Graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown error", "err", err)
os.Exit(1)
}
logger.Info("nuntius stopped cleanly")
}
// withRequestLog logs each HTTP request method, path, status, and duration.
func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.status,
"duration_ms", time.Since(start).Milliseconds(),
"ip", clientIP(r),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if i := last(host, ':'); i >= 0 {
return host[:i]
}
return host
}
func last(s string, c byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == c {
return i
}
}
return -1
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+271
View File
@@ -0,0 +1,271 @@
# HTTP API Reference
Every form defined in `config.toml` exposes the same three HTTP verbs on its configured `path`. The server also exposes one global endpoint (`GET /health`).
## Per-Form Endpoints
For each form in the config:
| Method | Behaviour |
|---|---|
| `POST` | Validate → honeypot → email send → (newsletter only) persist to disk |
| `OPTIONS` | CORS preflight. Returns `204 No Content` if the origin is allowed; `403` otherwise. |
| _other_ | `405 Method Not Allowed` (Go 1.22+ `http.ServeMux` semantics) |
### Request shape
All four form types accept a single JSON object as the request body. Required fields depend on the form's `type`:
| Type | Required | Optional |
|---|---|---|
| `contact` | `name`, `email`, `message` | `service`, `honeypot` |
| `feedback` | `name`, `email`, `message` | `honeypot` |
| `newsletter` | `email` | `honeypot` |
| `generic` | `name`, `email`, `message` | `honeypot` |
The `honeypot` field name in the request body is configured per form (`honeypot_field` in `config.toml`, default `"website"`). The handler reads it into `contactform.Request.Honeypot` (which is `json:"-"`, so it does not appear in the public Go API) and drops the request silently if it is non-empty.
#### Example: contact
```http
POST /api/nuntius/contact HTTP/1.1
Host: example.com
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"service": "architecture",
"message": "Hello, I would like to discuss an engagement."
}
```
#### Example: feedback
```http
POST /api/nuntius/feedback HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I love how the site uses Vue.js with zero runtime deps."
}
```
#### Example: newsletter
```http
POST /api/nuntius/newsletter HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{ "email": "jane@example.com" }
```
## Response Status Codes
| Status | Body | When |
|---|---|---|
| `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) |
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) |
| `204` | _empty_ | CORS preflight succeeded |
| `400` | `{"error": "invalid_json", "message": "..."}` | Body is not valid JSON |
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
| `405` | _empty_ | Method not allowed (e.g. `GET` on a form path) |
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
| `500` | `{"error": "send_failed", "message": "..."}` | SMTP round-trip failed |
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter` only — could not write the JSONL line |
## Response Bodies
### Success
```json
{ "ok": true }
```
### Validation error
```json
{
"error": "validation",
"details": [
{ "field": "name", "message": "name must be at least 2 characters" },
{ "field": "email", "message": "email is invalid" },
{ "field": "message", "message": "message must be at least 10 characters" }
]
}
```
`details[]` always has at least one entry when `error == "validation"`. Each entry has `field` and `message` strings. The `message` is human-readable and safe to surface in the UI.
### JSON parse error
```json
{ "error": "invalid_json", "message": "Could not parse JSON body." }
```
### CORS rejection
```json
{ "error": "origin_not_allowed" }
```
### Rate limit
```json
{ "error": "rate_limited", "message": "Too many requests, please try again later." }
```
### SMTP failure
```json
{ "error": "send_failed", "message": "Could not send email." }
```
### Storage failure (newsletter only)
```json
{ "error": "storage_failed", "message": "Could not record subscription." }
```
## CORS
CORS is enforced **per form**. The flow:
1. Browser sends a preflight `OPTIONS` request with `Origin` and `Access-Control-Request-Method`.
2. nuntius checks `Origin` against the form's `allowed_origins`.
3. If allowed, the response carries:
```http
Access-Control-Allow-Origin: <echoed origin>
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Vary: Origin
```
4. If not allowed, the response is `403` with `{"error": "origin_not_allowed"}`.
For the actual `POST`:
- An absent `Origin` header is allowed (the form is treated as a non-browser client). This makes `curl` work out of the box.
- A present `Origin` that is **not** in `allowed_origins` returns `403`.
- A present `Origin` that **is** in `allowed_origins` gets the same CORS headers as the preflight.
Wildcard `*` is not supported. List every origin explicitly.
## Rate Limiting
Each form has its own in-memory token bucket. The bucket size equals `rate_limit_per_hour` and refills at `perHour / 3600` tokens per second. The first request from a new IP creates a full bucket.
To disable rate limiting on a form, set `rate_limit_per_hour: 0` explicitly. There is no global safety net — use with care.
Rate 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`.
The IP used for rate limiting is taken from the same precedence chain as the log line:
1. `X-Forwarded-For` (first hop, before any comma)
2. `X-Real-IP`
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.
## Health Check
### `GET /health`
```http
GET /health HTTP/1.1
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "status": "ok", "forms": 3 }
```
| Field | Type | Description |
|---|---|---|
| `status` | string | Always `"ok"` while the process is up. There is no deep health check; the endpoint is suitable for a load balancer that wants to know "is the listener alive". |
| `forms` | int | Number of forms loaded from `config.toml` at startup. |
This endpoint is not rate-limited and is not CORS-checked.
## Curl Recipes
### POST a contact form
```bash
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."
}'
```
### POST a feedback form
```bash
curl -X POST http://localhost:8080/api/nuntius/feedback \
-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."
}'
```
### POST a newsletter signup
```bash
curl -X POST http://localhost:8080/api/nuntius/newsletter \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{ "email": "jane@example.com" }'
```
### Trigger the honeypot (bot)
```bash
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-d '{
"name": "Bot",
"email": "bot@spam.example",
"message": "buy cheap viagra click here",
"website": "https://spam.example"
}'
# → 200 {"ok": true}, no email sent, no log line about message sent
```
### CORS preflight
```bash
curl -i -X OPTIONS http://localhost:8080/api/nuntius/contact \
-H "Origin: https://petrbalvin.org" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
```
### Health check
```bash
curl -s http://localhost:8080/health | jq .
# → { "status": "ok", "forms": 3 }
```
## Notes
- nuntius does not currently expose per-form metrics, request counters, or Prometheus endpoints. A future release may add `GET /metrics`; for now, count rows in the JSONL log and grep `journalctl -u nuntius` for request lines.
- All request bodies are bounded by `ReadTimeout` (15 s) and `WriteTimeout` (30 s). The full request body is read into memory by `json.NewDecoder(r.Body).Decode(&req)`; for a 5,000-rune message this is negligible.
- Logs are emitted to stdout in JSON form via `log/slog`. One line per request (`method`, `path`, `status`, `duration_ms`, `ip`) plus one line per sent / failed message and one line per honeypot hit. There is no PII in the log line beyond the client IP.
+239
View File
@@ -0,0 +1,239 @@
# Architecture
**nuntius** is a small HTTP service that accepts JSON form submissions, validates them, sends them via SMTP, and (for `newsletter` forms) appends the email to a JSONL log on disk. This document explains the layers, the request flow, and the design decisions that keep the binary ~6 MB and the dependency graph empty.
## High-Level Overview
```mermaid
graph TD
Browser[Browser / Frontend]:::accent6
Nginx[nginx reverse proxy]:::accent6
Server[cmd/server/main.go]:::accent0
Config[internal/config/]:::accent7
Handler[internal/handler/]:::accent0
RateLimit[rateLimiter per form]:::accent7
CORS[CORS allowlist per form]:::accent7
Honeypot[honeypot_field per form]:::accent7
Validate[pkg/contactform/Validate]:::accent3
Sender[internal/email/FormSender]:::accent1
Storage[internal/storage/NewsletterStore]:::accent1
SMTP[(SMTP server)]:::accent2
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
Browser -->|POST + Origin| Nginx
Nginx -->|X-Forwarded-For| Server
Server --> Config
Server --> Handler
Handler --> RateLimit
Handler --> CORS
Handler --> Honeypot
Handler --> Validate
Handler --> Sender
Handler --> Storage
Sender -->|SMTP PLAIN over net/smtp| SMTP
Storage -->|append JSONL| JSONL
Handler -->|200 / 4xx / 5xx| Nginx
Nginx --> 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
```
## Layers
### 1. Entry point — `cmd/server/main.go`
- Wires up a JSON `slog` handler on stdout.
- Loads the config from `config.ConfigPath()` (default `/etc/nuntius/config.toml`).
- Builds a `*handler.ContactHandler` via `handler.New(cfg)`.
- Registers one `POST` + one `OPTIONS` handler per form on a fresh `http.ServeMux`, plus `GET /health`.
- Wraps the mux in a request-logging middleware that emits a single `slog.Info` per request with method, path, status, duration, and client IP.
- Starts an `http.Server` with explicit read / write / idle timeouts.
- Installs a `signal.NotifyContext` listener for SIGINT / SIGTERM and shuts the server down gracefully within 15 s.
The main package owns **no business logic** — it only orchestrates the four internal packages.
### 2. Configuration — `internal/config/`
The config package is the gatekeeper for everything that varies per deployment.
| Concern | Where |
|---|---|
| File path | `config.ConfigPath()` returns `/etc/nuntius/config.toml` |
| Default file | `defaultConfig` is a `[]byte` constant embedded in the source; written on first start |
| Env-var expansion | `os.Expand(string(raw), os.Getenv)` runs **before** TOML is parsed by `interpres` |
| Strict decoding | `interpres.NewDecoder(...).DisallowUnknownFields()` rejects typos |
| Schema validation | `(*Config).validate()` enforces required fields, unique paths, valid form types, sensible defaults |
| Defaults | `port = 8080`, `data_dir = "./data"`, `rate_limit_per_hour = 10`, `honeypot_field = "website"`, `from = smtp.user` |
The `Form` type mirrors the TOML shape one-to-one. `ValidFormTypes` is the authoritative allow-list — adding a form type means editing one map in this package.
### 3. HTTP handler — `internal/handler/`
`ContactHandler` is a single struct with four maps keyed by URL path:
```go
type ContactHandler struct {
forms map[string]*config.Form
senders map[string]*email.FormSender
rateLimits map[string]*rateLimiter
stores map[string]*storage.NewsletterStore
}
```
`New(cfg)` builds all four maps in one pass. `Register(mux)` then mounts `POST <path>` and `OPTIONS <path>` per form, plus `GET /health`.
The per-form handler closure (`makeHandler`) runs this pipeline in order:
```mermaid
sequenceDiagram
participant Client
participant Handler as ContactHandler
participant CORS
participant Limiter as rateLimiter
participant Body
participant Honey as Honeypot
participant Validate as contactform.Validate
participant Sender as email.FormSender
participant Store as storage.NewsletterStore
Client->>Handler: POST /api/nuntius/contact
alt preflight
Handler->>CORS: formAllowed(origin)?
CORS-->>Handler: yes / no
Handler-->>Client: 204 No Content (or 403)
else actual
Handler->>CORS: formAllowed(origin)?
CORS-->>Handler: yes / no
alt origin present and not allowed
Handler-->>Client: 403 origin_not_allowed
end
Handler->>Limiter: allow(ip)
alt over limit
Handler-->>Client: 429 rate_limited
end
Handler->>Body: json.Decode
alt invalid JSON
Handler-->>Client: 400 invalid_json
end
Handler->>Honey: honeypot field non-empty?
alt bot
Handler-->>Client: 200 ok (silent)
end
Handler->>Validate: contactform.Validate(req, form.Type)
alt errors
Handler-->>Client: 400 validation + details
end
Handler->>Sender: Send(req)
alt SMTP error
Handler-->>Client: 500 send_failed
end
opt form.Type == "newsletter"
Handler->>Store: Append(Subscriber{...})
alt write error
Handler-->>Client: 500 storage_failed
end
end
Handler-->>Client: 200 ok
end
```
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.
- **Client IP honours reverse proxies** — `clientIP` reads `X-Forwarded-For` (first hop), then `X-Real-IP`, then falls back to `RemoteAddr`. Strip the port by hand because the stdlib does not give us a `netip.AddrPort` for the remote.
- **Rate limiting is in-process** — `rateLimiter` is a per-IP token bucket. The bucket refills at `perHour/3600` tokens per second. The map is guarded by a single `sync.Mutex`; in practice the lock is held for microseconds and the access pattern is dominated by `Allow` lookups, not insert / delete.
- **Honeypot is a positive control** — A bot that fills the invisible field gets a 200 with no email sent and no log line. Humans are never blocked.
- **Validation is the same function the public library uses** — `contactform.Validate` is the only validator in the codebase; the handler just calls it with `form.Type` and forwards the returned `[]FieldError` to the client.
### 4. Email — `internal/email/`
Each form gets a `FormSender` constructed once in `handler.New`. `Send(req)` is the only public method:
1. `smtp.PlainAuth("", user, password, host)` — the empty identity means "use the supplied user as-is", which is what every modern SMTP provider expects.
2. `compose(from, to, req, formName, formType)` builds a `multipart/alternative` message (text/plain + text/html).
3. `smtp.SendMail(addr, auth, from, []string{to}, msg)`.
`compose` picks one of four HTML templates (`contact`, `feedback`, `newsletter`, `generic`) and four matching plain-text subjects. The subject for `contact` includes the `[<service>]` tag if `req.Service` is set, so inbox filters can route by service interest.
Credentials are never logged — neither the password nor the full `From` / `To` headers appear in any `slog` line. The handler logs `form`, `path`, `service` (if any), and `ip` only.
### 5. Storage — `internal/storage/`
`NewsletterStore` is a thin wrapper over an append-only JSONL file:
| Method | Semantics |
|---|---|
| `Append(Subscriber)` | Creates the parent dir if missing, opens the file with `O_APPEND \| O_CREATE \| O_WRONLY`, writes one JSON line + `\n`, closes. Sets `CreatedAt = time.Now().UTC()` if zero. |
| `Count()` | Streams the file with a `bufio.Scanner` (1 MB max line), increments on each line that unmarshals into a non-empty `Subscriber`. Malformed lines are skipped. |
| `List()` | Same scan, returns all valid subscribers in insertion order. Reads the entire log into memory — do not call on multi-million-row files. |
| `Path()` | Returns the configured file path. |
A single `sync.Mutex` guards all three methods. The store is safe for concurrent use from the handler goroutines.
There is **no rotation**. Add a `logrotate` unit if the file grows beyond what you want to `wc -l`.
## Request Flow (single form)
```mermaid
sequenceDiagram
participant Browser
participant Nginx
participant Nuntius as nuntius
participant SMTP
participant Disk
Browser->>Nginx: POST /api/nuntius/contact
Nginx->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
Nuntius->>Nuntius: CORS check
Nuntius->>Nuntius: rate-limit per IP
Nuntius->>Nuntius: parse JSON
Nuntius->>Nuntius: honeypot check
Nuntius->>Nuntius: contactform.Validate
Nuntius->>SMTP: SMTP PLAIN auth + multipart message
SMTP-->>Nuntius: 250 OK
opt newsletter form
Nuntius->>Disk: append one JSONL line
end
Nuntius-->>Nginx: 200 {"ok": true}
Nginx-->>Browser: 200 {"ok": true}
```
## Config Merging
There is no merging in nuntius — the TOML file is the only source of runtime configuration. The file itself goes through three stages at startup:
1. **Default generation** — If the file does not exist, `os.WriteFile(path, defaultConfig, 0644)` writes the embedded three-form template.
2. **Env-var expansion**`os.Expand` substitutes `${VAR}` and `$VAR` references **in the raw text** before parsing. The parsed struct never holds a literal like `"${NUNTIUS_SMTP_PASSWORD}"` — it holds the expanded value (or an empty string if the env var was unset).
3. **Strict parsing + validation**`interpres.NewDecoder(...).DisallowUnknownFields()` rejects typos; `validate()` enforces required fields, unique paths, valid form types, and applies defaults.
The order matters: expansion before parsing means env-var references work even for values that are not strings (e.g. an integer with a `${PORT}` reference would fail, but you can still reference strings).
## Error Handling
Errors are returned as a typed string in the JSON body, with the HTTP status code doing the heavy lifting:
| Status | Body shape | When |
|---|---|---|
| `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) |
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail) |
| `400` | `{"error": "invalid_json", "message": "..."}` | Body is not valid JSON |
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
| `500` | `{"error": "send_failed", "message": "..."}` | SMTP round-trip failed |
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter` only — could not write the JSONL line |
The `message` field is intentionally generic; field-level details for validation errors live in `details[]`. None of the error bodies leak SMTP credentials, file paths, or stack traces.
## Why These Choices
- **stdlib only** — `net/smtp`, `net/http`, `net/mail`, `log/slog`, `bufio`, `os/signal`, `sync` plus the first-party `interpres` TOML library are all that is needed. The build is reproducible, the binary is small, and the external supply chain is empty.
- **No global state outside the binary** — `ContactHandler` owns the maps; `slog` is the only package-level default. Everything else is constructed explicitly in `New(cfg)` so the handler is easy to instantiate from tests.
- **Append-only JSONL** — Cheaper to implement than SQLite, easier to inspect than a custom binary format, easy to back up, and resilient to partial writes.
- **Per-form isolation** — One form's SMTP credentials never travel through another form's code path. A misconfigured `contact` form cannot leak credentials from a `newsletter` form.
- **Strict config parsing** — Catching `forms[0].smtp.port = "587"` (string) or `forms[0].type = "contatc"` (typo) at startup is worth the small extra error-path code.
+191
View File
@@ -0,0 +1,191 @@
# Configuration Reference
All runtime configuration for nuntius lives in a single TOML file. The default path is `/etc/nuntius/config.toml`; if the file does not exist when the server starts, nuntius writes a three-form template there. The parser rejects unknown fields and unknown form types so typos fail loudly at startup instead of silently disabling a form.
## Quick Reference
```toml
data_dir = "./data"
[server]
port = 8080
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
```
## Top-Level Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `server` | table | | `{"port": 8080}` | Server-wide settings |
| `server.port` | int | | `8080` | HTTP listen port |
| `data_dir` | string | | `"./data"` | Directory for newsletter JSONL logs; created on first write |
| `forms` | array | ✅ | — | One or more form definitions (must contain at least one) |
> **Note:** Path resolution for `data_dir` is relative to the process's current working directory. The systemd unit installed in `docs/deployment.md` sets `WorkingDirectory=/var/lib/nuntius`, so the default `data_dir: "./data"` resolves to `/var/lib/nuntius/data/` in production.
## Form Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `name` | string | ✅ | — | Internal identifier. Appears in logs and in `data_dir/newsletter-<name>.jsonl`. |
| `path` | string | ✅ | — | HTTP path the form is served on (e.g. `/api/nuntius/contact`). Must start with `/`. Must be unique across all forms. |
| `type` | string | | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`. See [Form types](#form-types). |
| `smtp` | table | ✅ | — | SMTP connection settings. See [SMTP fields](#smtp-fields). |
| `to` | string | ✅ | — | Recipient address (`To:` header). |
| `from` | string | | `smtp.user` | Sender address (`From:` header). |
| `rate_limit_per_hour` | int | | `10` | Submissions per IP per hour. `0` disables. |
| `honeypot_field` | string | | `"website"` | Name of the invisible form field. Empty string disables the honeypot. |
| `allowed_origins` | []string | | `[]` | CORS allowlist. One entry per origin (e.g. `https://example.com`). |
## SMTP Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `smtp.host` | string | ✅ | — | SMTP server hostname |
| `smtp.port` | int | ✅ | — | SMTP port; `587` for STARTTLS, `465` for implicit TLS (Go's `net/smtp` does both via `SendMail`) |
| `smtp.user` | string | ✅ | — | SMTP auth user (typically the `from` address) |
| `smtp.password` | string | ✅ | — | SMTP auth password. See [Secrets](#secrets-via-environment-variables). |
## Form Types
| Type | Required request fields | Email subject | Notes |
|---|---|---|---|
| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/<name>] Contact form submission`, or `[nuntius/<name>][<service>] ...` if `service` is set | The default. `service` is restricted to an allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`. |
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. |
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | Also writes the email to disk as a JSONL line in `data_dir/newsletter-<name>.jsonl`. No double opt-in. |
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | Escape hatch for one-off forms that do not fit the other three. |
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 mail.
### Service allow-list (for `contact`)
The `service` field on `contact` is restricted to a fixed list (defined in `pkg/contactform/validate.go`):
| Value | Use |
|---|---|
| `architecture` | Architecture / system design |
| `ai` | AI / ML engagements |
| `infrastructure` | Infrastructure / DevOps / SRE |
| `software` | Software engineering |
| `unix` | Unix / Linux tooling |
| `other` | Anything else |
An empty string is also valid (skip the field). Anything else triggers a `400 validation` error with `details[0].field = "service"`. To add a new value, edit `validServices` in `pkg/contactform/validate.go` and update this table.
## Validation Rules
The `pkg/contactform.Validate` function enforces the following limits (in runes, not bytes):
| Field | Min | Max | Validator |
|---|:---:|:---:|---|
| `name` | 2 | 100 | `utf8.RuneCountInString` after `strings.TrimSpace` |
| `email` | — | — | `net/mail.ParseAddress` (RFC 5322) after `strings.TrimSpace` |
| `service` | — | — | allow-list (see above) |
| `message` | 10 | 5,000 | `utf8.RuneCountInString` after `strings.TrimSpace` |
Whitespace is trimmed from `name`, `email`, `service`, and `message` before validation. The trimmed values are what the email body sees.
## Newsletter Subscribers
For every accepted `newsletter` submission, nuntius appends one JSON line to `data_dir/newsletter-<name>.jsonl`:
```json
{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}
```
| Field | Description |
|---|---|
| `email` | Trimmed, lowercased-on-output by your downstream tool — nuntius does not normalize |
| `ip` | Client IP as seen by nuntius (honours `X-Forwarded-For` / `X-Real-IP`) |
| `form` | The `name` of the form the signup hit |
| `created_at` | UTC timestamp set by nuntius at `Append` time |
The log is **append-only** and **survives restarts**. It is **not rotated automatically** — add a `logrotate` unit (or equivalent) if it grows. Malformed lines are skipped on read, so a partial write can never brick the file.
Quick shell recipes:
```bash
# Count subscribers
wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl
# Extract just the emails
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl
# Extract unique emails sorted
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u
# Signups per day
jq -r .created_at[:10] /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort | uniq -c
```
## Secrets via Environment Variables
The TOML file supports `${VAR_NAME}` and `$VAR_NAME` expansion **before parsing**, so secrets can stay out of the file. Example:
```toml
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
```
The `NUNTIUS_SMTP_PASSWORD` env var is read at startup and substituted into the TOML before it is parsed. If the env var is unset or empty, an empty string is substituted — so make sure the variable is set in the process environment (systemd `EnvironmentFile=`, container secrets, etc.). SMTP credentials are never logged.
Recommended production setup:
```bash
# /etc/nuntius/.env (mode 0600, owner root:nuntius)
NUNTIUS_SMTP_PASSWORD=actual-app-password
```
```ini
# /etc/systemd/system/nuntius.service
[Service]
EnvironmentFile=/etc/nuntius/.env
```
The `install.sh` helper script sets the file mode automatically.
## Defaults at a Glance
When a key is missing from the TOML, nuntius applies the following defaults:
| Key | Default | Source |
|---|---|---|
| `server.port` | `8080` | `(*Config).validate()` |
| `data_dir` | `"./data"` | `(*Config).validate()` |
| `forms[i].type` | `"contact"` | `(*Config).validate()` |
| `forms[i].from` | `forms[i].smtp.user` | `(*Config).validate()` |
| `forms[i].rate_limit_per_hour` | `10` | `(*Config).validate()` |
| `forms[i].honeypot_field` | `"website"` | `(*Config).validate()` |
Setting `rate_limit_per_hour` to `0` explicitly disables rate limiting for that form (use with care — there is no global safety net).
## Validation Errors at Startup
If the config file is malformed, nuntius exits 1 and writes a single JSON line to stderr. Examples:
```text
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"parse /etc/nuntius/config.toml: interpres: unknown field \"smtp_ost\""}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": type \"contatc\" is not supported (must be one of: contact, feedback, newsletter, generic)"}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": path must start with /"}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"feedback\": duplicate path \"/api/nuntius/contact\" (also used by \"contact\")"}
```
Fix the reported line and re-run; the server will not start with a broken config.
+254
View File
@@ -0,0 +1,254 @@
# 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.
## Architecture in Production
```mermaid
graph LR
Browser[Visitor's browser]:::accent6
Internet((Internet)):::accent2
Nginx[nginx :443]:::accent0
Nuntius[nuntius :8080]:::accent1
Systemd[systemd unit]:::accent7
SMTP[(SMTP provider)]:::accent2
Disk[(/var/lib/nuntius/data/*.jsonl)]:::accent2
Browser --> Internet
Internet --> Nginx
Nginx -->|proxy_pass /api/nuntius/| Nuntius
Systemd -.->|manages| Nuntius
Nuntius -->|SMTP PLAIN| SMTP
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.
## 1. Build and Upload (Local Machine)
```bash
cd nuntius
just build
# → ./bin/nuntius
rsync -avz \
bin/nuntius \
.env.example \
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.
## 2. Install on the Server (SSH Session)
### Option A — Manual (the README quick start)
```bash
ssh user@your-server
# Create the nuntius system user (one time)
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/nuntius nuntius
sudo mkdir -p /var/lib/nuntius
sudo chown nuntius:nuntius /var/lib/nuntius
# Install the binary
sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius
# First run auto-creates /etc/nuntius/config.toml with the 3-form template
sudo /usr/local/bin/nuntius &
sleep 2
sudo kill %1
# Install the systemd service (created inline from this guide)
sudo tee /etc/systemd/system/nuntius.service > /dev/null <<'EOF'
[Unit]
Description=nuntius contact form backend
After=network.target
[Service]
Type=simple
User=nuntius
Group=nuntius
WorkingDirectory=/var/lib/nuntius
EnvironmentFile=/etc/nuntius/.env
ExecStart=/usr/local/bin/nuntius
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now nuntius
# Set up secrets
sudo cp /tmp/nuntius/.env.example /etc/nuntius/.env
sudo chmod 600 /etc/nuntius/.env
sudo chown root:nuntius /etc/nuntius/.env
sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD
# Edit the auto-generated config to your real SMTP + allowed origins
sudo $EDITOR /etc/nuntius/config.toml
# Restart and watch logs
sudo systemctl restart nuntius
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.
### Option B — `install.sh` (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):
```bash
ssh user@your-server
cd /tmp/nuntius
# Paste the [Unit]…[Install] block from Option A into this file:
sudo $EDITOR /tmp/nuntius/nuntius.service
sudo bash install.sh
```
The script is idempotent: re-running on an already-installed host is safe. It:
1. Creates the `nuntius` system user if missing.
2. Creates `/var/lib/nuntius` with the right ownership.
3. Installs the binary to `/usr/local/bin/nuntius`.
4. Installs the systemd unit to `/etc/systemd/system/nuntius.service`.
5. Runs nuntius briefly to auto-generate `/etc/nuntius/config.toml`.
6. Copies `.env.example` to `/etc/nuntius/.env` (mode 0600) if missing.
7. Reloads systemd and `enable`s the service — but does **not** start it, so you can edit `/etc/nuntius/.env` and `/etc/nuntius/config.toml` first.
The script ends with the exact next steps you need to run by hand.
## 3. nginx Reverse Proxy
Add this location block to the same nginx server that already serves your site (most likely `/etc/nginx/sites-available/petrbalvin.org`):
```nginx
location /api/nuntius/ {
proxy_pass http://127.0.0.1:8080/api/nuntius/;
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:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
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.
## 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:
```bash
# .env (in the web project, only needed if NOT proxying through nginx)
VITE_NUNTIUS_URL=https://your-server:8080
```
For a feedback or newsletter endpoint on the same site, just call the matching path:
```js
await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
});
await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
```
CORS is enforced per form by the `allowed_origins` list in `config.toml`. Add every frontend origin that should be able to post to each form.
## 5. Updating Later
```bash
cd nuntius
just build
rsync -avz bin/nuntius user@your-server:/tmp/nuntius/
ssh user@your-server 'sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius && sudo systemctl restart nuntius'
```
For schema changes, also ship the new `config.toml` and a migration note in your changelog.
## 6. Updating the Config
The service does **not** auto-reload `config.toml`. After editing it, restart:
```bash
sudo $EDITOR /etc/nuntius/config.toml
sudo systemctl restart nuntius
sudo journalctl -u nuntius -f
```
The process exits 1 on a broken config (unknown field, unknown form type, duplicate path, missing SMTP host, …) and systemd will not keep it up. Fix the reported line and retry.
## Files This Guide Creates on the Server
| File | Owner | Mode | Purpose |
|---|---|---|---|
| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary |
| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory + data dir |
| `/var/lib/nuntius/data/` | `nuntius` | `0750` | Newsletter JSONL logs (created at runtime) |
| `/etc/nuntius/config.toml` | `root` | `0644` | nuntius config (auto-generated, then edited) |
| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets (SMTP password) |
| `/etc/systemd/system/nuntius.service` | `root` | `0644` | systemd unit |
`/var/lib/nuntius/data/newsletter-*.jsonl` is created on the first `newsletter` form submission, by the `nuntius` user.
## Verifying the Deploy
After `systemctl restart nuntius`, check:
```bash
# Service is up
systemctl status nuntius
# Binary version
/usr/local/bin/nuntius --version
# Listener is bound
ss -tlnp | grep 8080
# Health endpoint responds
curl -s http://127.0.0.1:8080/health | jq .
# Recent log lines
journalctl -u nuntius -n 50 --no-pager
# nginx proxy works
curl -i https://your-domain.example/api/nuntius/health
# End-to-end POST through nginx
curl -X POST https://your-domain.example/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://your-domain.example" \
-d '{"name":"Test","email":"test@example.com","message":"hello there, this is a test message"}'
```
If the end-to-end POST returns `{"ok": true}` and `journalctl -u nuntius -f` shows a `request` line with `status=200`, the deploy is live.
## Backing Up
- `/etc/nuntius/config.toml` and `/etc/nuntius/.env` — back up alongside your other config files.
- `/var/lib/nuntius/data/*.jsonl` — back up the JSONL log with your usual file backup job. It is append-only and trivially `tar`'able.
## Uninstalling
```bash
sudo systemctl disable --now nuntius
sudo rm /etc/systemd/system/nuntius.service /usr/local/bin/nuntius
sudo rm -rf /etc/nuntius /var/lib/nuntius
sudo userdel nuntius
```
+231
View File
@@ -0,0 +1,231 @@
# Library Usage — `pkg/contactform`
`pkg/contactform` is the public, reusable Go package that the nuntius HTTP server uses for validation. It has no third-party dependencies and no awareness of HTTP, SMTP, or the filesystem — drop it into any Go project that needs to validate a contact-style form payload.
## Installation
```bash
go get codeberg.org/petrbalvin/nuntius/pkg/contactform
```
Because nuntius ships with no `go.sum` entries from third-party modules, this import pulls in only stdlib transitively.
## Types
### `Request`
The JSON body sent to the contact endpoint.
```go
type Request struct {
Name string `json:"name"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
Honeypot string `json:"-"`
}
```
| Field | JSON tag | Notes |
|---|---|---|
| `Name` | `name` | Required for `contact`, `feedback`, `generic` |
| `Email` | `email` | Required for all four types |
| `Service` | `service,omitempty` | Optional. Validated against an allow-list for `contact` only. |
| `Message` | `message` | Required for `contact`, `feedback`, `generic` |
| `Honeypot` | `-` | Never marshalled to / from JSON. Read it from a separate field in your own request struct, or wire it through your favourite JSON tag. |
The handler-side mapping (used inside the nuntius server) reads the honeypot value into `Request.Honeypot` based on the form's `honeypot_field` config. If you embed `contactform.Request` directly in your own API, you are responsible for reading the honeypot field separately.
### `Response`
```go
type Response struct {
OK bool `json:"ok"`
}
```
A successful submission returns `{"ok": true}`.
### `FieldError`
```go
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
```
One entry in the `details[]` array of a `validation` error.
### `ErrorResponse`
```go
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details []FieldError `json:"details,omitempty"`
}
```
Returned for any non-2xx response. The `Error` field is a short, machine-readable code (`validation`, `invalid_json`, `origin_not_allowed`, `rate_limited`, `send_failed`, `storage_failed`). `Message` is a human-readable summary; `Details` carries the per-field validation failures.
## Functions
### `Validate`
```go
func Validate(r *Request, formType string) []FieldError
```
Dispatches to the right per-type validator. Returns a slice of field errors; an empty (or nil) slice means the request is valid.
| `formType` | What is checked |
|---|---|
| `"contact"` | `name` (2100 runes), `email` (RFC 5322), `service` (allow-list), `message` (105,000 runes) |
| `"feedback"` | `name`, `email`, `message` (no `service`) |
| `"newsletter"` | `email` only |
| `"generic"` | `name`, `email`, `message` (no `service`) |
| `""` (empty) | Falls back to `contact` |
| anything else | Falls back to `contact` — the strict allow-list is enforced at the config layer, not here |
Whitespace is trimmed from `name`, `email`, `service`, and `message` **before** validation. The trimmed values are what the validator sees and what the email body sees.
### Constants
| Name | Value | Used by |
|---|---|---|
| `MinNameRunes` | `2` | `name` minimum length |
| `MaxNameRunes` | `100` | `name` maximum length |
| `MinMessageRunes` | `10` | `message` minimum length |
| `MaxMessageRunes` | `5000` | `message` maximum length |
## Standalone Example
The `examples/minimal/` directory contains a runnable demo. From the repo root:
```bash
go run ./examples/minimal
```
Source (`examples/minimal/main.go`):
```go
// Minimal example showing how to use the contactform package
// standalone (without the HTTP server).
package main
import (
"fmt"
"os"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
func main() {
req := &contactform.Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hi, I'd like to discuss a possible engagement.",
}
if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
fmt.Fprintln(os.Stderr, "validation failed:")
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.Field, e.Message)
}
os.Exit(1)
}
fmt.Println("Request is valid:")
fmt.Printf(" name: %s\n", req.Name)
fmt.Printf(" email: %s\n", req.Email)
fmt.Printf(" service: %s\n", req.Service)
fmt.Printf(" message: %s\n", req.Message)
}
```
## Custom Validators
If you need to extend the allow-list of `service` values, edit `validServices` in `pkg/contactform/validate.go`:
```go
var validServices = map[string]bool{
"": true,
"architecture": true,
"ai": true,
"infrastructure": true,
"software": true,
"unix": true,
"other": true,
// add yours here, e.g.:
// "security": true,
}
```
If you need different length limits, edit the four `Min*` / `Max*` constants in the same file. Or fork the package — it is intentionally small (~150 LOC) so a copy-into-your-project is also reasonable.
## Using `Validate` From Your Own HTTP Handler
```go
package main
import (
"encoding/json"
"log"
"net/http"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
func contactHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
var req contactform.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{
Error: "invalid_json",
Message: "Could not parse JSON body.",
})
return
}
if errs := contactform.Validate(&req, "contact"); len(errs) > 0 {
writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{
Error: "validation",
Details: errs,
})
return
}
// ... your SMTP / persistence / webhook code here ...
writeJSON(w, http.StatusOK, contactform.Response{OK: true})
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func main() {
http.HandleFunc("/api/contact", contactHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
The example deliberately omits CORS, rate limiting, and the honeypot — copy those from `internal/handler/contact.go` if you need them.
## Why a Separate Package?
`pkg/contactform` is the one piece of nuntius that is **not** tied to HTTP, SMTP, or the filesystem. Keeping it in a separate, dependency-free package:
- Makes it trivially embeddable in any Go service.
- Makes it cheap to unit-test (see `pkg/contactform/validate_test.go`).
- Makes the public API obvious — there is one entry point (`Validate`) and four types, all in one file pair.
- Keeps `internal/handler` focused on HTTP, `internal/email` on SMTP, and `internal/storage` on disk.
+142
View File
@@ -0,0 +1,142 @@
# Security
nuntius is a public-facing HTTP service that accepts untrusted input and turns it into outbound email and on-disk records. This document covers the threats it faces, the defenses built in, and the things you, the operator, are responsible for.
## Threat Model
nuntius is designed to defend against:
| Threat | Mitigation |
|---|---|
| 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) |
| 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 |
| SMTP credential leakage in logs | `log/slog` never logs the `From` / `Authorization` / password; the password only lives in `FormSender` |
| Process crash on disk full | Append-only JSONL opens with `O_APPEND`; partial writes cannot corrupt earlier lines |
| Partial write bricking the subscriber log | `Count` and `List` skip malformed lines |
| Config typos silently disabling a form | `interpres.NewDecoder(...).DisallowUnknownFields()` + per-type allow-list at startup |
| Long-running abusive connections | `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout` on the server |
| Process hanging on shutdown | 15 s graceful shutdown via `signal.NotifyContext` + `srv.Shutdown` |
nuntius is **not** designed to defend against:
- A determined attacker who can submit a valid request from a rotating pool of IPs and bypass the per-IP rate limit. nuntius has no CAPTCHA, no proof-of-work, no IP reputation service, and no global rate limit. If you need those, put a reverse proxy with Cloudflare Turnstile / hCaptcha / reCAPTCHA in front of it.
- A compromised frontend that posts valid CORS-allowlisted traffic at high rate from a single IP. The token bucket handles that case.
- An attacker who has root on the server. nuntius reads the SMTP password from the process environment; anyone with `ps` access to the nuntius PID can read it from `/proc/<pid>/environ`. Use `systemd` `EnvironmentFile=` with mode `0600` to keep the file readable only by `root:nuntius`.
- An attacker who can MITM the SMTP connection. Use a provider that supports STARTTLS on port `587` (Go's `net/smtp.SendMail` does STARTTLS automatically when the server advertises it) or implicit TLS on port `465` via a small wrapper.
## Input Validation
All four form types are validated by `pkg/contactform.Validate`, which enforces:
- `name` — 2100 runes after `strings.TrimSpace`
- `email` — RFC 5322 via `net/mail.ParseAddress` after `strings.TrimSpace`
- `service` (contact only) — restricted to a fixed allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`, or empty
- `message` — 105,000 runes after `strings.TrimSpace`
Validation is server-side and runs on every request, regardless of what the client claims it has already checked. Field-level errors are returned as `details[]` so the UI can highlight individual inputs.
## Honeypot
Each form has a `honeypot_field` (default `"website"`) that should be hidden from humans via CSS (`display: none`, off-screen positioning, `tabindex="-1"`, `autocomplete="off"`). The form's HTML has a real-looking input for that name; humans never fill it; bots auto-fill every input they see.
If the field is non-empty, nuntius:
1. Logs a `honeypot triggered` line at `INFO` with `form`, `path`, and `ip`.
2. Responds `200 {"ok": true}` — the same as a legitimate submission.
3. Does **not** send mail.
4. Does **not** persist the subscriber.
This makes the bot think it succeeded. There is no way for an external observer to tell a honeypot-hit response from a real success without the server log.
To disable the honeypot on a specific form, set `honeypot_field: ""`. The form's HTML must then omit the hidden input.
## CORS
Per-form allowlist, configured in `config.toml`:
```toml
[[forms]]
name = "contact"
allowed_origins = ["https://petrbalvin.org", "https://www.petrbalvin.org"]
```
Rules:
- An absent `Origin` header is allowed (treats the client as `curl` / non-browser). This makes debugging easy without weakening anything in production.
- A present `Origin` is checked against the allowlist. If not present, the request is rejected with `403 origin_not_allowed`.
- Wildcards are not supported. List every origin explicitly.
- The preflight (`OPTIONS`) and the actual request go through the same allowlist check.
The response sets `Access-Control-Allow-Origin` to the echoed origin (not `*`) and includes `Vary: Origin` so downstream caches do not mix origins.
## Rate Limiting
Each form has its own per-IP token bucket. The bucket size is `rate_limit_per_hour` and the refill rate is `perHour / 3600` tokens per second. The bucket map is guarded by a `sync.Mutex` and is in-process.
The IP used is taken from:
1. `X-Forwarded-For` (first hop, before any comma)
2. `X-Real-IP`
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.
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`.
## 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.
Things nuntius does **not** do:
- It does not log the password, the `Authorization` header, or the raw SMTP `From` / `To` of any single message (only the form-level `to` from the config).
- It does not write the password to disk. `/etc/nuntius/.env` is the only place it is persisted.
- It does not echo the password in any error response.
- It does not log the full request body. `slog.Info` carries only `method`, `path`, `status`, `duration_ms`, and `ip`.
Things you should do:
- Make sure `/etc/nuntius/.env` is `0600` and owned `root:nuntius` (the `install.sh` 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.
- 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.
## Disk Storage
The newsletter JSONL log is:
- Append-only — nuntius only ever writes new lines and reads them back. There is no `UPDATE` / `DELETE` path.
- Crash-safe — `os.OpenFile(path, O_APPEND | O_CREATE | O_WRONLY, 0644)` plus a single `Write(line)`. A partial write leaves a malformed trailing line; `Count` and `List` skip malformed lines, so the rest of the log is still readable.
- Bounded — there is no upper bound; add a `logrotate` unit if it grows.
The systemd unit runs nuntius as user `nuntius` and points `WorkingDirectory` at `/var/lib/nuntius`. The data dir inherits that ownership.
## Timeouts
The `http.Server` is configured with:
| Timeout | Value | Purpose |
|---|---|---|
| `ReadHeaderTimeout` | 10 s | Limit Slowloris-style header reads |
| `ReadTimeout` | 15 s | Total request body read |
| `WriteTimeout` | 30 s | Total response write |
| `IdleTimeout` | 60 s | Keep-alive idle |
The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`).
## What nuntius Does Not (Yet) Do
- **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 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 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.
## Reporting a Vulnerability
Email **petrbalvin@yandex.com** with a description and a reproducer. Please do not file a public issue for security-sensitive reports.
+35
View File
@@ -0,0 +1,35 @@
//go:build linux || freebsd
// Minimal example showing how to use the contactform package
// standalone (without the HTTP server).
package main
import (
"fmt"
"os"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
func main() {
req := &contactform.Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hi, I'd like to discuss a possible engagement.",
}
if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
fmt.Fprintln(os.Stderr, "validation failed:")
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.Field, e.Message)
}
os.Exit(1)
}
fmt.Println("Request is valid:")
fmt.Printf(" name: %s\n", req.Name)
fmt.Printf(" email: %s\n", req.Email)
fmt.Printf(" service: %s\n", req.Service)
fmt.Printf(" message: %s\n", req.Message)
}
+5
View File
@@ -0,0 +1,5 @@
module codeberg.org/petrbalvin/nuntius
go 1.26
require codeberg.org/petrbalvin/interpres v1.0.0
+2
View File
@@ -0,0 +1,2 @@
codeberg.org/petrbalvin/interpres v1.0.0 h1:bnY6zSekKklss+w/95O/0GCO1col05pV4HE8JsVXQN4=
codeberg.org/petrbalvin/interpres v1.0.0/go.mod h1:sWgi5ijKNNLu5sCtjix9jG/guhdnDRlROENrFhrIt90=
Executable
+153
View File
@@ -0,0 +1,153 @@
#!/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 "$@"
+231
View File
@@ -0,0 +1,231 @@
//go:build linux || freebsd
// Package config loads nuntius configuration from a TOML file.
//
// Configuration supports env var expansion for secrets:
// 1. A TOML file (config.toml) — checked into the repo or provisioned
// per environment. Holds non-secret defaults and references to env vars.
// 2. Environment variables — used for secrets like SMTP passwords.
// Reference them in the TOML file as ${VAR_NAME} or $VAR_NAME.
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"codeberg.org/petrbalvin/interpres"
)
// ValidFormTypes lists the form types nuntius knows how to handle.
// Each form's `type` field is validated against this list in validate().
var ValidFormTypes = map[string]bool{
"contact": true,
"feedback": true,
"newsletter": true,
"generic": true,
}
// Config is the root configuration for nuntius.
type Config struct {
Server ServerConfig `toml:"server"`
Forms []Form `toml:"forms"`
DataDir string `toml:"data_dir"`
}
// ServerConfig holds server-wide settings.
type ServerConfig struct {
Port int `toml:"port"`
}
// Form is one contact / feedback / newsletter endpoint.
type Form struct {
Name string `toml:"name"`
Path string `toml:"path"`
Type string `toml:"type"`
SMTP SMTPConfig `toml:"smtp"`
To string `toml:"to"`
From string `toml:"from"`
RateLimitPerHour int `toml:"rate_limit_per_hour"`
HoneypotField string `toml:"honeypot_field"`
AllowedOrigins []string `toml:"allowed_origins"`
}
// SMTPConfig holds SMTP credentials and connection details.
type SMTPConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
User string `toml:"user"`
Password string `toml:"password"`
}
// Load reads, expands env vars in, and parses the TOML file at path.
// If the file does not exist, a default template is created first.
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
if err := os.WriteFile(path, defaultConfig, 0644); err != nil {
return nil, fmt.Errorf("write default config to %s: %w", path, err)
}
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
// Step 1: expand ${VAR_NAME} references in the raw text.
expanded := os.Expand(string(raw), os.Getenv)
// Step 2: parse TOML, rejecting unknown fields to catch typos early.
var cfg Config
if err := interpres.NewDecoder().DisallowUnknownFields().Decode([]byte(expanded), &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
// validate runs sanity checks on the loaded config and applies defaults.
func (c *Config) validate() error {
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.DataDir == "" {
c.DataDir = "./data"
}
if len(c.Forms) == 0 {
return fmt.Errorf("at least one form must be defined under `forms`")
}
paths := make(map[string]string, len(c.Forms))
for i := range c.Forms {
f := &c.Forms[i]
if f.Name == "" {
return fmt.Errorf("form #%d: name is required", i+1)
}
if f.Path == "" {
return fmt.Errorf("form %q: path is required", f.Name)
}
if f.Type == "" {
c.Forms[i].Type = "contact"
}
if !ValidFormTypes[c.Forms[i].Type] {
supported := make([]string, 0, len(ValidFormTypes))
for k := range ValidFormTypes {
supported = append(supported, k)
}
return fmt.Errorf("form %q: type %q is not supported (must be one of: %s)",
f.Name, c.Forms[i].Type, strings.Join(supported, ", "))
}
if !strings.HasPrefix(f.Path, "/") {
return fmt.Errorf("form %q: path must start with /", f.Name)
}
if existing, ok := paths[f.Path]; ok {
return fmt.Errorf("form %q: duplicate path %q (also used by %q)", f.Name, f.Path, existing)
}
paths[f.Path] = f.Name
if f.SMTP.Host == "" {
return fmt.Errorf("form %q: smtp.host is required", f.Name)
}
if f.SMTP.Port == 0 {
return fmt.Errorf("form %q: smtp.port is required", f.Name)
}
if f.SMTP.User == "" {
return fmt.Errorf("form %q: smtp.user is required", f.Name)
}
if f.To == "" {
return fmt.Errorf("form %q: `to` is required", f.Name)
}
if f.From == "" {
// Default From to the SMTP user.
c.Forms[i].From = f.SMTP.User
}
if f.RateLimitPerHour == 0 {
c.Forms[i].RateLimitPerHour = 10
}
if f.HoneypotField == "" {
c.Forms[i].HoneypotField = "website"
}
}
return nil
}
// ConfigPath returns the canonical path for nuntius configuration.
func ConfigPath() string {
return "/etc/nuntius/config.toml"
}
// AddrFor returns "host:port" for the given SMTP config.
func (s SMTPConfig) AddrFor() string {
return s.Host + ":" + strconv.Itoa(s.Port)
}
// defaultConfig is written to disk when no config exists yet.
var defaultConfig = []byte(`# nuntius configuration.
#
# Secrets (SMTP passwords) are referenced as ${VAR_NAME} and expanded from the
# environment at startup, so they never live in this file.
data_dir = "./data"
[server]
port = 8080
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
[[forms]]
name = "feedback"
type = "feedback"
path = "/api/nuntius/feedback"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
[[forms]]
name = "newsletter"
type = "newsletter"
path = "/api/nuntius/newsletter"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
`)
+94
View File
@@ -0,0 +1,94 @@
//go:build linux || freebsd
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadWritesAndParsesDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
// The first Load writes the default template, then parses it back.
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 8080 {
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
}
if cfg.DataDir != "./data" {
t.Errorf("DataDir = %q, want ./data", cfg.DataDir)
}
if len(cfg.Forms) != 3 {
t.Fatalf("len(Forms) = %d, want 3", len(cfg.Forms))
}
if cfg.Forms[0].Name != "contact" || cfg.Forms[0].SMTP.Host != "smtp.example.com" {
t.Errorf("Forms[0] = %#v", cfg.Forms[0])
}
if cfg.Forms[2].RateLimitPerHour != 100 {
t.Errorf("newsletter rate_limit_per_hour = %d, want 100", cfg.Forms[2].RateLimitPerHour)
}
}
func TestLoadExpandsEnvVars(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
doc := `
data_dir = "./data"
[server]
port = 9000
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "u@example.com"
password = "${NUNTIUS_TEST_PW}"
`
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("NUNTIUS_TEST_PW", "s3cret")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Forms[0].SMTP.Password != "s3cret" {
t.Errorf("password = %q, want s3cret", cfg.Forms[0].SMTP.Password)
}
}
func TestLoadRejectsUnknownField(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
doc := `
bogus = true
[server]
port = 8080
[[forms]]
name = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "u@example.com"
`
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("expected an error for an unknown field")
}
}
+292
View File
@@ -0,0 +1,292 @@
//go:build linux || freebsd
// Package email handles SMTP message composition and delivery.
package email
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"time"
"codeberg.org/petrbalvin/nuntius/internal/config"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
// FormSender delivers contact form submissions for a single form
// using its own SMTP credentials. Each form has its own FormSender
// so credentials are isolated per tenant.
type FormSender struct {
form *config.Form
}
// NewFormSender returns a new FormSender bound to the given form.
func NewFormSender(form *config.Form) *FormSender {
return &FormSender{form: form}
}
// Send composes and sends a multi-part email (plain text + HTML)
// for the given request. Returns an error if the SMTP round-trip fails.
func (s *FormSender) Send(req contactform.Request) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg := compose(s.form.From, s.form.To, req, s.form.Name, s.form.Type)
return smtp.SendMail(s.form.SMTP.AddrFor(), auth, s.form.From, []string{s.form.To}, msg)
}
// emailTemplateContact is the HTML body template for contact-type forms.
var emailTemplateContact = template.Must(template.New("contact").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.06)">
<!-- Header -->
<tr>
<td style="background-color:#1e40af;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:0.5px">
Contact Form Submission
</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#ffffff">
{{.FormName}}
</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:28px 28px 12px">
<!-- Submitter -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:20px">
<tr>
<td style="padding-bottom:8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">From</span>
<br>
<span style="font-size:15px;font-weight:600;color:#1f2937">{{.Name}}</span>
<span style="font-size:14px;color:#1e40af;margin-left:8px">{{.Email}}</span>
</td>
</tr>
{{if .Service}}
<tr>
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Service Interest</span>
<br>
<span style="font-size:14px;color:#1f2937">{{.Service}}</span>
</td>
</tr>
{{end}}
<tr>
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Received</span>
<br>
<span style="font-size:13px;color:#9ca3af">{{.Received}}</span>
</td>
</tr>
</table>
<!-- Message -->
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;margin:0 0 8px">Message</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;padding:6px 0">{{.Message}}</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:16px 28px 28px">
<p style="margin:0;font-size:11px;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:16px">
Delivered by <strong style="color:#6b7280">nuntius</strong> — a contact form backend for Linux servers.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateFeedback is the HTML body template for feedback-type forms.
var emailTemplateFeedback = template.Must(template.New("feedback").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#0f766e;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#5eead4;text-transform:uppercase;letter-spacing:.5px">New Feedback</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
</td></tr>
<tr><td style="padding:28px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#0f766e">{{.Email}}</span></p>
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Feedback</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateNewsletter is the HTML body template for newsletter-signup forms.
var emailTemplateNewsletter = template.Must(template.New("newsletter").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="460" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:20px 24px">
<p style="margin:0;font-size:18px;font-weight:700;color:#fff">{{.FormName}} — new subscriber</p>
</td></tr>
<tr><td style="padding:24px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 4px">Email</p>
<p style="font-size:16px;font-weight:600;color:#1e40af;margin:0 0 16px">{{.Email}}</p>
<p style="font-size:11px;color:#9ca3af;margin:16px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateGeneric is the HTML body template for generic forms.
var emailTemplateGeneric = template.Must(template.New("generic").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:.5px">Form Submission</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
</td></tr>
<tr><td style="padding:28px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#1e40af">{{.Email}}</span></p>
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Message</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
func compose(from, to string, req contactform.Request, formName, formType string) []byte {
received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC")
// --- HTML part ---
var htmlBuf bytes.Buffer
tmpl := emailTemplateContact
switch formType {
case "newsletter":
tmpl = emailTemplateNewsletter
case "feedback":
tmpl = emailTemplateFeedback
case "generic":
tmpl = emailTemplateGeneric
}
_ = tmpl.Execute(&htmlBuf, map[string]string{
"FormName": formName,
"Name": req.Name,
"Email": req.Email,
"Service": req.Service,
"Message": req.Message,
"Received": received,
})
// --- Subject + plain-text body per form type ---
var subject, text string
switch formType {
case "newsletter":
subject = fmt.Sprintf("[nuntius/%s] New newsletter subscriber", formName)
text = fmt.Sprintf(
"New Newsletter Subscriber: %s\r\n"+
"---\r\n"+
"Email: %s\r\n"+
"Received: %s\r\n"+
"\r\n"+
"Delivered by nuntius\r\n",
formName, req.Email, received,
)
case "feedback":
subject = fmt.Sprintf("[nuntius/%s] New feedback", formName)
text = fmt.Sprintf(
"New Feedback: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, received, req.Message,
)
case "generic":
subject = fmt.Sprintf("[nuntius/%s] New submission", formName)
text = fmt.Sprintf(
"New Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, received, req.Message,
)
default: // contact
subject = fmt.Sprintf("[nuntius/%s] Contact form submission", formName)
if req.Service != "" {
subject = fmt.Sprintf("[nuntius/%s][%s] Contact form submission", formName, req.Service)
}
text = fmt.Sprintf(
"Contact Form Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Service interest: %s\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, req.Service, received, req.Message,
)
}
// Assemble multipart/alternative message.
boundary := "nuntius-boundary"
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", req.Email))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary))
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(text)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(htmlBuf.String())
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
return msg.Bytes()
}
+269
View File
@@ -0,0 +1,269 @@
//go:build linux || freebsd
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"time"
"codeberg.org/petrbalvin/nuntius/internal/config"
"codeberg.org/petrbalvin/nuntius/internal/email"
"codeberg.org/petrbalvin/nuntius/internal/storage"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
// ContactHandler serves one or more contact forms, dispatched by URL path.
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
// Newsletter-type forms also get a per-form subscriber store.
type ContactHandler struct {
forms map[string]*config.Form
senders map[string]*email.FormSender
rateLimits map[string]*rateLimiter
stores map[string]*storage.NewsletterStore
}
// New constructs a ContactHandler that serves all forms defined in cfg.
func New(cfg *config.Config) *ContactHandler {
h := &ContactHandler{
forms: make(map[string]*config.Form, len(cfg.Forms)),
senders: make(map[string]*email.FormSender, len(cfg.Forms)),
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
stores: make(map[string]*storage.NewsletterStore, len(cfg.Forms)),
}
for i := range cfg.Forms {
f := &cfg.Forms[i]
h.forms[f.Path] = f
h.senders[f.Path] = email.NewFormSender(f)
h.rateLimits[f.Path] = newRateLimiter(f.RateLimitPerHour)
if f.Type == "newsletter" {
storePath := filepath.Join(cfg.DataDir, "newsletter-"+f.Name+".jsonl")
h.stores[f.Path] = storage.NewNewsletterStore(storePath)
}
}
return h
}
// Register mounts one POST + OPTIONS handler per form, plus a single
// GET /health handler.
func (h *ContactHandler) Register(mux *http.ServeMux) {
for path := range h.forms {
mux.HandleFunc("POST "+path, h.makeHandler(path))
mux.HandleFunc("OPTIONS "+path, h.makeHandler(path))
}
mux.HandleFunc("GET /health", h.Health)
}
// makeHandler returns the per-form HTTP handler.
func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
form := h.forms[path]
sender := h.senders[path]
limiter := h.rateLimits[path]
store := h.stores[path] // nil for non-newsletter forms
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// CORS preflight.
if r.Method == http.MethodOptions {
if formAllowed(form, origin) {
writeCORS(w, origin, form.AllowedOrigins)
}
w.WriteHeader(http.StatusNoContent)
return
}
// CORS on actual request.
if formAllowed(form, origin) {
writeCORS(w, origin, form.AllowedOrigins)
} else if origin != "" {
respondError(w, http.StatusForbidden, "origin_not_allowed", "")
return
}
// Rate limit.
if form.RateLimitPerHour > 0 {
ip := clientIP(r)
if !limiter.allow(ip) {
respondError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests, please try again later.")
return
}
}
// Parse body.
defer r.Body.Close()
var req contactform.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.")
return
}
// Honeypot: silently accept but never send.
if form.HoneypotField != "" && req.Honeypot != "" {
slog.Info("honeypot triggered, dropping silently",
"form", form.Name, "path", path, "ip", clientIP(r))
respondOK(w)
return
}
// Validate.
if errs := contactform.Validate(&req, form.Type); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: "validation",
Details: errs,
})
return
}
// Send email.
if err := sender.Send(req); err != nil {
slog.Error("send failed",
"err", err, "form", form.Name, "path", path, "ip", clientIP(r))
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send email.")
return
}
// For newsletter forms, also persist the subscriber to disk.
if store != nil {
if err := store.Append(storage.Subscriber{
Email: req.Email,
IP: clientIP(r),
Form: form.Name,
}); err != nil {
slog.Error("newsletter store append failed",
"err", err, "form", form.Name, "path", path, "ip", clientIP(r))
respondError(w, http.StatusInternalServerError, "storage_failed",
"Could not record subscription.")
return
}
}
slog.Info("message sent",
"form", form.Name, "path", path,
"service", req.Service, "ip", clientIP(r),
)
respondOK(w)
}
}
// Health handles GET /health.
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"forms": len(h.forms),
})
}
// --- internals ---
func formAllowed(form *config.Form, origin string) bool {
if origin == "" {
return true
}
for _, a := range form.AllowedOrigins {
if a == origin {
return true
}
}
return false
}
func writeCORS(w http.ResponseWriter, origin string, allowed []string) {
// Only echo the origin back if it is in the allowlist.
for _, a := range allowed {
if a == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
return
}
}
}
// rateLimiter is a per-IP token bucket.
type rateLimiter struct {
mu sync.Mutex
perHour int
buckets map[string]*bucket
}
type bucket struct {
tokens float64
last time.Time
}
func newRateLimiter(perHour int) *rateLimiter {
return &rateLimiter{
perHour: perHour,
buckets: make(map[string]*bucket),
}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
b, ok := r.buckets[ip]
if !ok {
b = &bucket{tokens: float64(r.perHour), last: now}
r.buckets[ip] = b
}
rate := float64(r.perHour) / 3600.0
elapsed := now.Sub(b.last).Seconds()
b.tokens = minF(b.tokens+elapsed*rate, float64(r.perHour))
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func minF(a, b float64) float64 {
if a < b {
return a
}
return b
}
func respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(contactform.Response{OK: true})
}
func respondError(w http.ResponseWriter, code int, err, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: err,
Message: msg,
})
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return xr
}
host := r.RemoteAddr
if i := strings.LastIndexByte(host, ':'); i >= 0 {
host = host[:i]
}
return host
}
+154
View File
@@ -0,0 +1,154 @@
//go:build linux || freebsd
// Package storage provides file-based persistence for nuntius.
//
// Today it contains only the newsletter subscriber log. It is designed
// to be zero-dependency (stdlib only) and crash-safe: each Append writes
// a single JSON line to an append-only file, so partial writes do not
// corrupt earlier records.
package storage
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Subscriber is a single newsletter signup, one JSON object per line.
type Subscriber struct {
Email string `json:"email"`
IP string `json:"ip,omitempty"`
Form string `json:"form"`
CreatedAt time.Time `json:"created_at"`
}
// NewsletterStore is a file-based append-only log of subscribers.
// All methods are safe for concurrent use.
type NewsletterStore struct {
path string
mu sync.Mutex
}
// NewNewsletterStore returns a store that appends to path.
// The file and its parent directory are created lazily on first Append.
func NewNewsletterStore(path string) *NewsletterStore {
return &NewsletterStore{path: path}
}
// Path returns the file path this store writes to.
func (s *NewsletterStore) Path() string {
return s.path
}
// Append writes sub as a single JSON line to the log.
// The file and parent directory are created on first call.
func (s *NewsletterStore) Append(sub Subscriber) error {
if sub.CreatedAt.IsZero() {
sub.CreatedAt = time.Now().UTC()
}
line, err := json.Marshal(sub)
if err != nil {
return fmt.Errorf("marshal subscriber: %w", err)
}
line = append(line, '\n')
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
}
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
if _, err := f.Write(line); err != nil {
return fmt.Errorf("write %s: %w", s.path, err)
}
return nil
}
// Count returns the number of valid subscriber lines in the log.
// Malformed lines are silently skipped so a partial write does not
// brick the entire file.
func (s *NewsletterStore) Count() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.countLocked()
}
func (s *NewsletterStore) countLocked() (int, error) {
f, err := os.Open(s.path)
if os.IsNotExist(err) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
n := 0
scanner := bufio.NewScanner(f)
// Allow up to 1 MB per line in case a single record balloons.
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
// Skip malformed lines rather than failing the whole count.
continue
}
if sub.Email != "" {
n++
}
}
if err := scanner.Err(); err != nil {
return n, fmt.Errorf("scan %s: %w", s.path, err)
}
return n, nil
}
// List returns all valid subscribers in insertion order.
// Use with care on large files; it reads the whole log into memory.
func (s *NewsletterStore) List() ([]Subscriber, error) {
s.mu.Lock()
defer s.mu.Unlock()
f, err := os.Open(s.path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
var out []Subscriber
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
continue
}
if sub.Email != "" {
out = append(out, sub)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %s: %w", s.path, err)
}
return out, nil
}
+153
View File
@@ -0,0 +1,153 @@
//go:build linux || freebsd
package storage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewsletterStore_AppendCreatesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs", "subscribers.jsonl")
s := NewNewsletterStore(path)
if err := s.Append(Subscriber{Email: "a@example.com", Form: "newsletter"}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file to exist, got %v", err)
}
}
func TestNewsletterStore_AppendAndCount(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
for i, e := range []string{"a@x.com", "b@x.com", "c@x.com"} {
if err := s.Append(Subscriber{Email: e, Form: "newsletter"}); err != nil {
t.Fatalf("Append #%d: %v", i, err)
}
}
n, err := s.Count()
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 3 {
t.Errorf("expected 3 subscribers, got %d", n)
}
}
func TestNewsletterStore_AppendIsOneLinePerRecord(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
for _, e := range []string{"a@x.com", "b@x.com"} {
if err := s.Append(Subscriber{Email: e, Form: "n"}); err != nil {
t.Fatalf("Append: %v", err)
}
}
// Each line should be a self-contained JSON object.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
count := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
t.Fatalf("Unmarshal line: %v", err)
}
count++
}
if err := scanner.Err(); err != nil {
t.Fatalf("scanner.Err: %v", err)
}
if count != 2 {
t.Errorf("expected 2 records, decoded %d", count)
}
}
func TestNewsletterStore_CountOnMissingFileIsZero(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.jsonl")
s := NewNewsletterStore(path)
n, err := s.Count()
if err != nil {
t.Fatalf("Count on missing file: %v", err)
}
if n != 0 {
t.Errorf("expected 0 on missing file, got %d", n)
}
}
func TestNewsletterStore_ListReturnsInsertionOrder(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
want := []string{"a@x.com", "b@x.com", "c@x.com"}
for _, e := range want {
_ = s.Append(Subscriber{Email: e, Form: "n"})
}
subs, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(subs) != len(want) {
t.Fatalf("expected %d, got %d", len(want), len(subs))
}
for i, sub := range subs {
if sub.Email != want[i] {
t.Errorf("position %d: want %q, got %q", i, want[i], sub.Email)
}
}
}
func TestNewsletterStore_SkipsMalformedLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
// Write a mix of valid and garbage lines.
content := `{"email":"good1@x.com","form":"n","created_at":"2026-01-01T00:00:00Z"}
this is not valid json
{"email":"good2@x.com","form":"n","created_at":"2026-01-02T00:00:00Z"}
{not even close to json
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewNewsletterStore(path)
n, err := s.Count()
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 2 {
t.Errorf("expected 2 valid records (skipping malformed), got %d", n)
}
}
func TestNewsletterStore_PathReturnsConfiguredPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "subs.jsonl")
s := NewNewsletterStore(path)
if got := s.Path(); got != path {
t.Errorf("Path: want %q, got %q", path, got)
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build linux || freebsd
// Package version exposes the nuntius release identity.
package version
// Name is the program name reported by `--version` and in log lines.
const Name = "nuntius"
// Version is the nuntius release version. Follows SemVer.
var Version = "1.0.0"
+27
View File
@@ -0,0 +1,27 @@
set dotenv-load := false
set positional-arguments := false
default:
@just --list
install:
@echo "→ Installing Go module dependencies…"
go mod tidy
go mod download
uninstall:
@echo "→ Removing ./bin…"
rm -rf bin
# Run the server in dev mode
run:
go run ./cmd/server
build:
@echo "→ Building for production…"
@mkdir -p bin
go build -ldflags="-s -w" -o bin/nuntius ./cmd/server
test:
@echo "→ Running tests…"
go test ./...
+33
View File
@@ -0,0 +1,33 @@
//go:build linux || freebsd
// Package contactform provides reusable types and validation
// for a JSON contact form. Import it into any Go project that
// needs a contact endpoint.
package contactform
// Request is the JSON body sent to the contact endpoint.
type Request struct {
Name string `json:"name"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
Honeypot string `json:"-"`
}
// Response is the success response.
type Response struct {
OK bool `json:"ok"`
}
// FieldError describes a single validation failure.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ErrorResponse is returned for any non-2xx response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details []FieldError `json:"details,omitempty"`
}
+111
View File
@@ -0,0 +1,111 @@
//go:build linux || freebsd
package contactform
import (
"net/mail"
"strings"
"unicode/utf8"
)
// validServices is the allow-list for contact-type forms.
var validServices = map[string]bool{
"": true,
"architecture": true,
"ai": true,
"infrastructure": true,
"software": true,
"unix": true,
"other": true,
}
// MinNameRunes is the minimum allowed length of a name.
const MinNameRunes = 2
// MaxNameRunes is the maximum allowed length of a name.
const MaxNameRunes = 100
// MinMessageRunes is the minimum allowed length of a message body.
const MinMessageRunes = 10
// MaxMessageRunes is the maximum allowed length of a message body.
const MaxMessageRunes = 5000
// Validate dispatches to the correct type-specific validator.
// Supported types: contact, feedback, newsletter, generic. Default is "contact".
func Validate(r *Request, formType string) []FieldError {
switch formType {
case "newsletter":
return validateNewsletter(r)
case "feedback", "generic":
return validateGeneric(r)
default:
return validateContact(r)
}
}
func validateContact(r *Request) []FieldError {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
r.Service = strings.TrimSpace(r.Service)
r.Message = strings.TrimSpace(r.Message)
var errs []FieldError
if n := utf8.RuneCountInString(r.Name); n < MinNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"})
} else if n > MaxNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"})
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if !validServices[r.Service] {
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
}
if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"})
} else if n > MaxMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"})
}
return errs
}
func validateNewsletter(r *Request) []FieldError {
r.Email = strings.TrimSpace(r.Email)
if _, err := mail.ParseAddress(r.Email); err != nil {
return []FieldError{{Field: "email", Message: "email is invalid"}}
}
return nil
}
func validateGeneric(r *Request) []FieldError {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
r.Message = strings.TrimSpace(r.Message)
var errs []FieldError
if n := utf8.RuneCountInString(r.Name); n < MinNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"})
} else if n > MaxNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"})
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"})
} else if n > MaxMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"})
}
return errs
}
+127
View File
@@ -0,0 +1,127 @@
//go:build linux || freebsd
package contactform
import (
"strings"
"testing"
)
func TestValidateContact_Happy(t *testing.T) {
r := &Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss a project.",
}
if errs := Validate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateContact_DefaultType(t *testing.T) {
// Unknown / empty type falls back to contact validation.
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
if errs := Validate(r, ""); len(errs) > 0 {
t.Fatalf("expected no errors with empty type, got %v", errs)
}
}
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
errs := Validate(r, "contact")
if len(errs) == 0 {
t.Fatal("expected error for invalid email")
}
if errs[0].Field != "email" {
t.Errorf("expected field=email, got %q", errs[0].Field)
}
}
func TestValidateContact_RejectsBadService(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
errs := Validate(r, "contact")
if len(errs) == 0 || errs[0].Field != "service" {
t.Fatalf("expected service error, got %v", errs)
}
}
func TestValidateFeedback_OmitsService(t *testing.T) {
// Feedback validates like generic: name, email, message; no service field.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
if errs := Validate(r, "feedback"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
errs := Validate(r, "generic")
if len(errs) == 0 || errs[0].Field != "message" {
t.Fatalf("expected message error, got %v", errs)
}
}
func TestValidateNewsletter_Happy(t *testing.T) {
r := &Request{Email: "jane@example.com"}
if errs := Validate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
r := &Request{Email: "garbage"}
errs := Validate(r, "newsletter")
if len(errs) == 0 {
t.Fatal("expected error for missing/invalid email")
}
}
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
// Newsletter type only cares about the email field.
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
if errs := Validate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
}
}
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
// validation, not to a hard error. The hard error is enforced at the
// config layer, not in Validate itself.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
if errs := Validate(r, "foo"); len(errs) > 0 {
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
}
}
func TestValidateTrimsWhitespace(t *testing.T) {
r := &Request{
Name: " Jane ",
Email: " jane@example.com ",
Service: " architecture ",
Message: " hello there ",
}
if errs := Validate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
t.Errorf("expected whitespace to be trimmed, got %+v", r)
}
}
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
// Sanity: the minLength error message in the en.json UI mirror should
// remain a simple string. Here we just check that error messages are
// human-readable, not empty.
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
errs := Validate(r, "contact")
if len(errs) < 2 {
t.Fatalf("expected at least 2 errors, got %v", errs)
}
for _, e := range errs {
if strings.TrimSpace(e.Message) == "" {
t.Errorf("error message is empty for field %q", e.Field)
}
}
}