feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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-*/
|
||||
@@ -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/...
|
||||
@@ -0,0 +1,9 @@
|
||||
# Build artifacts
|
||||
bin/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Local secrets and config
|
||||
.env*
|
||||
!.env.example
|
||||
config.toml
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Nuntius — A Small, Opinionated Contact Form Backend
|
||||
|
||||
[](LICENSE)
|
||||
[](https://go.dev)
|
||||
[](https://kernel.org)
|
||||
[](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)
|
||||
@@ -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 |
@@ -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 |
@@ -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:])
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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` (2–100 runes), `email` (RFC 5322), `service` (allow-list), `message` (10–5,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.
|
||||
@@ -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` — 2–100 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` — 10–5,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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module codeberg.org/petrbalvin/nuntius
|
||||
|
||||
go 1.26
|
||||
|
||||
require codeberg.org/petrbalvin/interpres v1.0.0
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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}"
|
||||
`)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 ./...
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user